diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index 5e77e35..490e72f 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -1,4 +1,14 @@ name: rainix-autopublish +# Merge-driven publish for LIBRARY repos — Soldeer, crates.io, npm, or any +# combination. The counterpart to rainix-tag-release's tag-driven publish for +# DEPLOY repos. +# +# What a consumer maintainer is bound by — which repo kind uses which workflow, +# how the publish version is derived, what a `next-v` intent tag does and when +# one is required, why foundry.toml carries no version, and the tag namespaces — +# is the rainix README's "Release lifecycle" section. That is the one statement +# of the contract, so this file does not restate it; the comments here are why +# each step is written the way it is. on: workflow_call: inputs: @@ -26,7 +36,7 @@ on: default: '' soldeer-package: description: >- - optional Soldeer registry package name (e.g. rain-erc). When set, the workflow publishes on a content change vs the newest published revision. The registry is the version ledger and git tags carry version intent: the publish version is `max(patch_bump(newest published), newest next-v tag merged into the pushed head)` under semver ordering — push a `next-v` tag for a deliberate minor/major jump; consumed or stale intent tags are inert under the max. First publish (no revisions on the registry yet) REQUIRES a next-v tag as the explicit version seed. foundry.toml carries NO release metadata: it is never read for a version and never rewritten, and an `[external.package]` / legacy `[package]` section still present in a consumer is simply ignored (excluded from the content hash), so deleting it is content-neutral. The workflow NEVER commits or pushes to the consumer branch: the `sol-v` tag + GitHub release are pushed as a tag ref, independent of any branch push — publishing works unchanged on branch-protected mains. A version's deploy-pin snapshot is built and committed by the PR that defines that version's content (main snapshots are frozen constants consumers pin against). + optional Soldeer registry package name (e.g. rain-erc). When set, the workflow publishes to the Soldeer registry on a content change vs the newest published revision, tags `sol-v` and creates a GitHub release. How the version is derived, what a `next-v` intent tag does, why foundry.toml carries no release metadata, and why nothing is committed or pushed to the consumer branch: rainix README, "Release lifecycle". required: false type: string default: '' diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 8eaaf1d..66e0779 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -4,22 +4,10 @@ name: rainix-tag-release # to rainix-autopublish's merge-driven publish for LIBRARY repos. # # The two lifecycles are mutually exclusive and a repo is strictly one or the -# other: -# -# * A LIBRARY repo (rainix-autopublish) publishes on content change at -# merge: the publish version is derived from the registry (newest -# published revision, patch-bumped) with `next-v` git tags carrying -# any larger version intent, foundry.toml holds no release metadata, and -# nothing is ever committed or pushed back to the branch. -# Consumers import its abstract surface (interfaces/libs); it never pins a -# deployed address, so it carries no per-tag deploy-pin snapshot. -# -# * A DEPLOY repo (this workflow) records deployed addresses. Its -# src/generated// snapshot pins the address + codehash of what it -# deployed, frozen so consumers can rely on them (enforced by the -# frozen-snapshots-append-only gate at PR time). [package].version is the -# LAST released version, and moves ONLY at release time, in lockstep with the -# snapshot it describes. +# other. Which repo is which, what each publishes, and the release procedure a +# maintainer follows are the rainix README's "Release lifecycle" section — the +# one statement of that contract, so it is not restated here. The comments in +# this file are why each step is written the way it is. # # The release is PUSH-FREE (rainlanguage/rainix#338). Deploy repo mains are # branch-protected, and rainix-autopublish was already made push-free in @@ -27,25 +15,16 @@ name: rainix-tag-release # commits and never pushes to any branch — a push to a protected main is rejected # (GH006) and would fail the release after publishing. # -# The release flow, in order: -# 1. Deploy on-chain (the repo's own human-driven rainix-manual-sol-artifacts -# dispatch — NOT this workflow; see below). -# 2. Open a PR that regenerates + commits the frozen src/generated// -# snapshot and bumps [package].version. That PR's normal CI runs the -# frozen-snapshots-append-only gate (rainix-sol-static) and the fork suite -# that asserts the live chain matches the pins (rainix-sol-test), so the -# deploy pins consumers trust are reviewed and verified BEFORE they publish. -# 3. A human merges the PR (normal protected-merge) and pushes a -# `sol-v` tag on the merged commit. -# 4. This workflow runs on that tag: it re-derives the version from the tag, -# re-runs the repo's NON-freezing generator and requires both a clean tree -# and a frozen snapshot byte-identical to the regenerated rolling one, to -# prove the tagged commit's snapshot is a fresh deterministic regeneration -# (the fail-closed publish guard below), -# re-attests the live chain matches the pins, then publishes to Soldeer and -# creates the GitHub release. main already carries the snapshot (from the -# merged PR), so the daily drift sweep and the repo's own snapshot tests keep -# reading src/generated// from main unchanged. +# This workflow is the LAST step of the release procedure the README describes: +# the deploy and the snapshot cut both happen before the tag exists. It +# re-derives the version from the tag, re-runs the repo's NON-freezing generator +# and requires both a clean tree and a frozen snapshot byte-identical to the +# regenerated rolling one — proving the tagged commit's snapshot is a fresh +# deterministic regeneration (the fail-closed publish guard below) — re-attests +# the live chain matches the pins, then publishes to Soldeer and creates the +# GitHub release. main already carries the snapshot from the reviewed PR that cut +# it, so the daily drift sweep and the repo's own snapshot tests keep reading +# src/generated// from main unchanged. # # Because the snapshot reaches main via the reviewed PR and never via this # workflow, the workflow has nothing to commit — it is read-only publish + tag + diff --git a/README.md b/README.md index 6888ad2..dbf3358 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,223 @@ jobs: Consumers needing only one of the three should call the individual reusable directly rather than this composite. +#### rainix-autopublish + +`.github/workflows/rainix-autopublish.yaml` is the LIBRARY repo release +workflow: it publishes on content change at merge, to Soldeer, crates.io, npm, +or any combination. Wrapper: + +```yaml +name: Package Release +on: + push: + branches: + - main +jobs: + release: + uses: rainlanguage/rainix/.github/workflows/rainix-autopublish.yaml@main + with: + soldeer-package: rain-lib-hash + secrets: inherit +``` + +The caller owns the trigger; a push to the release branch is the convention. + +A called workflow can only downgrade the caller's token, never elevate it, so +the grants this one needs have to reach it from the caller: `contents: write` to +push the `sol-v` tag and create the release, `actions: read` for the CI gate +that reads the commit's other workflow runs, and `id-token: write` for the npm +lane's OIDC publish. A caller that declares a `permissions:` block must list +every one it needs there. A caller that declares none — the example above — +inherits the repository's default `GITHUB_TOKEN` permissions, which covers it +only while those defaults are read-write; under read-only defaults the omission +fails at the tag push, the release, the gate, or npm auth, so such a repo has to +spell the block out. + +What each input means is documented on the input itself; what the workflow does +with it is [Release lifecycle](#release-lifecycle) below. + +#### rainix-tag-release + +`.github/workflows/rainix-tag-release.yaml` is the DEPLOY repo release workflow: +it verifies and publishes the commit a `sol-v` tag names. Wrapper: + +```yaml +name: Package Release +on: + push: + tags: + - sol-v* +jobs: + release: + uses: rainlanguage/rainix/.github/workflows/rainix-tag-release.yaml@main + with: + soldeer-package: rain-math-float-deploy + secrets: inherit +``` + +The caller's `tags:` filter decides which tags release; the workflow only parses +the version out of the ref. `secrets: inherit` carries `SOLDEER_API_TOKEN`, +which this workflow declares required, and the fork RPC secrets the verification +step needs. See [Release lifecycle](#release-lifecycle). + +### Release lifecycle + +A repo that publishes is strictly one of two kinds, and the kind fixes the +workflow, the trigger, and where the version comes from: + +| | library repo | deploy repo | +| ---------------------- | ---------------------------------------- | ------------------------- | +| workflow | `rainix-autopublish` | `rainix-tag-release` | +| trigger | push to the release branch | `sol-v` tag push | +| publishes | only if packaged content changed | always — the tag is it | +| version from | the Soldeer registry, raised by `next-v` | the tag | +| foundry.toml `version` | absent by design | the last released version | +| deploy pins | none — it pins no address | frozen `src/generated/` | + +A library publishes an abstract surface (interfaces, libs) and pins no deployed +address, so it carries no per-version snapshot. A deploy repo records addresses: +its `src/generated//` snapshot pins the address and codehash of what it +deployed, frozen so consumers can rely on them, which makes its release a human +decision about a deployment that already happened. + +Everything below about Soldeer is the Solidity lane. `rainix-autopublish` also +carries a cargo lane and an npm lane, which a library repo may use instead of or +alongside it; those gate on their own registry comparison (a normalized crate +content hash against crates.io, the `npm pack` shasum against the published one) +and take their version from the repo's own manifest via `cargo release` / +`npm version`, not from the rules below. + +#### Library repos: what publishes, and when + +Nothing publishes unless the packaged content changed. The Soldeer gate hashes +what `forge soldeer push --dry-run` would upload, minus two exclusions: +everything under `src/generated/` (derived from source, and a fresh directory +appears there every release, which would otherwise mark every merge as changed), +and `foundry.toml`'s `[external.package]` — or legacy `[package]` — section +together with the comment block attached above it. A push that changed nothing +short-circuits before the pre-publish test suite and the CI gate below. + +Nothing bumps, tags or publishes until every other workflow run on that same +commit has finished green. A commit with no other runs at all is an error, not a +pass. + +#### Library repos: where the version comes from + +The **Soldeer registry is the version ledger** — no file in the repo is. The +published version is + +``` +max(patch_bump(newest published revision), highest next-v tag merged into HEAD) +``` + +under semver ordering. Three consequences a maintainer has to hold: + +- **The default for every merge is a patch bump.** The pipeline never infers + semver from a diff. Delete an entire public library and it publishes as a + patch unless someone says otherwise. +- **`next-v` git tags are how someone says otherwise.** To cut a minor or + major, push `next-v` on the commit that defines that version's content, + before or as it lands on the release branch. The tag counts only while it is + reachable from the head being published, which `git tag --merged HEAD` + decides. That is why the release checkout is full-depth and why the gate + refuses to run on a shallow one rather than silently missing an intent tag. + Once the registry has passed it the tag falls inert under the `max`, so + consumed and stale intent tags need no cleanup. A `next-v` tag whose remainder + is not `..` fails the run loudly; a typo'd intent is + never skipped. +- **A package's first publish requires a `next-v` tag** as the explicit version + seed. With no revision on the registry there is nothing to patch-bump, and the + gate will not guess `0.1.0`. + +The tag is read once, from the checkout of the run that publishes, so both the +timing and the merge method matter — and neither way of getting them wrong goes +red: + +- A tag pushed **after** the merge is invisible to the run that just published. + That release ships as a patch, and the tag then raises whatever merges next, + mislabelling two versions rather than one. +- A tag on a PR head survives a **merge commit** and does not survive a squash + or rebase merge: the tagged commit never becomes an ancestor of the release + branch, so no run ever sees it. + +So push the tag on the PR head before the merge, and merge that PR with a merge +commit. There is no retroactive fix — once a version is published the registry +has it, and a repo that allows squash or rebase merges should turn them off if +it intends to use intent tags at all. + +So "this is a breaking change" is a claim only a human can make, by tagging +`next-v.0.0`. Nothing today fails a PR that changes the public surface +and ships it as a patch — see #327, which tracks that gate. + +#### Library repos: foundry.toml carries no version + +Deliberately. It is never read for a version and never rewritten, and its +release-metadata section is excluded from the content hash, so carrying, +editing, or deleting that section is content-neutral. A version added there +publishes nothing and means nothing. Do not add one back. + +#### Library repos: what gets written + +The Soldeer lane **never commits and never pushes to the branch**. The +`sol-v` tag and its GitHub release are pushed as a tag ref, independent +of any branch push, so publishing works unchanged on a branch-protected main. +The cargo and npm lanes do commit their version bump and push it, so a repo on +those lanes needs a branch its deploy key can write. + +#### Deploy repos: the release order + +The deploy, the snapshot and the publish are three separate steps, in this +order, and only the last is `rainix-tag-release`: + +1. **Deploy on-chain**, via the repo's own human-driven manual dispatch. It is + deliberately not part of the release workflow: a per-network, funds- and + RPC-dependent operation must not gate a one-shot tag publish where one + transient failure blocks the release. +2. **PR the snapshot.** That PR regenerates and commits the frozen + `src/generated//` deploy pins and bumps foundry.toml's + `[external.package].version` (the legacy `[package]` form is still read). Its + normal CI runs the append-only gate and the fork suite, so the pins consumers + will trust are reviewed and verified before they can publish. +3. **Merge it, then push `sol-v`** on the merged commit. The tag is the + release authorization, and it must be an ancestor of the release branch — a + tag cut from an unmerged branch is refused. +4. **The workflow verifies and publishes.** It re-attests the live chain matches + the freshly regenerated pins, requires the tagged commit's frozen snapshot to + be byte-identical to that regeneration, and only then publishes. Stale, + hand-edited and never-cut snapshots all fail there, before anything is + published. It writes to no branch either — main already carries the snapshot + from step 2. + +A deploy repo's `[package].version` **is** the last released version and moves +only at release time, in lockstep with the snapshot it describes — the opposite +of the library rule above. + +#### Tag namespaces + +The pipeline reads and writes exactly these: + +| tag | who | meaning | +| ------------------ | --------------------------------- | ------------------------- | +| `sol-v` | written by both release workflows | the published release | +| `next-v` | read only, never created by CI | library version intent | +| `-v` | written by the cargo lane | the published crate | +| `npm-` | written by the npm lane | the published npm package | + +On a deploy repo a `sol-v` push is also the release trigger, so creating one is +authorizing a release. + +Every other tag is invisible to the pipeline. In particular a bare `v` is +**not** an intent tag: the gate ignores every tag without the `next-v` prefix, +so pre-pipeline manual `v*` tags neither seed a version nor block one. A repo +whose first releases predate the pipeline therefore carries one version series +across two namespaces — `v*` for the manual publishes, `sol-v*` from the first +automated one — and a tool enumerating either prefix alone sees a truncated +history. + +Never move or delete a `sol-v*` or `next-v*` tag: one rewrites what a release +was, the other rewrites what the next one is numbered. + ### Fork RPC endpoints Each `_RPC_URL` is chosen at job start by the `rpc-preflight` composite