Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.approved_sha }}
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
Expand All @@ -88,9 +88,11 @@ jobs:
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
Expand Down Expand Up @@ -126,6 +128,7 @@ jobs:
build:
name: build (${{ matrix.name }})
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
permissions:
contents: read # checkout only; the publish job holds contents: write
actions: read # SignPath reads run details + downloads the unsigned artifact
Expand Down Expand Up @@ -300,6 +303,7 @@ jobs:
publish:
name: publish release
needs: build
if: ${{ always() && !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Direct prereleases/manual stable recovery pass release-gate; canaries run
Expand Down Expand Up @@ -387,7 +391,7 @@ jobs:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
if: ${{ github.repository_owner == 'esengine' }}
if: ${{ always() && !cancelled() && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.approved_sha }}
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
Expand All @@ -87,9 +87,11 @@ jobs:
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
Expand Down Expand Up @@ -122,6 +124,7 @@ jobs:
npm:
name: publish npm packages
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Direct prereleases/manual stable recovery pass release-gate above; canaries
Expand Down
51 changes: 47 additions & 4 deletions .github/workflows/release-stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ 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.
# and one GitHub approval releases every channel. A manual recovery runs the
# fixed control plane from protected main-v2 while preserving an older tagged
# candidate on main-v2 history. SignPath intentionally keeps its separate
# manual confirmations. Prerelease and canary workflows remain independently
# callable.
on:
push:
tags:
Expand Down Expand Up @@ -45,7 +47,9 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.tag || github.ref }}
# A recovery dispatch uses the fixed workflow/scripts from protected
# main-v2. Publishers still check out the immutable approved tag SHA.
ref: ${{ github.sha }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
Expand All @@ -57,6 +61,7 @@ jobs:
id: release
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ github.event_name == 'workflow_dispatch' }}
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
Expand Down Expand Up @@ -130,3 +135,41 @@ jobs:
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
secrets: inherit

postflight:
name: verify stable release artifacts
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require every publisher to succeed
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do
if [ "$result" != "success" ]; then
echo "::error::stable publisher result is $result, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
# Postflight belongs to the trusted control plane, not the old build
# candidate, which may predate this verifier.
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Verify public artifacts and npm latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
run: bash scripts/verify-stable-release-artifacts.sh
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.approved_sha }}
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
Expand All @@ -57,9 +57,11 @@ jobs:
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
Expand Down Expand Up @@ -92,6 +94,7 @@ jobs:
goreleaser:
name: archives + checksums + homebrew tap
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Standalone prereleases pass release-gate above. This job therefore must not
Expand Down
21 changes: 16 additions & 5 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ separate SignPath confirmations for the AMD64 and ARM64 requests.
> 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.
the protected stable tag (or protected `main-v2` recovery ref). Normal tag-push
releases bind the caller workflow commit to the approved SHA. Recovery keeps the
fixed control-plane workflow on protected `main-v2`, resolves the existing three
tags to one immutable historical SHA on `main-v2`, and uses that SHA only for the
actual build and publication checkouts. Every publisher revalidates its 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
Expand Down Expand Up @@ -101,14 +104,22 @@ from accidental or unauthorized invocation.
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.
assertion. The stable orchestrator finishes with a postflight that verifies
both GitHub Releases contain their required assets and npm `latest` exactly
matches the approved version; missing artifacts can no longer produce a green
stable run.
7. **Next cycle** — the canary rolls on toward `1.5.0`.

## Notes

