From 98f4b2baad5669a80c1e032a2bb4905fefbc2f80 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Wed, 16 Sep 2026 11:29:24 +0000 Subject: [PATCH 1/4] Document the release lifecycle where a consumer maintainer can read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library maintainer's whole contact with the release pipeline is the `uses: rainlanguage/rainix/.github/workflows/rainix-autopublish.yaml@main` line, and nothing it led to stated the contract they are bound by. The README documented nine reusables and not the two that decide what a consumer publishes; the rules existed only as implementation commentary in four partial copies, each carrying a different subset. README.md gains `#### rainix-autopublish` / `#### rainix-tag-release` alongside the other nine, and a `### Release lifecycle` section stating the contract once: the library/deploy split, what "content changed" is measured over, the registry as version ledger, `next-v` intent tags and the first-publish seed, that a breaking change is a major only because a human tagged it, foundry.toml carrying no version by design, what the Soldeer lane does and does not write, the deploy-repo release order, and the tag namespaces including why a bare `v*` tag is invisible to the gate. The workflow comments move rather than copy, so this adds no fifth partial copy: rainix-tag-release's library/deploy block and release procedure and rainix-autopublish's `soldeer-package` input description hand the contract to the README and keep only the mechanism-level "why" their own steps need. soldeer_gate.rs's module doc stays — different reader, different question. Every rule stated is verified against the workflows as written and against soldeer_gate.rs (`max_intent_tag`, `publish_version`, `require_full_history`, `strip_release_metadata`) and ci_gate.rs. Closes #381. Refs rainlanguage/rain.lib.hash#55. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- .github/workflows/rainix-autopublish.yaml | 12 +- .github/workflows/rainix-tag-release.yaml | 49 ++---- README.md | 185 ++++++++++++++++++++++ 3 files changed, 210 insertions(+), 36 deletions(-) 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..6cf4bc7 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,191 @@ 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 +caller that declares no `permissions:` block, as above, needs none — but one +that declares any must declare every grant the workflow uses (`contents: write`, +`actions: read`, and `id-token: write` for the npm lane), because a called +workflow can only downgrade the caller's token, never elevate it. 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 registry, raised by `next-v` | the tag | +| `[package].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. + +#### Library repos: what publishes, and when + +Nothing publishes unless the packaged content changed. The 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. An unchanged push short-circuits +before the test suite ever runs. + +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`. + +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 `[package].version`. 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 From 877fb3fa039bed86677d325dc4020e1aae764210 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Wed, 16 Sep 2026 11:33:28 +0000 Subject: [PATCH 2/4] Scope the Soldeer rules to the Solidity lane, name the current toml table Two accuracy fixes to the new section, both found reading it back against the workflow: `rainix-autopublish` is not Soldeer-only. Its cargo and npm lanes gate on their own registry comparison and take their version from the repo's own manifest via `cargo release` / `npm version`, so stating the registry-ledger rules as "library repos" made them false for a crate-shipping repo. They are now explicitly the Solidity lane, with one paragraph saying what the other two lanes do instead. Deploy repos: `release_guard` reads `[external.package]` as the current form and `[package]` as legacy, so the release order says the current one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- README.md | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6cf4bc7..69d4871 100644 --- a/README.md +++ b/README.md @@ -302,14 +302,14 @@ step needs. See [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 registry, raised by `next-v` | the tag | -| `[package].version` | absent by design | the last released version | -| deploy pins | none — it pins no address | frozen `src/generated/` | +| | 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: @@ -317,15 +317,22 @@ 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 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. An unchanged push short-circuits -before the test suite ever runs. +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 @@ -388,7 +395,8 @@ order, and only the last is `rainix-tag-release`: 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 `[package].version`. Its + `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 From 2f890cd537145d494935f9225ab0d00d615ed053 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Wed, 16 Sep 2026 11:36:10 +0000 Subject: [PATCH 3/4] State the two silent ways an intent tag is lost The section told a maintainer to push a next-v tag "before or as" the merge, which is not a rule they can follow. The tag is read once from the publishing run's checkout, so there are two ways to lose it and neither goes red: a tag pushed after the merge is invisible to the run that just published and then raises whatever merges next, and a tag on a PR head is never an ancestor of the release branch after a squash or rebase merge. Both observed and recorded downstream at rainlanguage/rain.lib.hash#59. The section now names them and gives the one form that works. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 69d4871..7e2ab0b 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,22 @@ under semver ordering. Three consequences a maintainer has to hold: 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. From 16bd2b44db2ace510ce86e117492e5152d2e6122 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Wed, 16 Sep 2026 11:58:14 +0000 Subject: [PATCH 4/4] Qualify the omitted permissions block, and say what each grant is for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section said a caller that declares no `permissions:` block "needs none", which is only true while the repository's default GITHUB_TOKEN permissions are read-write. Under read-only defaults the omission fails at the tag push, the release, the CI gate, or npm auth — and since a called workflow can only downgrade the caller's token, nothing in this repo can recover it. Now states the direction of the constraint first, then what each grant is actually for, then both caller shapes. Raised by CodeRabbit on #382. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- README.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7e2ab0b..dbf3358 100644 --- a/README.md +++ b/README.md @@ -265,13 +265,21 @@ jobs: secrets: inherit ``` -The caller owns the trigger; a push to the release branch is the convention. A -caller that declares no `permissions:` block, as above, needs none — but one -that declares any must declare every grant the workflow uses (`contents: write`, -`actions: read`, and `id-token: write` for the npm lane), because a called -workflow can only downgrade the caller's token, never elevate it. What each -input means is documented on the input itself; what the workflow does with it is -[Release lifecycle](#release-lifecycle) below. +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