diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa09402633..f0509d91cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,6 +260,16 @@ jobs: version: v2.12.2 args: --timeout=5m + - name: release workflow contracts + run: | + go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 \ + -ignore 'label "windows-11-arm" is unknown' \ + .github/workflows/release-stable.yml \ + .github/workflows/release.yml \ + .github/workflows/release-npm.yml \ + .github/workflows/release-desktop.yml + bash scripts/release-workflows.test.sh + # The site/ auth client has security-sensitive redirect-validation logic # (safeNext) covered by node:test unit tests. Those tests use only Node # builtins, so no `npm install` is needed — run them directly on every PR so diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 21151e8284..b64e3329ce 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -1,9 +1,9 @@ name: Release desktop -# Desktop (Wails) release line. Tag namespace `desktop-v` triggers this — -# plain `v` tags are the CLI release (release.yml) and intentionally do NOT -# trigger here (GitHub glob `v*` does not match `desktop-v*`). Bump with: -# git tag desktop-vX.Y.Z && git push origin desktop-vX.Y.Z +# Desktop (Wails) release line. Stable releases are called by +# release-stable.yml after its single GitHub environment approval. Direct tag +# pushes are retained only for desktop prereleases so they can still ship +# independently. # # Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native # runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a @@ -11,14 +11,15 @@ name: Release desktop # release and mirrored to R2 (the updater reads R2 first, then the crash worker # release gateway; stable desktop releases own GitHub's repository-wide "latest"). # -# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag -# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/ -# pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary +# The same build/sign/manifest pipeline serves two channels. An orchestrated +# `desktop-v*` tag (or manual stable recovery) publishes a GitHub release and +# moves R2's latest/ pointer. A manual `channel: canary` dispatch builds with +# -X main.channel=canary # and uploads only to R2's canary/ pointer — no GitHub release, so canary never # shows on the releases page and never touches latest/. on: push: - tags: ["desktop-v*"] + tags: ["desktop-v*-*"] workflow_dispatch: inputs: channel: @@ -34,16 +35,85 @@ on: description: "canary: intended next stable version, e.g. 1.5.0" required: false type: string + workflow_call: + inputs: + channel: + description: "Release channel selected by the approved orchestrator" + required: true + type: string + tag: + description: "Existing desktop tag to publish" + required: false + default: "" + type: string + base_version: + description: "Base version used for canary builds" + required: false + default: "" + type: string + approved_cli_tag: + description: "Stable CLI tag recorded by the approved orchestrator" + required: true + type: string + approved_sha: + description: "Immutable commit recorded by the approved orchestrator" + required: true + type: string + orchestrated: + description: "True only when called by release-stable.yml after approval" + required: false + default: false + type: boolean permissions: contents: write # create the release and upload artifacts jobs: + orchestration-guard: + name: verify approved orchestrator + if: ${{ inputs.orchestrated }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.approved_sha }} + - name: Verify caller and approved release ref + env: + ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }} + CALLER_EVENT_NAME: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_REF_PROTECTED: ${{ github.ref_protected }} + CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }} + APPROVED_SHA: ${{ inputs.approved_sha }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + bash scripts/verify-release-authorization.sh + bash scripts/verify-release-tag.sh + + release-gate: + name: approve standalone desktop release + if: ${{ !inputs.orchestrated && !(github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') }} + runs-on: ubuntu-latest + environment: release + steps: + - env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: echo "Approved standalone desktop release $RELEASE_TAG" + cache-guard: name: cache hit guard + needs: [orchestration-guard, release-gate] + if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') || needs.release-gate.result == 'success'))) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - uses: actions/setup-go@v6 with: @@ -79,6 +149,8 @@ jobs: shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - uses: actions/setup-go@v6 with: @@ -140,7 +212,11 @@ jobs: security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null # Prepend the signing keychain to the search list so codesign / find-identity see it. - security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + existing_keychains=() + while IFS= read -r keychain; do + [ -n "$keychain" ] && existing_keychains+=("$keychain") + done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//') + security list-keychains -d user -s "$KEYCHAIN" "${existing_keychains[@]}" echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8" rm -f "$RUNNER_TEMP/cert.p12" @@ -174,8 +250,9 @@ jobs: # the manifest SHA must be computed over the signed build. SignPath pulls the file # from the Actions artifact API, so it must be uploaded first. Canary signs with the # test certificate; stable/rc sign with the Foundation release certificate, but only - # once SIGNPATH_RELEASE_SIGNING_READY is set — until that cert clears CSR-pending, - # stable ships unsigned instead of failing the whole release on an invalid policy. + # once SIGNPATH_RELEASE_SIGNING_READY is set. The release-signing policy verifies the + # trusted GitHub origin and intentionally retains its manual approval process as a + # second-party control. Each Windows architecture can require its own confirmation. - name: Upload unsigned installer for SignPath if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') id: unsigned-installer @@ -198,8 +275,8 @@ jobs: github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }} github-token: ${{ github.token }} wait-for-completion: true - # The policy uses an approval process — leave headroom for a human to approve - # the request (the approver gets an email on submit) before the job gives up. + # Leave headroom for the manual SignPath confirmation, origin verification, + # malware scanning, and signing. wait-for-completion-timeout-in-seconds: 1800 output-artifact-directory: signed @@ -224,11 +301,13 @@ jobs: name: publish release needs: build runs-on: ubuntu-latest - # Stable publishes go through the `release` environment (esengine must - # approve); canary uses the open `canary` environment so maintainers self-serve. - environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }} + # The stable caller has already passed the single GitHub release approval. + # Direct prereleases/manual stable recovery pass release-gate; canaries run + # free. Windows signing retains its separate SignPath confirmations. steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - uses: actions/setup-go@v6 with: @@ -270,6 +349,13 @@ jobs: if: steps.ver.outputs.channel != 'canary' run: node scripts/release-notes.mjs render --version "${{ steps.ver.outputs.version }}" --output /tmp/release-notes.md + - name: Revalidate approved release ref + if: ${{ inputs.orchestrated }} + env: + RELEASE_TAG: ${{ inputs.tag }} + APPROVED_SHA: ${{ inputs.approved_sha }} + run: bash scripts/verify-release-tag.sh + - name: Publish GitHub release if: steps.ver.outputs.channel != 'canary' env: @@ -306,6 +392,8 @@ jobs: HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - name: Resolve version id: ver @@ -318,6 +406,13 @@ jobs: REF_NAME: ${{ github.ref_name }} RUN_NUMBER: ${{ github.run_number }} + - name: Revalidate approved release ref + if: ${{ inputs.orchestrated }} + env: + RELEASE_TAG: ${{ inputs.tag }} + APPROVED_SHA: ${{ inputs.approved_sha }} + run: bash scripts/verify-release-tag.sh + # Canary has no GitHub release — pull the signed dist from the workflow # artifact. Stable pulls from the published release. - name: Download canary dist diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 7780d2a76f..8e81624b4f 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -1,25 +1,22 @@ name: Release npm -# npm line. Tag namespace `npm-v` triggers this — plain `v` tags -# are the CLI binary + Homebrew release (release.yml) and intentionally do NOT -# trigger here (GitHub glob `npm-v*` does not match `v*`). Splitting npm onto its -# own tag lets a prerelease ship to npm's `next` channel without touching the -# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave -# its rc). Bump with: -# git tag npm-vX.Y.Z # stable -> publishes under `latest` +# npm line. Stable releases are called by release-stable.yml after its single +# GitHub environment approval. Direct tag pushes are retained only for +# prereleases so npm `next` can still ship independently without touching the +# stable Homebrew cask. Bump a prerelease with: # git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next` # git push origin # # A manual workflow_dispatch can publish either an opt-in canary from the current -# ref or an approved stable/next version when tag creation is restricted. Canary -# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves +# ref or recover an approved stable version from an existing tag. Canary publishes +# to the `canary` dist-tag (npm i reasonix@canary) and never moves # `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`, # any other prerelease is `next`, and a stable version is `latest` (#5822 — the # old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded # `npm update -g` users). The verify step below asserts the tag actually landed. on: push: - tags: ['npm-v*'] + tags: ['npm-v*-*'] workflow_dispatch: inputs: channel: @@ -34,16 +31,88 @@ on: description: "Version to publish. Canary appends -canary.; stable publishes exactly this version." required: true type: string + tag: + description: "stable: existing npm tag to publish (for example npm-v1.18.0)" + required: false + type: string + workflow_call: + inputs: + channel: + description: "Publish channel selected by the approved release orchestrator" + required: true + type: string + base_version: + description: "Version to publish" + required: true + type: string + tag: + description: "Existing npm tag to check out for stable publication" + required: false + default: "" + type: string + approved_cli_tag: + description: "Stable CLI tag recorded by the approved orchestrator" + required: true + type: string + approved_sha: + description: "Immutable commit recorded by the approved orchestrator" + required: true + type: string + orchestrated: + description: "True only when called by release-stable.yml after approval" + required: false + default: false + type: boolean permissions: contents: read jobs: + orchestration-guard: + name: verify approved orchestrator + if: ${{ inputs.orchestrated }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.approved_sha }} + - name: Verify caller and approved release ref + env: + ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }} + CALLER_EVENT_NAME: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_REF_PROTECTED: ${{ github.ref_protected }} + CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }} + APPROVED_SHA: ${{ inputs.approved_sha }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + bash scripts/verify-release-authorization.sh + bash scripts/verify-release-tag.sh + + release-gate: + name: approve standalone npm release + if: ${{ !inputs.orchestrated && !(github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') }} + runs-on: ubuntu-latest + environment: release + steps: + - env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: echo "Approved standalone npm release $RELEASE_TAG" + cache-guard: name: cache hit guard + needs: [orchestration-guard, release-gate] + if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') || needs.release-gate.result == 'success'))) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -54,12 +123,13 @@ jobs: name: publish npm packages needs: cache-guard runs-on: ubuntu-latest - # A tag push (npm-v*) or manual stable dispatch publishes a stable/next - # release and goes through the `release` environment (esengine approves); - # canary dispatches run free. - environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }} + # The stable caller has already passed the single GitHub release approval. + # Direct prereleases/manual stable recovery pass release-gate above; canaries + # run free. steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -73,28 +143,35 @@ jobs: # the requested semver exactly. build.mjs strips the leading `npm-`/`v`. - name: Resolve version id: ver - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - base="${{ inputs.base_version }}"; base="${base#v}" - if [ "${{ inputs.channel }}" = "canary" ]; then - echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT" - else - echo "arg=v${base}" >> "$GITHUB_OUTPUT" - fi - else - echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" - fi - - run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish env: + EVENT_NAME: ${{ github.event_name }} + IN_ORCHESTRATED: ${{ inputs.orchestrated }} + IN_CHANNEL: ${{ inputs.channel }} + IN_BASE_VERSION: ${{ inputs.base_version }} + IN_TAG: ${{ inputs.tag }} + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + run: bash scripts/resolve-npm-release.sh + - name: Revalidate approved release ref + if: ${{ inputs.orchestrated }} + env: + RELEASE_TAG: ${{ inputs.tag }} + APPROVED_SHA: ${{ inputs.approved_sha }} + run: bash scripts/verify-release-tag.sh + - env: + VERSION_ARG: ${{ steps.ver.outputs.arg }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node npm/build.mjs "$VERSION_ARG" --publish # Assert the dist-tag landed where build.mjs aimed it. This is the guard # that #5822 lacked: `latest` sat on 0.53.2 for months while stables went # to `next`, and nothing noticed until users got downgraded. The registry # applies tags asynchronously after publish, hence the poll. - name: Verify dist-tags + env: + VERSION_ARG: ${{ steps.ver.outputs.arg }} run: | set -euo pipefail - version="${{ steps.ver.outputs.arg }}" + version="$VERSION_ARG" version="${version#npm-}"; version="${version#v}" case "$version" in *-canary.*) tag=canary ;; diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000000..6bde9b551b --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,132 @@ +name: Release stable +run-name: Release stable ${{ inputs.tag || github.ref_name }} + +# Stable publication has exactly one GitHub environment gate. Push the CLI, +# npm, and desktop tags atomically; the vX.Y.Z tag starts this workflow, +# preflight verifies that all three tags point to the current main-v2 commit, +# and one GitHub approval releases every channel. SignPath intentionally keeps +# its separate manual confirmations. Prerelease and canary workflows remain +# independently callable. +on: + push: + tags: + - 'v*' + - '!v*-*' + workflow_dispatch: + inputs: + tag: + description: "Existing stable CLI tag to recover (for example v1.18.0)" + required: true + type: string + +concurrency: + group: stable-release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +# Reusable release workflows can only reduce caller permissions, so the +# orchestrator grants the union needed by CLI/Desktop publication. +permissions: + actions: write + contents: write + +jobs: + preflight: + name: validate stable release set + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.release.outputs.version }} + cli_tag: ${{ steps.release.outputs.cli_tag }} + npm_tag: ${{ steps.release.outputs.npm_tag }} + desktop_tag: ${{ steps.release.outputs.desktop_tag }} + sha: ${{ steps.release.outputs.sha }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.tag || github.ref }} + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Resolve stable release + id: release + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: bash scripts/resolve-stable-release.sh + - name: Validate reviewed release notes + run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md + - name: Cache hit guard + run: ./scripts/cache-guard.sh + + authorize: + name: approve stable release + needs: preflight + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + outputs: + version: ${{ steps.approved.outputs.version }} + cli_tag: ${{ steps.approved.outputs.cli_tag }} + npm_tag: ${{ steps.approved.outputs.npm_tag }} + desktop_tag: ${{ steps.approved.outputs.desktop_tag }} + sha: ${{ steps.approved.outputs.sha }} + steps: + - name: Record approved release + id: approved + env: + VERSION: ${{ needs.preflight.outputs.version }} + CLI_TAG: ${{ needs.preflight.outputs.cli_tag }} + NPM_TAG: ${{ needs.preflight.outputs.npm_tag }} + DESKTOP_TAG: ${{ needs.preflight.outputs.desktop_tag }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + run: | + { + echo "version=$VERSION" + echo "cli_tag=$CLI_TAG" + echo "npm_tag=$NPM_TAG" + echo "desktop_tag=$DESKTOP_TAG" + echo "sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" + echo "Approved stable release $VERSION at $RELEASE_SHA" + + cli: + name: publish CLI and Homebrew + needs: authorize + uses: ./.github/workflows/release.yml + with: + tag: ${{ needs.authorize.outputs.cli_tag }} + approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} + approved_sha: ${{ needs.authorize.outputs.sha }} + orchestrated: true + secrets: inherit + + npm: + name: publish npm + needs: authorize + uses: ./.github/workflows/release-npm.yml + with: + channel: stable + base_version: ${{ needs.authorize.outputs.version }} + tag: ${{ needs.authorize.outputs.npm_tag }} + approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} + approved_sha: ${{ needs.authorize.outputs.sha }} + orchestrated: true + secrets: inherit + + desktop: + name: publish desktop + needs: authorize + uses: ./.github/workflows/release-desktop.yml + with: + channel: stable + tag: ${{ needs.authorize.outputs.desktop_tag }} + approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} + approved_sha: ${{ needs.authorize.outputs.sha }} + orchestrated: true + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9647d1d04c..4ebf9729ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,88 @@ name: Release -# CLI binary line. Tag namespace `v` triggers this — it builds the -# release archives + checksums, publishes the GitHub release, and updates the -# Homebrew cask. npm is a SEPARATE line (release-npm.yml, `npm-v*`) so an npm -# prerelease can ship without forcing the stable cask, and vice versa; desktop is -# `desktop-v*` (release-desktop.yml). A prerelease `v*-rc*` still builds archives -# and a prerelease GitHub release, but goreleaser auto-skips the cask upload +# CLI binary line. Stable releases are called by release-stable.yml after its +# single GitHub `release`-environment approval. Direct tag pushes are retained +# only for CLI prereleases so they can still ship independently; those runs keep +# their own approval gate. goreleaser auto-skips the cask upload for prereleases # (brew has no prerelease channel — see .goreleaser.yaml). on: push: - tags: ['v*'] + tags: ['v*-*'] + workflow_dispatch: + inputs: + tag: + description: "Existing CLI tag to publish (for example v1.18.0-rc.1)" + required: true + type: string + workflow_call: + inputs: + tag: + description: "Existing CLI tag selected by the approved release orchestrator" + required: true + type: string + approved_cli_tag: + description: "Stable CLI tag recorded by the approved orchestrator" + required: true + type: string + approved_sha: + description: "Immutable commit recorded by the approved orchestrator" + required: true + type: string + orchestrated: + description: "True only when called by release-stable.yml after approval" + required: false + default: false + type: boolean permissions: contents: write # create the release and upload archives jobs: + orchestration-guard: + name: verify approved orchestrator + if: ${{ inputs.orchestrated }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.approved_sha }} + - name: Verify caller and approved release ref + env: + ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }} + CALLER_EVENT_NAME: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_REF_PROTECTED: ${{ github.ref_protected }} + CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }} + APPROVED_SHA: ${{ inputs.approved_sha }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + bash scripts/verify-release-authorization.sh + bash scripts/verify-release-tag.sh + + release-gate: + name: approve standalone CLI release + if: ${{ !inputs.orchestrated }} + runs-on: ubuntu-latest + environment: release + steps: + - env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: echo "Approved standalone CLI release $RELEASE_TAG" + cache-guard: name: cache hit guard + needs: [orchestration-guard, release-gate] + if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -30,15 +93,14 @@ jobs: name: archives + checksums + homebrew tap needs: cache-guard runs-on: ubuntu-latest - # CLI stable release (archives + GitHub release + Homebrew cask). It does not - # claim GitHub's repository-wide Latest badge; that belongs to desktop so the - # repository homepage points users at the installable app. Gated on the - # `release` environment so only esengine can approve it going public. - environment: release + # The stable caller has already passed the single GitHub release approval. + # Standalone prereleases pass release-gate above. This job therefore must not + # add a second GitHub environment approval. steps: - uses: actions/checkout@v7 with: fetch-depth: 0 + ref: ${{ inputs.approved_sha || inputs.tag || github.ref }} - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -47,7 +109,15 @@ jobs: with: node-version: "22" - name: Render reviewed release notes - run: node scripts/release-notes.mjs render --version "${{ github.ref_name }}" --output /tmp/release-notes.md + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md + - name: Revalidate approved release ref + if: ${{ inputs.orchestrated }} + env: + RELEASE_TAG: ${{ inputs.tag }} + APPROVED_SHA: ${{ inputs.approved_sha }} + run: bash scripts/verify-release-tag.sh - uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' @@ -55,17 +125,21 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even + # though checkout is on the release tag. Pin GoReleaser explicitly, and + # avoid ambiguity from the three release tags sharing one commit. + GORELEASER_CURRENT_TAG: ${{ inputs.tag || github.ref_name }} - name: Publish product release notes env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} + TAG: ${{ inputs.tag || github.ref_name }} run: gh release edit "$TAG" --notes-file /tmp/release-notes.md - name: Attach desktop manifest compatibility asset env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} + TAG: ${{ inputs.tag || github.ref_name }} HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_BUCKET: ${{ secrets.R2_BUCKET }} @@ -107,7 +181,7 @@ jobs: # cover the release CDN propagating the freshly uploaded asset. - name: Smoke public compatibility manifest env: - TAG: ${{ github.ref_name }} + TAG: ${{ inputs.tag || github.ref_name }} HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} run: | set -euo pipefail @@ -143,18 +217,18 @@ jobs: # step only guards runs that happen; this guard catches the run that # DIDN'T. # - # Two distinct states, two responses (the runs race: both workflows wait - # on the release environment independently, and npm dist-tags propagate + # Two distinct states, two responses (the approved orchestrator starts the + # CLI and npm reusable workflows concurrently, and npm dist-tags propagate # asynchronously, so "tag pushed but latest not moved yet" is a NORMAL # mid-release state, not a failure): # - npm-v tag missing -> hard fail. This is the #5822 gap: # nobody pushed the npm release at all. # - tag pushed, latest lagging -> poll briefly, then WARN and pass. - # The npm run may still be awaiting its own environment approval; - # release-npm.yml's verify step owns asserting the dist-tag lands. + # The npm job may still be publishing; release-npm.yml's verify step + # owns asserting the dist-tag lands. - name: Check npm latest dist-tag freshness env: - TAG: ${{ github.ref_name }} + TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail case "$TAG" in @@ -180,5 +254,5 @@ jobs: echo "npm latest -> ${got:-}, want >= $version (attempt $attempt)" sleep 10 done - echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands." + echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-} — the concurrent npm publish is likely still running or propagating. Monitor the npm job; its verify step asserts the dist-tag lands." exit 0 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2bec5e5872..60af863043 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -28,17 +28,35 @@ ending in `-canary.N` publish under the `canary` dist-tag.) | Action | Who | Mechanism | |---|---|---| -| **Cut a canary** | any maintainer (write access) | `workflow_dispatch`, runs free (open `canary` environment) | -| **Ship `next` / stable** | **esengine only** | stable publish jobs gate on the `release` environment — esengine must approve before anything goes public | +| **Cut a canary** | any maintainer (write access) | `workflow_dispatch`, runs without a production approval | +| **Ship stable** | release-tag creators + one configured reviewer | atomically push the three stable tags; the **Release stable** workflow requests one GitHub `release`-environment approval before every channel publishes | +| **Ship a standalone RC** | release-tag creators + one configured reviewer | push the surface-specific prerelease tag; that one standalone workflow requests one `release` approval | -So a maintainer can dispatch a canary anytime, but a stable release — even one a -maintainer starts by pushing a tag — pauses in the Actions UI until **esengine approves** -the `release` environment deployment. +So a maintainer can dispatch a canary anytime. A stable release pauses once in +the **Release stable** run until a configured reviewer approves the GitHub +`release` environment; the CLI, npm, and Desktop jobs then continue without +another GitHub environment prompt. Windows signing intentionally retains +separate SignPath confirmations for the AMD64 and ARM64 requests. -> Repo settings backing this: Environments → `release` has esengine as a required -> reviewer; `canary` has none. (Optional hardening: a tag ruleset restricting -> `v*`/`npm-v*`/`desktop-v*` creation to esengine, so maintainers can't even start a -> stable release.) +> Repo settings backing this: Environments → `release` has the release owners as +> required reviewers, and the release-tag ruleset restricts +> `v*`/`npm-v*`/`desktop-v*` creation, update, and deletion. Only the +> orchestrator and standalone RC/recovery paths reference the protected +> environment. + +The reusable publishers additionally require the stable orchestrator to run on +the protected stable tag (or protected `main-v2` recovery ref), bind the caller +workflow commit to the approved SHA, check out that SHA, and revalidate each +remote release tag immediately before publication. An unprotected branch cannot +claim that it already passed the approval job. + +Repository `write` access remains a privileged role: GitHub Actions workflows on +repository branches can access repository-level Actions secrets. Do not grant +`write` to someone who must be technically unable to publish. A stricter trust +separation requires moving external publication credentials to protected +environment secrets or provider-side OIDC/trusted-publishing policies; the +workflow approval and tag ruleset primarily protect the supported release path +from accidental or unauthorized invocation. ## The release loop @@ -60,20 +78,30 @@ the `release` environment deployment. 5. **Fix** on `main-v2` via PRs; re-cut the canary as needed (`canary.N` bumps). Re-run **Prepare release notes** after material fixes; it updates the same branch and PR without publishing anything. -6. **Ship stable** when the canary is clean and the release-notes PR is merged — push the three tags: +6. **Ship stable** when the canary is clean and the release-notes PR is merged — + create the three tags locally, then push them atomically: ```sh - git tag v1.4.0 && git push origin v1.4.0 # CLI binaries + Homebrew - git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> latest - git tag desktop-v1.4.0 && git push origin desktop-v1.4.0 # desktop -> R2 latest/ + git tag v1.4.0 + git tag npm-v1.4.0 + git tag desktop-v1.4.0 + git push --atomic origin v1.4.0 npm-v1.4.0 desktop-v1.4.0 ``` - Each stable run **waits for esengine to approve the `release` environment** before publishing. + The `v1.4.0` tag starts **Release stable**. Its preflight requires all three + tags to exist on the exact current `main-v2` commit, renders the reviewed + release notes, and runs the cache guard. It then **waits once for a configured + reviewer to approve the GitHub `release` environment** before invoking all + three publishers. The approval records the immutable release commit; every + publisher checks out that SHA and fails if its remote tag moves afterward. + The two Windows signing requests then retain their manual SignPath + confirmations as a separate control. A stable `npm-v*` publish moves the `latest` dist-tag automatically (build.mjs) and release-npm.yml verifies it landed. **Do not skip the npm tag**: the stable - CLI release (release.yml) fails when the matching `npm-v*` tag was never pushed - — that guard exists because 1.0.0–1.17.5 shipped without stable npm tags and - `npm update -g` silently downgraded users to 0.53.2 (#5822). A pushed tag whose - publish is still awaiting approval only warns; release-npm.yml's verify step - owns asserting the dist-tag lands. + preflight fails when the matching `npm-v*` or `desktop-v*` tag is missing or + points elsewhere. That guard exists because 1.0.0–1.17.5 shipped without + stable npm tags and `npm update -g` silently downgraded users to 0.53.2 (#5822). + The CLI and npm jobs run concurrently; the CLI's freshness check may warn while + npm is still propagating, while release-npm.yml's verify step owns the final + assertion. 7. **Next cycle** — the canary rolls on toward `1.5.0`. ## Notes @@ -81,6 +109,10 @@ the `release` environment deployment. - Canary version numbers use the workflow `run_number`, so the desktop and CLI canary numbers differ (e.g. `canary.11` vs `canary.2`). Only monotonicity per channel matters. - A stable `-rc` tag (e.g. `npm-v1.4.0-rc.1`) still ships under `next`, not `canary`. +- Windows release signing uses SignPath trusted-build and origin verification. + Keep **Use approval process** enabled on `release-signing`: the AMD64 and ARM64 + requests can each require a manual confirmation after the single GitHub + release-environment approval. - Desktop in-app updates use R2 first, then the `crash.reasonix.io` desktop release gateway. The gateway resolves the `desktop-v*` release line directly and never uses GitHub's repository-wide `/releases/latest`, because plain `v*` tags are the CLI diff --git a/scripts/release-workflows.test.sh b/scripts/release-workflows.test.sh new file mode 100755 index 0000000000..b47c41bde7 --- /dev/null +++ b/scripts/release-workflows.test.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-release-workflow-test.XXXXXX")" +cleanup() { + case "$test_root" in + */reasonix-release-workflow-test.*) rm -rf -- "$test_root" ;; + *) echo "refusing to clean unexpected test directory: $test_root" >&2 ;; + esac +} +trap cleanup EXIT + +# Stable tags have one entrypoint and one protected environment. Reusable +# publishers must verify that only that entrypoint can claim prior approval. +[ "$(grep -Ec '^ environment: release$' "$repo_root/.github/workflows/release-stable.yml")" = "1" ] +for workflow in release.yml release-npm.yml release-desktop.yml; do + grep -Eq 'github\.workflow_ref' "$repo_root/.github/workflows/$workflow" + grep -Eq 'github\.ref_protected' "$repo_root/.github/workflows/$workflow" + grep -Eq 'inputs\.approved_sha' "$repo_root/.github/workflows/$workflow" + grep -Eq 'verify-release-tag\.sh' "$repo_root/.github/workflows/$workflow" + grep -Eq 'release-stable\.yml' "$repo_root/.github/workflows/$workflow" +done + +git init --bare -q "$test_root/remote.git" +git clone -q "$test_root/remote.git" "$test_root/repo" +( + cd "$test_root/repo" + git config user.name "Release Workflow Test" + git config user.email "release-workflow-test@example.invalid" + git commit --allow-empty -q -m "candidate" + git branch -M main-v2 + git push -q -u origin main-v2 + + git tag v1.2.3 + git tag npm-v1.2.3 + git tag -a desktop-v1.2.3 -m "desktop release" + git push -q origin v1.2.3 npm-v1.2.3 desktop-v1.2.3 + GITHUB_OUTPUT="$test_root/stable.out" RELEASE_TAG=v1.2.3 "$repo_root/scripts/resolve-stable-release.sh" + grep -Eq '^version=1\.2\.3$' "$test_root/stable.out" + grep -Eq '^desktop_tag=desktop-v1\.2\.3$' "$test_root/stable.out" + approved_sha="$(git rev-parse HEAD)" + ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/tags/v1.2.3' \ + EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/tags/v1.2.3' \ + CALLER_EVENT_NAME=push CALLER_REF=refs/tags/v1.2.3 CALLER_REF_PROTECTED=true \ + CALLER_WORKFLOW_SHA="$approved_sha" APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ + "$repo_root/scripts/verify-release-authorization.sh" + RELEASE_TAG=desktop-v1.2.3 APPROVED_SHA="$approved_sha" \ + "$repo_root/scripts/verify-release-tag.sh" + + if ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/topic' \ + EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/topic' \ + CALLER_EVENT_NAME=workflow_dispatch CALLER_REF=refs/heads/topic CALLER_REF_PROTECTED=false \ + CALLER_WORKFLOW_SHA="$approved_sha" APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ + "$repo_root/scripts/verify-release-authorization.sh" >"$test_root/unprotected.log" 2>&1; then + echo "unprotected caller unexpectedly passed release authorization" >&2 + exit 1 + fi + grep -Eq 'caller ref is not protected' "$test_root/unprotected.log" + + git tag v1.2.4 + git tag npm-v1.2.4 + git push -q origin v1.2.4 npm-v1.2.4 + if RELEASE_TAG=v1.2.4 "$repo_root/scripts/resolve-stable-release.sh" >"$test_root/missing.log" 2>&1; then + echo "missing sibling tag unexpectedly passed" >&2 + exit 1 + fi + grep -Eq 'required stable release tag is missing: desktop-v1\.2\.4' "$test_root/missing.log" + + other_sha="$(git commit-tree HEAD^{tree} -p HEAD -m "other")" + git tag -f desktop-v1.2.3 "$other_sha" >/dev/null + git push -q -f origin desktop-v1.2.3 + if RELEASE_TAG=desktop-v1.2.3 APPROVED_SHA="$approved_sha" \ + "$repo_root/scripts/verify-release-tag.sh" >"$test_root/moved-tag.log" 2>&1; then + echo "moved release tag unexpectedly passed approved SHA validation" >&2 + exit 1 + fi + grep -Eq 'moved to .* after approval' "$test_root/moved-tag.log" + git tag -f desktop-v1.2.3 "$approved_sha" >/dev/null + git push -q -f origin desktop-v1.2.3 + + git tag v1.2.5 + git tag npm-v1.2.5 "$other_sha" + git tag desktop-v1.2.5 + git push -q origin v1.2.5 npm-v1.2.5 desktop-v1.2.5 + if RELEASE_TAG=v1.2.5 "$repo_root/scripts/resolve-stable-release.sh" >"$test_root/mismatch.log" 2>&1; then + echo "mismatched sibling tag unexpectedly passed" >&2 + exit 1 + fi + grep -Eq 'npm-v1\.2\.5 points to .* expected' "$test_root/mismatch.log" + + if RELEASE_TAG=v1.2.3-rc.1 "$repo_root/scripts/resolve-stable-release.sh" >"$test_root/prerelease.log" 2>&1; then + echo "prerelease tag unexpectedly passed stable validation" >&2 + exit 1 + fi + grep -Eq 'stable release tag must be vMAJOR.MINOR.PATCH' "$test_root/prerelease.log" +) + +EVENT_NAME=push IN_CHANNEL=stable IN_TAG=desktop-v1.2.3 REF_NAME=v1.2.3 RUN_NUMBER=10 \ + GITHUB_OUTPUT="$test_root/desktop-stable.out" bash "$repo_root/scripts/resolve-desktop-release.sh" +grep -Eq '^tag=desktop-v1\.2\.3$' "$test_root/desktop-stable.out" +grep -Eq '^version=v1\.2\.3$' "$test_root/desktop-stable.out" + +EVENT_NAME=workflow_dispatch IN_CHANNEL=canary IN_BASE_VERSION=1.3.0 IN_TAG='' REF_NAME=main-v2 RUN_NUMBER=42 \ + GITHUB_OUTPUT="$test_root/desktop-canary.out" bash "$repo_root/scripts/resolve-desktop-release.sh" +grep -Eq '^version=v1\.3\.0-canary\.42$' "$test_root/desktop-canary.out" + +EVENT_NAME=push IN_CHANNEL='' IN_TAG='' REF_NAME=desktop-v1.4.0-rc.1 RUN_NUMBER=50 \ + GITHUB_OUTPUT="$test_root/desktop-rc.out" bash "$repo_root/scripts/resolve-desktop-release.sh" +grep -Eq '^prerelease=true$' "$test_root/desktop-rc.out" + +if EVENT_NAME=workflow_dispatch IN_CHANNEL=stable IN_TAG='' REF_NAME=main-v2 RUN_NUMBER=50 \ + GITHUB_OUTPUT="$test_root/desktop-missing-tag.out" bash "$repo_root/scripts/resolve-desktop-release.sh" \ + >"$test_root/desktop-missing-tag.log" 2>&1; then + echo "tag-less desktop stable dispatch unexpectedly passed" >&2 + exit 1 +fi +grep -Eq 'stable dispatch requires tag' "$test_root/desktop-missing-tag.log" + +EVENT_NAME=push IN_ORCHESTRATED=false IN_CHANNEL='' IN_BASE_VERSION='' IN_TAG='' \ + REF_NAME=npm-v1.4.0-rc.1 RUN_NUMBER=50 GITHUB_OUTPUT="$test_root/npm-rc.out" \ + bash "$repo_root/scripts/resolve-npm-release.sh" +grep -Eq '^arg=npm-v1\.4\.0-rc\.1$' "$test_root/npm-rc.out" + +EVENT_NAME=push IN_ORCHESTRATED=true IN_CHANNEL=stable IN_BASE_VERSION=1.5.0 \ + IN_TAG=npm-v1.5.0 REF_NAME=v1.5.0 RUN_NUMBER=51 GITHUB_OUTPUT="$test_root/npm-stable.out" \ + bash "$repo_root/scripts/resolve-npm-release.sh" +grep -Eq '^arg=v1\.5\.0$' "$test_root/npm-stable.out" + +if EVENT_NAME=workflow_dispatch IN_ORCHESTRATED=false IN_CHANNEL=stable IN_BASE_VERSION=1.5.0 \ + IN_TAG=npm-v1.5.1 REF_NAME=main-v2 RUN_NUMBER=52 GITHUB_OUTPUT="$test_root/npm-mismatch.out" \ + bash "$repo_root/scripts/resolve-npm-release.sh" >"$test_root/npm-mismatch.log" 2>&1; then + echo "mismatched npm stable dispatch unexpectedly passed" >&2 + exit 1 +fi +grep -Eq 'does not match requested version' "$test_root/npm-mismatch.log" + +echo "release workflow contract tests: PASS" diff --git a/scripts/resolve-desktop-release.sh b/scripts/resolve-desktop-release.sh index d483a181d0..b492440d44 100644 --- a/scripts/resolve-desktop-release.sh +++ b/scripts/resolve-desktop-release.sh @@ -3,25 +3,40 @@ # build, publish, and mirror jobs so they agree on a single value. Reads the run's # context from env and writes the four outputs to $GITHUB_OUTPUT. # -# stable: from a desktop-v* tag push, or a manual dispatch with `tag`. +# stable: from a desktop-v* prerelease tag push, a manual dispatch with `tag`, +# or the stable release orchestrator's workflow_call input. # canary: a manual dispatch with channel=canary; version is synthesized from # base_version + the monotonic run_number, tag is the rolling # `desktop-canary`, and it is always a prerelease. set -euo pipefail -if [ "${EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${IN_CHANNEL:-stable}" = "canary" ]; then +stable_semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +release_semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$' + +if [ "${IN_CHANNEL:-stable}" = "canary" ]; then base="${IN_BASE_VERSION:?canary dispatch requires base_version}" base="${base#v}" base="${base#desktop-v}" + if [[ ! "$base" =~ $stable_semver_re ]]; then + echo "::error::canary base version must be MAJOR.MINOR.PATCH, got: $base" >&2 + exit 1 + fi version="v${base}-canary.${RUN_NUMBER}" tag="desktop-canary" channel="canary" prerelease="true" else - if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then - tag="${IN_TAG:?stable dispatch requires tag}" + if [ -n "${IN_TAG:-}" ]; then + tag="${IN_TAG}" + elif [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then + echo "::error::stable dispatch requires tag" >&2 + exit 1 else - tag="${REF_NAME}" + tag="${REF_NAME:?stable release requires a tag input or ref name}" + fi + if [[ ! "$tag" =~ ^desktop-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $release_semver_re ]]; then + echo "::error::desktop release tag must be desktop-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag" >&2 + exit 1 fi version="${tag#desktop-}" channel="stable" diff --git a/scripts/resolve-npm-release.sh b/scripts/resolve-npm-release.sh new file mode 100755 index 0000000000..12c9edd5d2 --- /dev/null +++ b/scripts/resolve-npm-release.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Resolve and validate the version argument passed to npm/build.mjs. Keeping +# workflow inputs in environment variables avoids interpolating dispatch input +# into shell source. +set -euo pipefail + +stable_semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +release_semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$' + +if [ "${EVENT_NAME:-}" = "workflow_dispatch" ] || [ "${IN_ORCHESTRATED:-false}" = "true" ]; then + base="${IN_BASE_VERSION:?dispatch/call requires base_version}" + base="${base#v}" + if [[ ! "$base" =~ $stable_semver_re ]]; then + echo "::error::base version must be MAJOR.MINOR.PATCH, got: $base" >&2 + exit 1 + fi + if [ "${IN_CHANNEL:-stable}" = "canary" ]; then + arg="v${base}-canary.${RUN_NUMBER:?canary requires run number}" + else + tag="${IN_TAG:-}" + if [ -z "$tag" ]; then + echo "::error::stable publication requires an existing npm tag" >&2 + exit 1 + fi + if [ "$tag" != "npm-v${base}" ]; then + echo "::error::tag $tag does not match requested version npm-v${base}" >&2 + exit 1 + fi + arg="v${base}" + fi +else + tag="${REF_NAME:?tag push requires ref name}" + if [[ ! "$tag" =~ ^npm-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $release_semver_re ]]; then + echo "::error::npm release tag must be npm-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag" >&2 + exit 1 + fi + arg="$tag" +fi + +echo "arg=$arg" >>"${GITHUB_OUTPUT:-/dev/stdout}" +echo "npm release resolved: $arg" diff --git a/scripts/resolve-stable-release.sh b/scripts/resolve-stable-release.sh new file mode 100755 index 0000000000..4ca7ba736c --- /dev/null +++ b/scripts/resolve-stable-release.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Validate one stable release tag set and emit the values shared by all release +# workflows. Stable releases are deliberately all-or-nothing: the CLI, npm, and +# desktop tags must already exist on the current main-v2 commit before the sole +# release-environment approval is requested. +set -euo pipefail + +release_tag="${RELEASE_TAG:?RELEASE_TAG is required}" +release_remote="${RELEASE_REMOTE:-origin}" + +if [[ ! "$release_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::stable release tag must be vMAJOR.MINOR.PATCH, got: $release_tag" >&2 + exit 1 +fi + +version="${release_tag#v}" +cli_tag="$release_tag" +npm_tag="npm-v${version}" +desktop_tag="desktop-v${version}" + +head_sha="$(git rev-parse HEAD^{commit})" +main_sha="$(git ls-remote "$release_remote" refs/heads/main-v2 | awk 'NR == 1 { print $1 }')" +if [ -z "$main_sha" ]; then + echo "::error::cannot resolve $release_remote/main-v2" >&2 + exit 1 +fi +if [ "$head_sha" != "$main_sha" ]; then + echo "::error::$cli_tag points to $head_sha, but $release_remote/main-v2 is $main_sha" >&2 + exit 1 +fi + +for tag in "$cli_tag" "$npm_tag" "$desktop_tag"; do + # Prefer the peeled commit for annotated tags; lightweight tags only return + # the first line. Both forms are valid release refs. + tag_sha="$( + git ls-remote --tags "$release_remote" "refs/tags/$tag" "refs/tags/$tag^{}" | + awk '/\^\{\}$/ { print $1; found = 1; exit } NR == 1 { first = $1 } END { if (!found) print first }' + )" + if [ -z "$tag_sha" ]; then + echo "::error::required stable release tag is missing: $tag" >&2 + exit 1 + fi + if [ "$tag_sha" != "$head_sha" ]; then + echo "::error::$tag points to $tag_sha, expected $head_sha" >&2 + exit 1 + fi +done + +output_file="${GITHUB_OUTPUT:-/dev/stdout}" +{ + echo "version=$version" + echo "cli_tag=$cli_tag" + echo "npm_tag=$npm_tag" + echo "desktop_tag=$desktop_tag" + echo "sha=$head_sha" +} >>"$output_file" + +echo "stable release resolved: version=$version sha=$head_sha" diff --git a/scripts/verify-release-authorization.sh b/scripts/verify-release-authorization.sh new file mode 100755 index 0000000000..1c0108cc34 --- /dev/null +++ b/scripts/verify-release-authorization.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Verify the non-forgeable GitHub context behind an orchestrated stable release. +# Inputs arrive through environment variables so workflow expressions never +# become shell source. +set -euo pipefail + +actual_caller="${ACTUAL_CALLER_WORKFLOW_REF:?ACTUAL_CALLER_WORKFLOW_REF is required}" +expected_caller="${EXPECTED_CALLER_WORKFLOW_REF:?EXPECTED_CALLER_WORKFLOW_REF is required}" +caller_event="${CALLER_EVENT_NAME:?CALLER_EVENT_NAME is required}" +caller_ref="${CALLER_REF:?CALLER_REF is required}" +caller_ref_protected="${CALLER_REF_PROTECTED:?CALLER_REF_PROTECTED is required}" +caller_workflow_sha="${CALLER_WORKFLOW_SHA:?CALLER_WORKFLOW_SHA is required}" +approved_cli_tag="${APPROVED_CLI_TAG:?APPROVED_CLI_TAG is required}" +approved_sha="${APPROVED_SHA:?APPROVED_SHA is required}" + +if [ "$actual_caller" != "$expected_caller" ]; then + echo "::error::orchestrated release caller is $actual_caller, expected $expected_caller" >&2 + exit 1 +fi +if [ "$caller_ref_protected" != "true" ]; then + echo "::error::orchestrated release caller ref is not protected: $caller_ref" >&2 + exit 1 +fi +if [ "$caller_workflow_sha" != "$approved_sha" ]; then + echo "::error::caller workflow SHA is $caller_workflow_sha, expected approved SHA $approved_sha" >&2 + exit 1 +fi +if [[ ! "$approved_cli_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::approved CLI tag must be vMAJOR.MINOR.PATCH, got: $approved_cli_tag" >&2 + exit 1 +fi + +case "$caller_event" in +push) + expected_ref="refs/tags/$approved_cli_tag" + ;; +workflow_dispatch) + expected_ref="refs/heads/main-v2" + ;; +*) + echo "::error::unsupported stable release caller event: $caller_event" >&2 + exit 1 + ;; +esac +if [ "$caller_ref" != "$expected_ref" ]; then + echo "::error::orchestrated release caller ref is $caller_ref, expected $expected_ref" >&2 + exit 1 +fi + +echo "release authorization verified: caller=$actual_caller sha=$approved_sha" diff --git a/scripts/verify-release-tag.sh b/scripts/verify-release-tag.sh new file mode 100755 index 0000000000..f4eb32dc6d --- /dev/null +++ b/scripts/verify-release-tag.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Verify that a release job is building the approved commit and that the remote +# tag still resolves to that commit. This closes the gap between the stable +# preflight/approval and the later publishing jobs. +set -euo pipefail + +release_tag="${RELEASE_TAG:?RELEASE_TAG is required}" +approved_sha="${APPROVED_SHA:?APPROVED_SHA is required}" +release_remote="${RELEASE_REMOTE:-origin}" + +if [[ ! "$approved_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::approved release SHA must be a full commit SHA, got: $approved_sha" >&2 + exit 1 +fi +if ! git check-ref-format "refs/tags/$release_tag" >/dev/null; then + echo "::error::invalid release tag: $release_tag" >&2 + exit 1 +fi + +head_sha="$(git rev-parse HEAD^{commit})" +if [ "$head_sha" != "$approved_sha" ]; then + echo "::error::release checkout is $head_sha, expected approved SHA $approved_sha" >&2 + exit 1 +fi + +# Prefer the peeled commit for annotated tags; lightweight tags only return the +# first line. Both forms are valid release refs. +tag_sha="$( + git ls-remote --tags "$release_remote" "refs/tags/$release_tag" "refs/tags/$release_tag^{}" | + awk '/\^\{\}$/ { print $1; found = 1; exit } NR == 1 { first = $1 } END { if (!found) print first }' +)" +if [ -z "$tag_sha" ]; then + echo "::error::approved release tag is missing: $release_tag" >&2 + exit 1 +fi +if [ "$tag_sha" != "$approved_sha" ]; then + echo "::error::$release_tag moved to $tag_sha after approval; expected $approved_sha" >&2 + exit 1 +fi + +echo "release tag verified: tag=$release_tag sha=$approved_sha"