- 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`.
- Recover an interrupted stable release by dispatching **Release stable** from
protected `main-v2` with the existing `vX.Y.Z` tag. Recovery requires the CLI,
npm, and Desktop tags to remain aligned on an ancestor of current `main-v2`,
then uses the same single approval and postflight. Never move or recreate the
published tags to pick up a workflow fix.
- 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
Expand Down
56 changes: 54 additions & 2 deletions scripts/release-workflows.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ for workflow in release.yml release-npm.yml release-desktop.yml; do
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"
grep -Eq "needs\.cache-guard\.result == 'success'" "$repo_root/.github/workflows/$workflow"
done
grep -Eq "needs\.build\.result == 'success'" "$repo_root/.github/workflows/release-desktop.yml"
grep -Eq "needs\.publish\.result == 'success'" "$repo_root/.github/workflows/release-desktop.yml"
grep -Eq '^ postflight:$' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'verify-stable-release-artifacts\.sh' "$repo_root/.github/workflows/release-stable.yml"

git init --bare -q "$test_root/remote.git"
git clone -q "$test_root/remote.git" "$test_root/repo"
Expand All @@ -43,15 +48,50 @@ git clone -q "$test_root/remote.git" "$test_root/repo"
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" \
CALLER_WORKFLOW_SHA="$approved_sha" CALLER_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"

git commit --allow-empty -q -m "release workflow fix"
git push -q origin main-v2
recovery_workflow_sha="$(git rev-parse HEAD)"
if RELEASE_TAG=v1.2.3 "$repo_root/scripts/resolve-stable-release.sh" >"$test_root/stale-main.log" 2>&1; then
echo "old stable tag unexpectedly passed normal release resolution" >&2
exit 1
fi
if ! grep -Eq 'points to .*expected' "$test_root/stale-main.log"; then
sed -n '1,20p' "$test_root/stale-main.log" >&2
exit 1
fi
GITHUB_OUTPUT="$test_root/recovery.out" ALLOW_STABLE_RECOVERY=true RELEASE_TAG=v1.2.3 \
"$repo_root/scripts/resolve-stable-release.sh"
grep -Eq '^sha='"$approved_sha"'$' "$test_root/recovery.out"
ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \
EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \
CALLER_EVENT_NAME=workflow_dispatch CALLER_REF=refs/heads/main-v2 CALLER_REF_PROTECTED=true \
CALLER_WORKFLOW_SHA="$recovery_workflow_sha" CALLER_SHA="$recovery_workflow_sha" \
APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \
"$repo_root/scripts/verify-release-authorization.sh"
RELEASE_TAG=v1.2.3 APPROVED_SHA="$approved_sha" VERIFY_RELEASE_CHECKOUT=false \
"$repo_root/scripts/verify-release-tag.sh"
if ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \
EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \
CALLER_EVENT_NAME=workflow_dispatch CALLER_REF=refs/heads/main-v2 CALLER_REF_PROTECTED=true \
CALLER_WORKFLOW_SHA="$approved_sha" CALLER_SHA="$recovery_workflow_sha" \
APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \
"$repo_root/scripts/verify-release-authorization.sh" >"$test_root/stale-workflow.log" 2>&1; then
echo "stale recovery workflow unexpectedly passed release authorization" >&2
exit 1
fi
grep -Eq 'recovery caller workflow SHA is' "$test_root/stale-workflow.log"

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" \
CALLER_WORKFLOW_SHA="$recovery_workflow_sha" CALLER_SHA="$recovery_workflow_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
Expand All @@ -68,8 +108,19 @@ git clone -q "$test_root/remote.git" "$test_root/repo"
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 v1.2.6 "$other_sha"
git tag npm-v1.2.6 "$other_sha"
git tag desktop-v1.2.6 "$other_sha"
git push -q origin v1.2.6 npm-v1.2.6 desktop-v1.2.6
if ALLOW_STABLE_RECOVERY=true RELEASE_TAG=v1.2.6 \
"$repo_root/scripts/resolve-stable-release.sh" >"$test_root/non-ancestor.log" 2>&1; then
echo "non-ancestor recovery tags unexpectedly passed release resolution" >&2
exit 1
fi
grep -Eq 'is not an ancestor of' "$test_root/non-ancestor.log"
git tag -f desktop-v1.2.3 "$other_sha" >/dev/null
git push -q -f origin desktop-v1.2.3
git checkout -q --detach "$approved_sha"
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
Expand All @@ -78,6 +129,7 @@ git clone -q "$test_root/remote.git" "$test_root/repo"
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 switch -q main-v2

git tag v1.2.5
git tag npm-v1.2.5 "$other_sha"
Expand Down
Loading