diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index b555387..3008d30 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -25,13 +25,13 @@ on: default: '' soldeer-package: description: >- - optional Soldeer registry package name (e.g. rain-erc). When set, the workflow runs the next-version release lifecycle: `[package].version` is the NEXT, in-development version (not yet on the registry). On a content change vs the published revision it publishes that version and bumps `[package].version` to the next, in one merge-driven process — no manual version bump. The bump does NOT generate per-release artifacts: a version's deploy-pin snapshot is built and committed by the PR that defines that version's content, so a next-version slot never carries a placeholder snapshot on main (main snapshots are frozen constants consumers pin against). + 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: the publish version is `max(patch_bump(newest published), [package].version)` under semver ordering, so the repo's version line is only an optional FLOOR — edit it in an ordinary PR for a deliberate minor/major jump. First publish (no revisions on the registry yet) uses the local version as-is. The workflow NEVER commits or pushes to the consumer branch: the version line is rewritten to the publish version in the CI checkout only (so the uploaded zip is self-consistent), and 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). required: false type: string default: '' soldeer-generate-cmd: description: >- - DEPRECATED / no-op. The version bump no longer generates per-release artifacts — a version's deploy-pin snapshot is built and committed by the PR that defines that version's content, never pre-generated onto main at bump time. Retained only so existing callers that still pass it do not error; it is ignored and will be removed. Consumers should drop it (and instead build snapshots in the PR, e.g. a pre-commit hook or a CI regenerate-and-verify check). + DEPRECATED / no-op. Publishing does not generate per-release artifacts — a version's deploy-pin snapshot is built and committed by the PR that defines that version's content, never generated onto main by this workflow. Retained only so existing callers that still pass it do not error; it is ignored and will be removed. Consumers should drop it (and instead build snapshots in the PR, e.g. a pre-commit hook or a CI regenerate-and-verify check). required: false type: string default: '' @@ -49,11 +49,25 @@ on: SOLDEER_API_TOKEN: required: false env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: release: if: ${{ !startsWith(github.event.head_commit.message, 'Package Release') }} runs-on: ubuntu-latest + # Serialize publish runs per caller repo + ref + soldeer package: without + # this, near-simultaneous pushes both derive the same registry version and + # the loser dies on Soldeer's duplicate rejection (harmless — no branch + # writes — but a red run). Job-level concurrency inside a called workflow + # is honored by GitHub (workflow-level would be ignored), so this covers + # consumers whose caller has no concurrency group of its own; callers that + # do carry one (e.g. a `package-release-` group) just serialize twice + # under distinct group names, which composes safely. + # cancel-in-progress false: a running publish is never killed mid-flight; + # a superseded PENDING job is replaced by the newest queued one, whose + # checkout contains everything the superseded push contained. + concurrency: + group: rainix-autopublish-${{ github.repository }}-${{ github.ref }}-${{ inputs.soldeer-package }} + cancel-in-progress: false permissions: id-token: write contents: write @@ -169,20 +183,22 @@ jobs: echo "new=$NEW" >> $GITHUB_OUTPUT if [ "$OLD" = "$NEW" ]; then echo "changed=false" >> $GITHUB_OUTPUT; else echo "changed=true" >> $GITHUB_OUTPUT; fi ' - # Soldeer content gate (next-version lifecycle). `[package].version` is the - # NEXT, unpublished version. `rainix-static soldeer-gate` compares the + # Soldeer content gate. `rainix-static soldeer-gate` compares the # NORMALIZED content of what `forge soldeer push --dry-run` would upload - # against the latest published revision, hashing SOURCE ONLY — everything + # against the newest published revision, hashing SOURCE ONLY — everything # under `src/generated/` (the per-release `/` snapshots and generated # aliasing libs) is excluded, since it is derived from source and a fresh # `/` dir appears every release, which would otherwise flag "changed" # on every merge and republish identical bytecode forever; foundry.toml's - # version line is blanked so a bump alone is never a content change. It - # fetches the registry + published zip, enforces the next-version invariant - # (the in-dev version must be AHEAD of what is published — else a prior - # run's failed bump-push is caught loud, not silently re-published), and - # emits changed / version / next. The gate logic is Rust, not inline bash - # or Python (rainix-static/src/main.rs); the workflow just runs it inside + # version line is blanked so a version edit alone is never a content + # change. It derives the publish version from the registry — + # max(patch_bump(newest published), local [package].version) under semver + # ordering, the local line being only a floor, and a first publish (no + # revisions yet) using the local version as-is — and emits + # changed / version. local == published is the normal steady state, not + # an error: nothing ever writes the version line back to the branch. The + # gate logic is Rust, not inline bash or Python + # (rainix-static/src/main.rs); the workflow just runs it inside # sol-shell, where forge and curl are on PATH. - name: Soldeer content gate if: ${{ inputs.soldeer-package != '' }} @@ -232,55 +248,60 @@ jobs: echo "NPM_VERSION=$NEW" >> $GITHUB_ENV git add package.json package-lock.json git commit -m "Package Release npm-${NEW}" - # Publish the CURRENT in-dev version (steps.soldeer.outputs.version) from - # the PRE-bump tree, so the package's contents match the version it is - # published under. Runs before the version bump below (which edits - # foundry.toml). If the later bump-commit push fails after this publishes, - # the next run's stale-toml guard fails loud with a clear action (bump the - # version). + # Publish the registry-derived version (steps.soldeer.outputs.version). + # foundry.toml's version line is rewritten to it in the CI checkout ONLY, + # so the uploaded zip is self-consistent; nothing commits or pushes the + # rewrite to the consumer branch (a following cargo/npm Tag-and-push + # discards it with `git checkout -- .` before rebasing). The rewrite is + # Rust (rainix-static soldeer-set-version), not sed. - name: Publish to Soldeer if: ${{ inputs.soldeer-package != '' && steps.soldeer.outputs.changed == 'true' }} env: SOLDEER_API_TOKEN: ${{ secrets.SOLDEER_API_TOKEN }} SOLDEER_PACKAGE: ${{ inputs.soldeer-package }} SOLDEER_VERSION: ${{ steps.soldeer.outputs.version }} - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer push "$SOLDEER_PACKAGE~$SOLDEER_VERSION" - # Bump [package].version to the next unpublished version and commit it as - # one "Package Release" (the prefix makes the job-level skip guard ignore - # the push this triggers). The bump does NOT generate per-release - # artifacts: a version's deploy-pin snapshot is built and committed by the - # PR that defines that version's content. So the next-version slot never - # carries a placeholder snapshot on main that a later PR would have to - # rewrite — snapshots on main are frozen constants that consumers pin - # against, and must never change. The bump is content-neutral (the version - # line is blanked in the change-gate hash) and leaves the deploy lib - # aliasing the last published tag, so main stays green until a PR builds the - # next version's snapshot. - - name: Bump Soldeer version + run: | + set -euo pipefail + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c rainix-static soldeer-set-version --version "$SOLDEER_VERSION" + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer push "$SOLDEER_PACKAGE~$SOLDEER_VERSION" + # Tag the published content — the commit that triggered this run — and + # push ONLY the tag ref. Deliberately decoupled from any branch push: + # consumer mains are commonly branch-protected, and the sol-v tag + + # release must not depend on a branch push being allowed. There is no + # soldeer bump commit at all; the branch is never written. + - name: Tag Soldeer release if: ${{ inputs.soldeer-package != '' && steps.soldeer.outputs.changed == 'true' }} - # Pass interpolations through env, never into the shell script directly - # (avoids GitHub Actions template injection). env: - SOLDEER_PACKAGE: ${{ inputs.soldeer-package }} - NEXT: ${{ steps.soldeer.outputs.next }} + SOLDEER_VERSION: ${{ steps.soldeer.outputs.version }} run: | set -euo pipefail - sed -i -E "0,/^version[[:space:]]*=.*/s//version = \"$NEXT\"/" foundry.toml - # --no-verify: an automated version bump must not be gated on the repo's - # pre-commit hooks. - git commit --no-verify foundry.toml -m "Package Release: soldeer $SOLDEER_PACKAGE $NEXT" - # Tag + push everything in one shot. cargo tags are namespaced - # -v; rebase onto any concurrent push first (only the - # shared Cargo.lock is expected to conflict). + git tag "sol-v$SOLDEER_VERSION" "${{ github.sha }}" + git push origin "refs/tags/sol-v$SOLDEER_VERSION" + # The soldeer GitHub release rides directly on the tag above, before any + # branch push, for the same decoupling reason. + - name: GitHub Release (soldeer) + if: ${{ inputs.soldeer-package != '' && steps.soldeer.outputs.changed == 'true' }} + uses: rainlanguage/rainix/.github/actions/gh-release@main + with: + tag-name: sol-v${{ steps.soldeer.outputs.version }} + name: Soldeer Release sol-v${{ steps.soldeer.outputs.version }} + github-token: ${{ secrets.GITHUB_TOKEN }} + # Tag + push the cargo/npm release commits in one shot. cargo tags are + # namespaced -v; rebase onto any concurrent push first + # (only the shared Cargo.lock is expected to conflict). Soldeer is absent + # here on purpose: it never creates a commit, and its tag is pushed by + # its own step above. - name: Tag and push - if: ${{ steps.cargo.outputs.changed == 'true' || steps.npm.outputs.changed == 'true' || steps.soldeer.outputs.changed == 'true' }} + if: ${{ steps.cargo.outputs.changed == 'true' || steps.npm.outputs.changed == 'true' }} run: | set -euo pipefail git fetch origin # Entering the devShell can rewrite generated tracked files (e.g. a - # nix-store pre-commit-config symlink), dirtying the tree. The release - # commit is already made, so restore any such leftovers before the - # rebase, or it aborts with "cannot rebase: You have unstaged changes". + # nix-store pre-commit-config symlink), and a soldeer publish leaves + # its checkout-only foundry.toml version rewrite behind — both dirty + # the tree. The release commit is already made, so restore all such + # leftovers before the rebase, or it aborts with "cannot rebase: You + # have unstaged changes". git checkout -- . if ! git rebase "origin/${{ github.ref_name }}"; then if [ -n "${{ inputs.crates }}" ] || [ -n "${{ inputs.crate }}" ]; then @@ -300,7 +321,6 @@ jobs: done fi if [ -n "${{ env.NPM_VERSION }}" ]; then git tag npm-${{ env.NPM_VERSION }}; fi - if [ "${{ steps.soldeer.outputs.changed }}" = "true" ]; then git tag sol-v${{ steps.soldeer.outputs.version }}; fi git push origin HEAD git push origin --tags # Package npm tarball for upload step. @@ -327,7 +347,8 @@ jobs: token: ${{ secrets.NPM_PUBLISH_PRIVATE_TOKEN }} access: public package: npm_package_${{ env.NPM_VERSION }}.tgz - # GitHub Releases. One per crate (loop), plus npm/soldeer. + # GitHub Releases. One per crate (loop), plus npm; the soldeer release + # is created earlier, right after its tag push. - name: GitHub Release (cargo) if: ${{ steps.cargo.outputs.changed == 'true' }} run: | @@ -346,10 +367,3 @@ jobs: name: NPM Package Release ${{ env.NPM_VERSION }} files: npm_package_${{ env.NPM_VERSION }}.tgz github-token: ${{ secrets.GITHUB_TOKEN }} - - name: GitHub Release (soldeer) - if: ${{ inputs.soldeer-package != '' && steps.soldeer.outputs.changed == 'true' }} - uses: rainlanguage/rainix/.github/actions/gh-release@main - with: - tag-name: sol-v${{ steps.soldeer.outputs.version }} - name: Soldeer Release sol-v${{ steps.soldeer.outputs.version }} - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index 1157a67..6e50f0f 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -2,7 +2,7 @@ name: rainix-copy-artifacts on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: copy-artifacts: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-artifacts.yaml b/.github/workflows/rainix-manual-sol-artifacts.yaml index c5ae6f4..8657632 100644 --- a/.github/workflows/rainix-manual-sol-artifacts.yaml +++ b/.github/workflows/rainix-manual-sol-artifacts.yaml @@ -83,7 +83,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-verify.yaml b/.github/workflows/rainix-manual-sol-verify.yaml index 75844dc..3ae4710 100644 --- a/.github/workflows/rainix-manual-sol-verify.yaml +++ b/.github/workflows/rainix-manual-sol-verify.yaml @@ -65,7 +65,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-static.yaml b/.github/workflows/rainix-rs-static.yaml index 96b7146..69077f7 100644 --- a/.github/workflows/rainix-rs-static.yaml +++ b/.github/workflows/rainix-rs-static.yaml @@ -2,7 +2,7 @@ name: rainix-rs-static on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: rs-static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-test.yaml b/.github/workflows/rainix-rs-test.yaml index 0d089b0..660d70d 100644 --- a/.github/workflows/rainix-rs-test.yaml +++ b/.github/workflows/rainix-rs-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-test on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: rs-test: strategy: diff --git a/.github/workflows/rainix-rs-wasm-test.yaml b/.github/workflows/rainix-rs-wasm-test.yaml index 354a0ec..9e02826 100644 --- a/.github/workflows/rainix-rs-wasm-test.yaml +++ b/.github/workflows/rainix-rs-wasm-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm-test on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: rs-wasm-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-wasm.yaml b/.github/workflows/rainix-rs-wasm.yaml index b7af393..f30ac80 100644 --- a/.github/workflows/rainix-rs-wasm.yaml +++ b/.github/workflows/rainix-rs-wasm.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: rs-wasm: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-legal.yaml b/.github/workflows/rainix-sol-legal.yaml index 82711e7..d661e3d 100644 --- a/.github/workflows/rainix-sol-legal.yaml +++ b/.github/workflows/rainix-sol-legal.yaml @@ -2,7 +2,7 @@ name: rainix-sol-legal on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: legal: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml index e893b6c..1ddce87 100644 --- a/.github/workflows/rainix-sol-static.yaml +++ b/.github/workflows/rainix-sol-static.yaml @@ -2,7 +2,7 @@ name: rainix-sol-static on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-test.yaml b/.github/workflows/rainix-sol-test.yaml index 6e015b6..a6522e6 100644 --- a/.github/workflows/rainix-sol-test.yaml +++ b/.github/workflows/rainix-sol-test.yaml @@ -23,7 +23,7 @@ on: RPC_URL_POLYGON_FORK: required: false env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-subgraph-test.yaml b/.github/workflows/rainix-subgraph-test.yaml index 2842161..4206fb5 100644 --- a/.github/workflows/rainix-subgraph-test.yaml +++ b/.github/workflows/rainix-subgraph-test.yaml @@ -2,7 +2,7 @@ name: rainix-subgraph-test on: workflow_call: env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: subgraph-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index a3a9375..fcd8fd0 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -6,9 +6,10 @@ name: rainix-tag-release # The two lifecycles are mutually exclusive and a repo is strictly one or the # other: # -# * A LIBRARY repo (rainix-autopublish) runs the next-version lifecycle: -# [package].version is the NEXT, unpublished version, one ahead of the -# registry; a content change on merge publishes it and bumps to the next. +# * 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 [package].version as an optional +# floor) 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. # @@ -19,11 +20,12 @@ name: rainix-tag-release # version, and moves ONLY at release time, in lockstep with the snapshot it # describes. # -# Running the next-version lifecycle on a deploy repo is the bug this exists to -# remove: autopublish bumps [package].version on every merge, while the frozen -# DEPLOY_TAG only advances at deploy time, so a version-vs-DEPLOY_TAG identity -# test (e.g. `testDeployTag`) is red on main between every merge and the next -# deploy — trained to be ignored, which is how a real regression rides through. +# Running the merge-driven lifecycle on a deploy repo is the bug this exists +# to remove: autopublish advances the published version on every content +# merge, while the frozen DEPLOY_TAG only advances at deploy time, so the +# repo's release identity (version-vs-DEPLOY_TAG, e.g. `testDeployTag`) +# breaks on main between every merge and the next deploy — trained to be +# ignored, which is how a real regression rides through. # # Here nothing moves on merge: a PR lands source only, main stays at the last # release (its live contracts still match its pins), and a human TAG is the sole @@ -101,7 +103,7 @@ on: RPC_URL_POLYGON_FORK: required: false env: - RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 + RAINIX_SHA: 558d5d82856d91a479d9d748017b0ab6b9f27a2d jobs: # The release tag must point at a commit already merged to the release branch. # `on: push: tags` fires for ANY tag, including one cut from an unmerged branch; diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index af1910b..80fc8b5 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -41,10 +41,19 @@ // ADDS a new , never edits an existing one. Needs the base ref // fetched with history (fetch-depth: 0 + `git fetch origin `). // soldeer-gate --package [--github-output ] -// Soldeer next-version content gate: compare the normalized content of what -// `forge soldeer push --dry-run` would upload against the latest published -// revision, and emit changed / version / next. Runs inside sol-shell, so -// `forge` and `curl` are on PATH. +// Soldeer content gate: compare the normalized content of what +// `forge soldeer push --dry-run` would upload against the newest published +// revision, derive the publish version from the registry +// (max(patch_bump(newest published), local [package].version) under +// semver ordering; the local version line is only a floor), and emit +// changed / version. Runs inside sol-shell, so `forge` and `curl` are +// on PATH. +// soldeer-set-version --version +// rewrite the cwd's foundry.toml first `version = "…"` line (the +// [package] version) to the given version, in place. rainix-autopublish +// runs it in the CI checkout just before `forge soldeer push`, so the +// uploaded zip carries the version it is published under; the rewrite is +// never committed or pushed. // rpc-preflight [--root ] [--github-env ] [--samples N] // [--timeout N] [--no-archive] // Pick a working fork RPC endpoint per network and export it as @@ -156,6 +165,13 @@ fn main() { .unwrap_or_else(|| fail("soldeer-gate: --package required")); soldeer_gate::run(&pkg, flag(&args, "--github-output").as_deref()); } + "soldeer-set-version" => { + let version = flag(&args, "--version") + .unwrap_or_else(|| fail("soldeer-set-version: --version required")); + if let Err(e) = soldeer_gate::set_version(Path::new("."), &version) { + fail(&e); + } + } "snapshots-append-only" => { let base = flag(&args, "--base").unwrap_or_else(|| "origin/main".to_string()); let root = flag(&args, "--root").unwrap_or_else(|| "src/generated".to_string()); @@ -197,7 +213,8 @@ fn main() { eprintln!( "rainix-static: unknown subcommand {other:?} \ (available: no-submodules, agent-context-cap, prompt-cap, \ - snapshots-append-only, soldeer-gate, rpc-preflight)" + snapshots-append-only, soldeer-gate, soldeer-set-version, \ + rpc-preflight)" ); std::process::exit(2); } diff --git a/rainix-static/src/soldeer_gate.rs b/rainix-static/src/soldeer_gate.rs index 57c708e..1b25ea5 100644 --- a/rainix-static/src/soldeer_gate.rs +++ b/rainix-static/src/soldeer_gate.rs @@ -1,9 +1,14 @@ -//! `soldeer-gate` — Soldeer next-version content gate. +//! `soldeer-gate` — Soldeer registry-derived content gate. //! //! Compares the normalized content of what `forge soldeer push --dry-run` would -//! upload against the latest published revision, enforces the next-version -//! ahead-invariant, and emits `changed` / `version` / `next`. Runs inside -//! sol-shell, so `forge` and `curl` are on PATH. +//! upload against the newest published revision, derives the publish version +//! from the registry (`max(patch_bump(newest published), local floor)` under +//! semver ordering), and emits `changed` / `version`. Runs inside sol-shell, +//! so `forge` and `curl` are on PATH. +//! +//! Also home to `soldeer-set-version`, which rewrites foundry.toml's version +//! line in the CI checkout so the published zip carries the version it is +//! published under; the workflow never commits or pushes that rewrite. use crate::fail; use sha2::{Digest, Sha256}; @@ -104,39 +109,133 @@ fn parse_ver(v: &str) -> Option<[u64; 3]> { Some([a, b, c]) } -/// True iff `a` is strictly ahead of `b` in version order. Fail-closed: an -/// unparseable operand is treated as NOT ahead. -fn ver_gt(a: &str, b: &str) -> bool { - match (parse_ver(a), parse_ver(b)) { - (Some(x), Some(y)) => x > y, - _ => false, +/// The version to publish. The registry is the authoritative version ledger: +/// its newest published revision, patch-bumped, is the baseline, and the +/// repo's `[package].version` is only a FLOOR — whichever is higher under +/// semver (numeric, not string) ordering wins. No published revision yet +/// means a first publish, which uses the local version as-is. A version that +/// does not parse as major.minor.patch is an error on either side: the local +/// floor is meaningless unless it can be ordered, and a published version +/// that cannot be ordered against must not be silently guessed past. +fn publish_version(local: &str, remote: Option<&str>) -> Result { + let l = parse_ver(local).ok_or_else(|| { + format!("foundry.toml [package].version ({local}) is not a major.minor.patch version") + })?; + let Some(r) = remote else { + return Ok(local.to_string()); + }; + let rv = parse_ver(r).ok_or_else(|| { + format!( + "published revision ({r}) is not a major.minor.patch version; \ + cannot derive the publish version from the registry" + ) + })?; + let patch = rv[2].checked_add(1).ok_or_else(|| { + format!("published revision ({r}) has patch u64::MAX; cannot patch-bump past it") + })?; + let bumped = [rv[0], rv[1], patch]; + Ok(if l > bumped { + local.to_string() + } else { + format!("{}.{}.{}", bumped[0], bumped[1], bumped[2]) + }) +} + +/// Rewrite the FIRST `[package].version` line (same first-match anchor as +/// `read_local_version`) to `version`, preserving every other byte. Errors on +/// a non-major.minor.patch `version` or when no version line exists. +fn set_first_version_line(content: &str, version: &str) -> Result { + if parse_ver(version).is_none() { + return Err(format!( + "version ({version}) is not a major.minor.patch version" + )); } + let mut out = String::with_capacity(content.len()); + let mut done = false; + for line in content.split_inclusive('\n') { + let (body, nl) = match line.strip_suffix('\n') { + Some(b) => (b, "\n"), + None => (line, ""), + }; + if !done && is_version_line(body) { + out.push_str(&format!("version = \"{version}\"")); + out.push_str(nl); + done = true; + } else { + out.push_str(line); + } + } + if !done { + return Err("foundry.toml has no [package].version line".to_string()); + } + Ok(out) } -/// The next unpublished version: bump `v`'s patch component. -fn bump_patch(v: &str) -> String { - let p = parse_ver(v).unwrap_or([0, 0, 0]); - format!("{}.{}.{}", p[0], p[1], p[2] + 1) +/// `soldeer-set-version`: rewrite `dir`/foundry.toml's version line in place. +/// Errors are returned, not exited on, so the caller (main) owns the process +/// exit and the unit tests stay a plain in-process assertion. +pub(crate) fn set_version(dir: &Path, version: &str) -> Result<(), String> { + let path = dir.join("foundry.toml"); + let content = + std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + let out = set_first_version_line(&content, version)?; + std::fs::write(&path, out).map_err(|e| format!("write {}: {e}", path.display())) } -/// Extract (latest published version, its zip url) from the Soldeer revision -/// API response. Either is None when absent. -fn parse_registry(json: &str) -> (Option, Option) { - let v: serde_json::Value = serde_json::from_str(json).unwrap_or(serde_json::Value::Null); - let d0 = v.get("data").and_then(|d| d.get(0)); - let ver = d0 - .and_then(|x| x.get("version")) - .and_then(|x| x.as_str()) - .map(str::to_string); - let url = d0 - .and_then(|x| x.get("url")) - .and_then(|x| x.as_str()) - .map(str::to_string); - (ver, url) +/// The newest published revision on the Soldeer registry: version + zip url. +#[derive(Debug, PartialEq)] +struct Revision { + version: String, + url: String, } -/// First `[package].version` value in foundry.toml (the in-dev, unpublished -/// version). Reads the value between the first pair of quotes on that line. +/// Decide what the registry said from the revision API's HTTP status + body. +/// Ok(Some(_)) is the newest published revision; Ok(None) is a genuine first +/// publish. Everything else — a non-registry HTTP status, unparseable JSON, +/// or a revision missing its version/url — is an error: a failed lookup must +/// never be mistaken for "nothing published yet". First publish has exactly +/// two shapes, both pinned against the live API: HTTP 404 carrying the +/// registry's own fail envelope ({"status":"fail"} — how it answers an +/// unknown project), and HTTP 200 with an explicitly empty data array (the +/// project exists with zero revisions). +fn registry_revision(status: u16, body: &str) -> Result, String> { + if status != 200 && status != 404 { + return Err(format!("soldeer registry returned HTTP {status}: {body}")); + } + let v: serde_json::Value = serde_json::from_str(body).map_err(|e| { + format!("soldeer registry returned HTTP {status} with unparseable JSON ({e}): {body}") + })?; + if status == 404 { + return if v.get("status").and_then(|s| s.as_str()) == Some("fail") { + Ok(None) + } else { + Err(format!( + "soldeer registry returned HTTP 404 without the registry's fail envelope: {body}" + )) + }; + } + let data = v + .get("data") + .and_then(|d| d.as_array()) + .ok_or_else(|| format!("soldeer registry response has no data array: {body}"))?; + let Some(d0) = data.first() else { + return Ok(None); + }; + let field = |k: &str| { + d0.get(k) + .and_then(|x| x.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("soldeer registry revision has no {k}: {body}")) + }; + Ok(Some(Revision { + version: field("version")?, + url: field("url")?, + })) +} + +/// First `[package].version` value in foundry.toml (the version FLOOR). +/// Reads the value between the first pair of quotes on that line. fn read_local_version(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("foundry.toml")).ok()?; for line in content.lines() { @@ -150,41 +249,32 @@ fn read_local_version(dir: &Path) -> Option { None } -/// Run the Soldeer content gate for `pkg` and emit changed / version / next. +/// Run the Soldeer content gate for `pkg` and emit changed / version. pub(crate) fn run(pkg: &str, gh_out: Option<&str>) { let dir = Path::new("."); let local = read_local_version(dir).unwrap_or_else(|| fail("foundry.toml has no [package].version")); - if parse_ver(&local).is_none() { - fail(&format!( - "foundry.toml [package].version ({local}) is not a major.minor.patch version" - )); - } - // Latest published revision (version + zip url); {} on any fetch failure. - let json = curl_stdout(&format!( + // Newest published revision (version + zip url) from the registry. A + // transport failure, non-registry HTTP status, or malformed response is a + // loud gate error — never a first publish (which would derive an + // already-published version and attempt an invalid upload). + let (status, body) = curl_status_body(&format!( "https://api.soldeer.xyz/api/v1/revision?project_name={pkg}&offset=0&limit=1" )) - .unwrap_or_else(|| "{}".to_string()); - let (remote, url) = parse_registry(&json); - - // Next-version invariant: the in-dev version must be AHEAD of what is - // published. If a prior run published but its bump-commit push failed, - // local would equal remote — fail loud with a clear action, not a silent - // re-publish / mis-bump. - if let Some(r) = &remote { - if !ver_gt(&local, r) { - fail(&format!( - "foundry.toml [package].version ({local}) is not ahead of the published revision ({r}); \ - the next-version lifecycle needs it to be the next UNPUBLISHED version — bump [package].version above {r}." - )); - } - } + .unwrap_or_else(|e| fail(&e)); + let remote = registry_revision(status, &body).unwrap_or_else(|e| fail(&e)); + + // The registry derives the publish version; the local version line is + // only a floor. local == published is the normal steady state (nothing + // ever writes the version line back to the branch). + let publish = publish_version(&local, remote.as_ref().map(|r| r.version.as_str())) + .unwrap_or_else(|e| fail(&e)); // Local package content: `forge soldeer push --dry-run` writes // .zip into the cwd. remove_cwd_zips(); - let spec = format!("{pkg}~{local}"); + let spec = format!("{pkg}~{publish}"); run_cmd( Command::new("forge").args(["soldeer", "push", &spec, "--dry-run"]), "forge soldeer push --dry-run", @@ -195,31 +285,38 @@ pub(crate) fn run(pkg: &str, gh_out: Option<&str>) { remove_cwd_zips(); // Published content, hashed the same way; "none" when nothing is published. - let old_hash = match (&remote, url.as_deref()) { - (Some(_), Some(u)) if !u.is_empty() => { + let old_hash = match &remote { + Some(rev) => { let tmp = std::env::temp_dir().join("soldeer_pub.zip"); run_cmd( - Command::new("curl").args(["-fsSL", u, "-o"]).arg(&tmp), + Command::new("curl") + .args(["-fsSL", &rev.url, "-o"]) + .arg(&tmp), "curl published zip", ); let mut pub_entries = read_zip(&tmp); let _ = std::fs::remove_file(&tmp); norm_hash(&mut pub_entries) } - _ => "none".to_string(), + None => "none".to_string(), }; let changed = old_hash != new_hash; - let next = bump_patch(&local); eprintln!( - "soldeer gate: remote={} publish={local} next={next} OLD={old_hash} NEW={new_hash}", - remote.as_deref().unwrap_or("none") + "soldeer gate: remote={} local={local} publish={publish} OLD={old_hash} NEW={new_hash}", + remote + .as_ref() + .map(|r| r.version.as_str()) + .unwrap_or("none") ); - emit( - gh_out, - &format!("changed={changed}\nversion={local}\nnext={next}\n"), - ); + emit(gh_out, &gate_output(changed, &publish)); +} + +/// The gate's machine output: the changed verdict and the derived publish +/// version, as GitHub-output key=value lines. +fn gate_output(changed: bool, publish: &str) -> String { + format!("changed={changed}\nversion={publish}\n") } /// Write key=value output lines to --github-output, or stdout when absent. @@ -248,12 +345,37 @@ fn run_cmd(cmd: &mut Command, what: &str) { } } -/// GET a URL with curl, returning its body on success. -fn curl_stdout(url: &str) -> Option { - let out = Command::new("curl").args(["-fsSL", url]).output().ok()?; - out.status - .success() - .then(|| String::from_utf8_lossy(&out.stdout).to_string()) +/// GET a URL with curl, returning (HTTP status, body). Deliberately NOT `-f`: +/// `-f` swallows the status and body of an HTTP-level failure, and the +/// registry answers an unknown project with a 404 whose body the caller must +/// see. `-w` appends the status code after the body on its own line. +/// Transport failures (spawn, DNS, connect, TLS) are errors. +fn curl_status_body(url: &str) -> Result<(u16, String), String> { + let out = Command::new("curl") + .args(["-sSL", "-w", "\n%{http_code}", url]) + .output() + .map_err(|e| format!("curl {url}: failed to spawn: {e}"))?; + if !out.status.success() { + return Err(format!( + "curl {url}: {} ({})", + String::from_utf8_lossy(&out.stderr).trim(), + out.status + )); + } + split_status_body(&String::from_utf8_lossy(&out.stdout)) +} + +/// Split curl `-w '\n%{http_code}'` stdout into (status, body): everything +/// after the LAST newline is the status code, everything before it the body. +fn split_status_body(stdout: &str) -> Result<(u16, String), String> { + let (body, code) = stdout + .rsplit_once('\n') + .ok_or_else(|| format!("curl output has no status-code line: {stdout}"))?; + let status = code + .trim() + .parse() + .map_err(|_| format!("curl status-code line ({code}) is not a number"))?; + Ok((status, body.to_string())) } /// Paths of `*.zip` files in the cwd. @@ -375,28 +497,229 @@ mod tests { } #[test] - fn version_parse_compare_bump() { + fn version_parse() { assert_eq!(parse_ver("1.2.3"), Some([1, 2, 3])); assert_eq!(parse_ver("1.2"), None); assert_eq!(parse_ver("1.2.3.4"), None); assert_eq!(parse_ver("1.2.x"), None); - assert!(ver_gt("0.1.2", "0.1.1")); - assert!(ver_gt("0.2.0", "0.1.9")); - assert!(!ver_gt("0.1.1", "0.1.1")); // equal is not ahead - assert!(!ver_gt("0.1.0", "0.1.1")); - assert!(!ver_gt("bad", "0.1.1")); // fail-closed - assert_eq!(bump_patch("0.1.2"), "0.1.3"); - assert_eq!(bump_patch("1.0.9"), "1.0.10"); } #[test] - fn registry_parse() { - let (v, u) = parse_registry(r#"{"data":[{"version":"1.2.3","url":"http://x/z.zip"}]}"#); - assert_eq!(v.as_deref(), Some("1.2.3")); - assert_eq!(u.as_deref(), Some("http://x/z.zip")); - assert_eq!(parse_registry("{}"), (None, None)); - assert_eq!(parse_registry(r#"{"data":[]}"#), (None, None)); - assert_eq!(parse_registry("not json"), (None, None)); + fn publish_version_first_publish_uses_local() { + assert_eq!(publish_version("0.1.0", None).unwrap(), "0.1.0"); + assert_eq!(publish_version("2.3.4", None).unwrap(), "2.3.4"); + } + + #[test] + fn publish_version_steady_state_patch_bumps_published() { + // local == newest published is the normal steady state (the workflow + // never writes the version line back); the registry drives the bump. + assert_eq!(publish_version("0.1.2", Some("0.1.2")).unwrap(), "0.1.3"); + } + + #[test] + fn publish_version_stale_low_local_is_ignored() { + // The version line is only a floor; the registry has moved past it. + assert_eq!(publish_version("0.1.0", Some("0.4.7")).unwrap(), "0.4.8"); + // Even a local BEHIND the published version is harmless. + assert_eq!(publish_version("0.1.0", Some("0.1.5")).unwrap(), "0.1.6"); + } + + #[test] + fn publish_version_local_ahead_wins_as_floor() { + // A deliberate minor/major jump in the repo outruns the registry bump. + assert_eq!(publish_version("0.2.0", Some("0.1.9")).unwrap(), "0.2.0"); + assert_eq!(publish_version("1.0.0", Some("0.9.9")).unwrap(), "1.0.0"); + } + + #[test] + fn publish_version_local_equal_to_bump_is_the_bump() { + assert_eq!(publish_version("0.1.3", Some("0.1.2")).unwrap(), "0.1.3"); + } + + #[test] + fn publish_version_orders_semver_not_strings() { + // 0.1.9 patch-bumps to 0.1.10, which orders ABOVE 0.1.9 numerically + // (a string compare would order "0.1.10" below "0.1.9"). + assert_eq!(publish_version("0.1.0", Some("0.1.9")).unwrap(), "0.1.10"); + assert_eq!(publish_version("0.1.9", Some("0.1.9")).unwrap(), "0.1.10"); + // A local floor of 0.1.10 beats a bumped 0.1.10 tie exactly. + assert_eq!(publish_version("0.1.10", Some("0.1.9")).unwrap(), "0.1.10"); + assert_eq!(publish_version("1.0.9", Some("1.0.9")).unwrap(), "1.0.10"); + } + + #[test] + fn publish_version_errors_on_patch_overflow() { + // A published patch of u64::MAX cannot be bumped: loud error, never a + // wraparound or panic — regardless of how high the local floor is. + let e = publish_version("0.1.0", Some("1.2.18446744073709551615")).unwrap_err(); + assert!(e.contains("18446744073709551615"), "{e}"); + assert!(e.contains("patch-bump"), "{e}"); + assert!(publish_version("9.9.9", Some("1.2.18446744073709551615")).is_err()); + // One below the boundary still bumps normally. + assert_eq!( + publish_version("0.1.0", Some("1.2.18446744073709551614")).unwrap(), + "1.2.18446744073709551615" + ); + } + + #[test] + fn publish_version_rejects_unparseable() { + assert!(publish_version("0.1.0", Some("garbage")).is_err()); + assert!(publish_version("garbage", None).is_err()); + assert!(publish_version("garbage", Some("0.1.0")).is_err()); + assert!(publish_version("0.1", Some("0.1.0")).is_err()); + } + + #[test] + fn set_version_line_rewrites_first_match_only() { + let src = "version = \"0.1.0\"\nversion = \"0.2.0\"\n"; + let out = set_first_version_line(src, "0.4.2").unwrap(); + assert_eq!(out, "version = \"0.4.2\"\nversion = \"0.2.0\"\n"); + } + + #[test] + fn set_version_line_preserves_everything_else() { + let src = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n# version = \"9\"\n"; + let out = set_first_version_line(src, "0.9.9").unwrap(); + assert_eq!( + out, + "[package]\nname = \"x\"\nversion = \"0.9.9\"\n# version = \"9\"\n" + ); + } + + #[test] + fn set_version_line_keeps_missing_trailing_newline() { + let out = set_first_version_line("version = \"1.0.0\"", "2.0.0").unwrap(); + assert_eq!(out, "version = \"2.0.0\""); + } + + #[test] + fn set_version_line_errors_without_version_line() { + assert!(set_first_version_line("[package]\nname = \"x\"\n", "1.0.0").is_err()); + } + + #[test] + fn set_version_line_rejects_non_semver() { + assert!(set_first_version_line("version = \"1.0.0\"\n", "not-a-version").is_err()); + assert!(set_first_version_line("version = \"1.0.0\"\n", "1.0").is_err()); + } + + #[test] + fn gate_output_emits_changed_and_publish_version() { + assert_eq!(gate_output(true, "0.1.3"), "changed=true\nversion=0.1.3\n"); + assert_eq!( + gate_output(false, "0.4.8"), + "changed=false\nversion=0.4.8\n" + ); + } + + #[test] + fn set_version_writes_foundry_toml() { + let d = tmp_dir(); + std::fs::write(d.join("foundry.toml"), "[package]\nversion = \"0.1.0\"\n").unwrap(); + set_version(&d, "0.9.9").unwrap(); + assert_eq!(read_local_version(&d).as_deref(), Some("0.9.9")); + } + + #[test] + fn registry_newest_revision_extracted() { + // Live API shape for a published project (extra fields present). + let rev = registry_revision( + 200, + r#"{"data":[{"version":"1.2.3","url":"http://x/z.zip","deleted":false,"downloads":7}],"status":"success"}"#, + ) + .unwrap() + .unwrap(); + assert_eq!( + rev, + Revision { + version: "1.2.3".to_string(), + url: "http://x/z.zip".to_string() + } + ); + } + + #[test] + fn registry_empty_revision_list_is_first_publish() { + // Project exists with zero revisions: the explicit empty data array. + assert_eq!( + registry_revision(200, r#"{"data":[],"status":"success"}"#).unwrap(), + None + ); + } + + #[test] + fn registry_unknown_project_404_is_first_publish() { + // Live API shape for a never-published project: HTTP 404 carrying the + // registry's own fail envelope. + assert_eq!( + registry_revision( + 404, + r#"{"message":"Project not found or access denied","status":"fail"}"# + ) + .unwrap(), + None + ); + } + + #[test] + fn registry_404_without_fail_envelope_is_an_error() { + // A 404 that is not the registry's own answer (an outage page, a + // proxy) must not be mistaken for "nothing published yet". + assert!(registry_revision(404, "not the registry").is_err()); + assert!(registry_revision(404, r#"{"data":[]}"#).is_err()); + assert!(registry_revision(404, r#"{"status":"success"}"#).is_err()); + } + + #[test] + fn registry_http_error_status_is_an_error() { + assert!(registry_revision(500, r#"{"status":"fail"}"#).is_err()); + assert!(registry_revision(502, "bad gateway").is_err()); + assert!(registry_revision(503, "").is_err()); + // A success-shaped body on an error status must not be trusted — + // neither as a revision nor as a first publish. + assert!( + registry_revision( + 500, + r#"{"data":[{"version":"1.2.3","url":"http://x/z.zip"}],"status":"success"}"# + ) + .is_err() + ); + assert!(registry_revision(500, r#"{"data":[],"status":"success"}"#).is_err()); + } + + #[test] + fn registry_malformed_response_is_an_error() { + assert!(registry_revision(200, "not json").is_err()); + assert!(registry_revision(200, "{}").is_err()); // no data array + assert!(registry_revision(200, r#"{"data":"x"}"#).is_err()); // data not an array + } + + #[test] + fn registry_revision_missing_fields_is_an_error() { + assert!(registry_revision(200, r#"{"data":[{"url":"http://x/z.zip"}]}"#).is_err()); + assert!(registry_revision(200, r#"{"data":[{"version":"1.2.3"}]}"#).is_err()); + assert!( + registry_revision(200, r#"{"data":[{"version":"","url":"http://x/z.zip"}]}"#).is_err() + ); + assert!(registry_revision(200, r#"{"data":[{"version":"1.2.3","url":""}]}"#).is_err()); + } + + #[test] + fn curl_output_splits_into_status_and_body() { + assert_eq!( + split_status_body("body\n200").unwrap(), + (200, "body".to_string()) + ); + // The LAST newline splits: bodies may contain newlines of their own. + assert_eq!( + split_status_body("{\"a\":1}\nmore\n404").unwrap(), + (404, "{\"a\":1}\nmore".to_string()) + ); + assert_eq!(split_status_body("\n404").unwrap(), (404, String::new())); + assert!(split_status_body("no-newline").is_err()); + assert!(split_status_body("body\nnot-a-number").is_err()); } #[test]