-
Notifications
You must be signed in to change notification settings - Fork 3
feat(ci): rainix-tag-release — tag-triggered deploy-repo release #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,332 @@ | ||
| name: rainix-tag-release | ||
| # Tag-triggered release for DEPLOY repos (deploy + publish + snapshot together), | ||
| # the counterpart 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) 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. | ||
| # 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/<tag>/ snapshot pins the address + codehash of what it | ||
| # deployed, frozen so consumers can rely on them (enforced by the | ||
| # frozen-snapshots-append-only gate). [package].version is the LAST released | ||
| # 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. | ||
| # | ||
| # 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 | ||
| # release trigger. The tag names the version; this workflow regenerates the | ||
| # snapshot for it, verifies the live chain against the fresh pins, publishes to | ||
| # Soldeer, and commits the new (append-only) snapshot back to main so the daily | ||
| # drift sweep always has the current release's pins to check. | ||
| # | ||
| # A release is deploy + publish + snapshot as one act. The `deploy` job broadcasts | ||
| # every suite in dependency order (one forge run per suite, the Zoltu | ||
| # nonce-isolation the manual dispatch already enforces) by fanning | ||
| # rainix-manual-sol-artifacts over `deploy-suites` at max-parallel 1 — composing | ||
| # the existing deploy reusable rather than re-implementing its broadcast. The | ||
| # `release` job then `needs:` it, so publish/snapshot only run once the chain | ||
| # actually carries the code the pins name. Deploy is independent of the snapshot | ||
| # regeneration (both derive from the same deterministic bytecode), so it needs no | ||
| # shared filesystem with the release job — the regen recomputes the pins and the | ||
| # verify gate confirms they now resolve on-chain. | ||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| soldeer-package: | ||
| description: Soldeer registry package name to publish (e.g. st0x-deploy). | ||
| required: true | ||
| type: string | ||
| tag-prefix: | ||
| description: >- | ||
| Prefix stripped from the pushed tag to derive the release version, e.g. `sol-v` turns tag `sol-v0.1.29` into version `0.1.29`. The caller restricts which tags trigger the release via its own `on: push: tags` filter; this only parses the version out of the ref. | ||
| required: false | ||
| type: string | ||
| default: sol-v | ||
| snapshot-generate-cmd: | ||
| description: >- | ||
| Command that regenerates the deploy-pin snapshot from the (deterministic) bytecode into src/generated/<tag>/, DEPLOY_TAG and any pointer libs, then formats. Run after [package].version is set to the release version, so the generated tag matches it. e.g. `forge script ./script/BuildPointers.sol && forge fmt`. | ||
| required: true | ||
| type: string | ||
| test-cmd: | ||
| description: >- | ||
| Pre-publish verification gate. Run against the regenerated snapshot; for a deploy repo this is the fork suite that reads the live chain and asserts it matches the fresh pins, so a release that snapshots addresses the chain does not actually carry fails loud BEFORE publishing. Default `forge test`. | ||
| required: false | ||
| type: string | ||
| default: forge test | ||
| main-branch: | ||
| description: The branch the release snapshot is committed back to. | ||
| required: false | ||
| type: string | ||
| default: main | ||
| deploy-suites: | ||
| description: >- | ||
| JSON array of deploy suites IN DEPENDENCY ORDER, e.g. `["stox-receipt", "stox-wrapped-token-vault"]`. Each is broadcast in a separate forge run (Zoltu nonce isolation) sequentially. A later suite that references an earlier one fails loud on the on-chain dep-codehash check if run out of order, so ordering is enforced by the deploy, not just by this list. | ||
| required: true | ||
| type: string | ||
| deploy-script: | ||
| description: >- | ||
| Fully qualified forge deploy script (`path:Contract`), passed to rainix-manual-sol-artifacts. Defaults to the conventional `script/Deploy.sol:Deploy`. | ||
| required: false | ||
| type: string | ||
| default: script/Deploy.sol:Deploy | ||
| deploy-verify: | ||
| description: >- | ||
| Whether the deploy passes `--verify` to forge. Set false when broadcasting pinned historical creation code that no longer matches current source (Etherscan would reject it). Default true. | ||
| required: false | ||
| type: boolean | ||
| default: true | ||
| secrets: | ||
| PUBLISH_PRIVATE_KEY: | ||
| # A deploy key whose push events (unlike GITHUB_TOKEN pushes) trigger the | ||
| # downstream git-clean / rainix-sol workflows on the commit-back to main. | ||
| required: false | ||
| PRIVATE_KEY: | ||
| # The on-chain DEPLOYMENT key (distinct from PUBLISH_PRIVATE_KEY, the git | ||
| # deploy key). Broadcasts the suites. | ||
| required: false | ||
| EXPLORER_VERIFICATION_KEY: | ||
| required: false | ||
| CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY: | ||
| required: false | ||
| CI_DEPLOY_BASE_ETHERSCAN_API_KEY: | ||
| required: false | ||
| CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY: | ||
| required: false | ||
| CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY: | ||
| required: false | ||
| CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY: | ||
| required: false | ||
| CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: | ||
| required: false | ||
| CI_GIT_EMAIL: | ||
| required: false | ||
| CI_GIT_USER: | ||
| required: false | ||
| SOLDEER_API_TOKEN: | ||
| # This workflow always publishes, so a missing token is a setup error to | ||
| # catch before the deploy/regenerate work, not after. | ||
| required: true | ||
| CACHIX_AUTH_TOKEN: | ||
| required: false | ||
| RPC_URL_ARBITRUM_FORK: | ||
| required: false | ||
| RPC_URL_BASE_FORK: | ||
| required: false | ||
| RPC_URL_BASE_SEPOLIA_FORK: | ||
| required: false | ||
| RPC_URL_ETHEREUM_FORK: | ||
| required: false | ||
| RPC_URL_FLARE_FORK: | ||
| required: false | ||
| RPC_URL_POLYGON_FORK: | ||
| required: false | ||
| env: | ||
| RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 | ||
| 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; | ||
| # without this the later rebase would replay that branch's unreviewed commits | ||
| # onto main and push them, and the deploy would broadcast unreviewed bytecode. | ||
| # Gate both `deploy` and `release` on it. All refs come from built-in env vars, | ||
| # never interpolated into the shell, to avoid template injection. | ||
| guard: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: rainlanguage/rainix/.github/actions/checkout@main | ||
| - name: Tag must be a tag on the release branch | ||
| env: | ||
| MAIN: ${{ inputs.main-branch }} | ||
| run: | | ||
| set -euo pipefail | ||
| case "$GITHUB_REF" in | ||
| refs/tags/*) : ;; | ||
| *) echo "::error::rainix-tag-release must be triggered by a tag push, got $GITHUB_REF" >&2; exit 1 ;; | ||
| esac | ||
| # The shared checkout is shallow; unshallow so the ancestry test can see | ||
| # whether the tag commit is on the release branch. | ||
| if [ -f "$(git rev-parse --git-dir)/shallow" ]; then | ||
| git fetch --no-tags --unshallow origin | ||
| fi | ||
| git fetch --no-tags origin "$MAIN" | ||
| if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$MAIN"; then | ||
| echo "::error::tag $GITHUB_REF_NAME ($GITHUB_SHA) is not on origin/$MAIN — refusing to release an unmerged commit" >&2 | ||
| exit 1 | ||
| fi | ||
| # Broadcast every suite in dependency order, one forge run each (Zoltu nonce | ||
| # isolation), by fanning the existing deploy reusable over `deploy-suites` at | ||
| # max-parallel 1. Runs before `release`, so publish/snapshot only happen once | ||
| # the chain carries the code the pins name. `verify:false`-capable via the | ||
| # `deploy-verify` input for pinned historical bytecode. | ||
| deploy: | ||
| needs: guard | ||
| strategy: | ||
| max-parallel: 1 | ||
| matrix: | ||
| suite: ${{ fromJSON(inputs.deploy-suites) }} | ||
| uses: rainlanguage/rainix/.github/workflows/rainix-manual-sol-artifacts.yaml@main | ||
| with: | ||
| suite: ${{ matrix.suite }} | ||
| script: ${{ inputs.deploy-script }} | ||
| verify: ${{ inputs.deploy-verify }} | ||
| secrets: | ||
| PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} | ||
| CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} | ||
| EXPLORER_VERIFICATION_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY }} | ||
| RPC_URL_ARBITRUM_FORK: ${{ secrets.RPC_URL_ARBITRUM_FORK }} | ||
| RPC_URL_BASE_FORK: ${{ secrets.RPC_URL_BASE_FORK }} | ||
| RPC_URL_BASE_SEPOLIA_FORK: ${{ secrets.RPC_URL_BASE_SEPOLIA_FORK }} | ||
| RPC_URL_ETHEREUM_FORK: ${{ secrets.RPC_URL_ETHEREUM_FORK }} | ||
| RPC_URL_FLARE_FORK: ${{ secrets.RPC_URL_FLARE_FORK }} | ||
| RPC_URL_POLYGON_FORK: ${{ secrets.RPC_URL_POLYGON_FORK }} | ||
| CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY }} | ||
| CI_DEPLOY_BASE_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_BASE_ETHERSCAN_API_KEY }} | ||
| CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY }} | ||
| CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY }} | ||
| CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY }} | ||
| CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_FLARE_ETHERSCAN_API_KEY }} | ||
| release: | ||
| needs: deploy | ||
| runs-on: ubuntu-latest | ||
| # contents: write for the commit-back to main + the gh-release composite. No | ||
| # id-token: nothing here uses OIDC (Soldeer uses SOLDEER_API_TOKEN, the release | ||
| # uses GITHUB_TOKEN). | ||
| permissions: | ||
| contents: write | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| steps: | ||
| # Deploy-key checkout so the commit-back to main can push and trigger | ||
| # downstream workflows. The pinned checkout / cache-nix / nix-install / | ||
| # Cachix SHAs all live once in the composites; the nix preamble then runs | ||
| # with checkout:'false'. | ||
| - uses: rainlanguage/rainix/.github/actions/checkout@main | ||
| with: | ||
| ssh-key: ${{ secrets.PUBLISH_PRIVATE_KEY }} | ||
| - uses: rainlanguage/rainix/.github/actions/nix-cachix-setup@main | ||
| with: | ||
| cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} | ||
| checkout: 'false' | ||
| - name: Git config | ||
| run: | | ||
| git config --global user.email "${{ secrets.CI_GIT_EMAIL || 'github-actions[bot]@users.noreply.github.com' }}" | ||
| git config --global user.name "${{ secrets.CI_GIT_USER || 'github-actions[bot]' }}" | ||
| # Entering the devShell writes a generated .pre-commit-config.yaml into | ||
| # the tree; hide it via the local exclude so it never dirties the release | ||
| # commit (repo-agnostic, no consumer needs to .gitignore it). | ||
| echo ".pre-commit-config.yaml" >> .git/info/exclude | ||
| - name: Install soldeer dependencies | ||
| if: ${{ hashFiles('soldeer.lock') != '' }} | ||
| run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer install | ||
| - name: Resolve release version from the tag | ||
| # Strip the caller's tag-prefix; a tag that does not carry it is a | ||
| # misconfigured trigger, not a release. | ||
| env: | ||
| TAG_PREFIX: ${{ inputs.tag-prefix }} | ||
| run: | | ||
| set -euo pipefail | ||
| TAG="${GITHUB_REF_NAME}" | ||
| case "$TAG" in | ||
| "$TAG_PREFIX"*) VERSION="${TAG#"$TAG_PREFIX"}" ;; | ||
| *) echo "::error::tag '$TAG' does not start with tag-prefix '$TAG_PREFIX'" >&2; exit 1 ;; | ||
| esac | ||
| # Require MAJOR.MINOR.PATCH. Beyond rejecting junk tags (sol-vfoo), this | ||
| # guarantees VERSION carries no characters special to the sed below | ||
| # (`&`, `\`, `/`), so it cannot corrupt foundry.toml. | ||
| if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then | ||
| echo "::error::version '$VERSION' from tag '$TAG' is not MAJOR.MINOR.PATCH" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "VERSION=$VERSION" >> "$GITHUB_ENV" | ||
| echo "Release version: $VERSION" | ||
| - name: Set foundry.toml version to the release version | ||
| # The tag names the version; set it BEFORE regenerating so the snapshot's | ||
| # DEPLOY_TAG bakes in the same value (a version-vs-DEPLOY_TAG identity test | ||
| # then holds by construction). Targets the first `version =` line, which is | ||
| # [package].version. | ||
| run: | | ||
| set -euo pipefail | ||
| sed -i -E "0,/^version[[:space:]]*=.*/s//version = \"${VERSION}\"/" foundry.toml | ||
| # Fail loud if the substitution matched nothing (no [package].version line | ||
| # to move) rather than silently releasing an unchanged version. | ||
| grep -qxE "version = \"${VERSION}\"" foundry.toml || { | ||
| echo "::error::foundry.toml has no [package] version line to set to ${VERSION}" >&2 | ||
| exit 1 | ||
| } | ||
| - name: Regenerate the deploy-pin snapshot | ||
| # Deterministic: the pins are computed from bytecode (address = f(bytecode) | ||
| # under CREATE2), so this needs no chain access and produces the exact | ||
| # src/generated/<tag>/ the release publishes and commits. | ||
| run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c '${{ inputs.snapshot-generate-cmd }}' | ||
| - name: Commit the release snapshot | ||
| # Commit BEFORE the append-only gate and Soldeer push so both operate on a | ||
| # clean, inspectable tree. The commit is what lands on main below. | ||
| run: | | ||
| set -euo pipefail | ||
| # Stage the release: the foundry.toml version bump AND the regenerated | ||
| # snapshot (tracked edits + the new src/generated/<version>/). Do NOT | ||
| # `git checkout` first — that would revert the version bump and any | ||
| # regenerated tracked file, leaving a commit that misses them and breaks | ||
| # the version/snapshot lockstep. The one devShell leftover | ||
| # (.pre-commit-config.yaml) is already hidden via .git/info/exclude in the | ||
| # Git config step, so `git add -A` will not stage it. | ||
| git add -A | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if git diff --cached --quiet; then | ||
| echo "::error::snapshot regeneration produced no changes for ${VERSION}; nothing to release" >&2 | ||
| exit 1 | ||
| fi | ||
| git commit --no-verify -m "Package Release: soldeer ${{ inputs.soldeer-package }} ${VERSION}" | ||
| - name: Enforce append-only snapshots | ||
| # The release must only ADD src/generated/<version>/; a frozen snapshot for | ||
| # an already-released tag must never change (consumers pin its constants). | ||
| uses: rainlanguage/rainix/.github/actions/frozen-snapshots-append-only@main | ||
| - name: Verify live chain matches the fresh pins | ||
| # The `deploy` job (needs:) has broadcast every suite; this gate confirms | ||
| # it actually landed, so a snapshot of addresses the | ||
| # chain does not carry never gets published. | ||
| env: | ||
| RPC_URL_ARBITRUM_FORK: ${{ secrets.RPC_URL_ARBITRUM_FORK }} | ||
| RPC_URL_BASE_FORK: ${{ secrets.RPC_URL_BASE_FORK }} | ||
| RPC_URL_BASE_SEPOLIA_FORK: ${{ secrets.RPC_URL_BASE_SEPOLIA_FORK }} | ||
| RPC_URL_ETHEREUM_FORK: ${{ secrets.RPC_URL_ETHEREUM_FORK }} | ||
| RPC_URL_FLARE_FORK: ${{ secrets.RPC_URL_FLARE_FORK }} | ||
| RPC_URL_POLYGON_FORK: ${{ secrets.RPC_URL_POLYGON_FORK }} | ||
| run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c '${{ inputs.test-cmd }}' | ||
| - name: Publish to Soldeer | ||
| # Pushes the working tree (== the release commit's tree, snapshot present) | ||
| # under the exact version the tag names. | ||
| env: | ||
| SOLDEER_API_TOKEN: ${{ secrets.SOLDEER_API_TOKEN }} | ||
| SOLDEER_PACKAGE: ${{ inputs.soldeer-package }} | ||
| run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer push "$SOLDEER_PACKAGE~$VERSION" | ||
| - name: Commit the snapshot back to main | ||
| # main carries the CURRENT release's pins so the daily drift sweep has live | ||
| # constants to check. The tag was made on the main tip; rebase in any | ||
| # concurrent move first, then push the release commit to main. src/generated | ||
| # is append-only (a new <version>/ dir), so a concurrent release is the only | ||
| # thing that could conflict, and it fails loud rather than silently. | ||
| env: | ||
| MAIN: ${{ inputs.main-branch }} | ||
| run: | | ||
| set -euo pipefail | ||
| git fetch --no-tags origin "$MAIN" | ||
| if ! git rebase "origin/$MAIN"; then | ||
| echo "::error::release commit does not rebase cleanly onto origin/$MAIN (concurrent release?); resolve manually" >&2 | ||
| exit 1 | ||
| fi | ||
| git push origin "HEAD:$MAIN" | ||
|
thedavidmeister marked this conversation as resolved.
|
||
| - name: GitHub Release | ||
| uses: rainlanguage/rainix/.github/actions/gh-release@main | ||
| with: | ||
| tag-name: ${{ github.ref_name }} | ||
| name: Release ${{ github.ref_name }} | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Declare explicit least-privilege permissions on
guardanddeploy.Neither job declares a
permissions:block, so both fall back to the runner/org default token permissions (flagged by zizmor for thedeployjob, which also grants those defaults to the calledrainix-manual-sol-artifacts.yamlreusable workflow). Only read access to repo contents is needed here;releasealready does this correctly withcontents: write.🔒 Proposed fix
guard: runs-on: ubuntu-latest + permissions: + contents: read steps:deploy: needs: guard + permissions: + contents: read strategy: max-parallel: 1Also applies to: 172-198
🤖 Prompt for AI Agents
Source: Linters/SAST tools