| 1 | name: Release stable |
| 2 | run-name: Release stable ${{ inputs.tag || github.ref_name }} |
| 3 | |
| 4 | # Stable publication has exactly one GitHub environment gate. Push the CLI, |
| 5 | # npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z |
| 6 | # tag to this workflow on protected main-v2. Preflight verifies that all three |
| 7 | # tags point to the reviewed Notes candidate on main-v2 history, and one GitHub |
| 8 | # approval releases every surface. After approval, a zero-publication Windows |
| 9 | # preflight verifies both architectures and signing stages before publication. |
| 10 | # Keeping the control-plane ref on main-v2 lets SignPath restrict |
| 11 | # production signing to that one protected origin instead of trusting wildcard |
| 12 | # tag-like branch names. Manual recovery uses the same fixed control plane while |
| 13 | # preserving an older tagged candidate on main-v2 history. |
| 14 | on: |
| 15 | workflow_dispatch: |
| 16 | inputs: |
| 17 | tag: |
| 18 | description: "Existing stable CLI tag to recover (for example v1.18.0)" |
| 19 | required: true |
| 20 | type: string |
| 21 | publish_cli: |
| 22 | description: "Recover the CLI/Homebrew channel" |
| 23 | required: false |
| 24 | default: true |
| 25 | type: boolean |
| 26 | publish_npm: |
| 27 | description: "Recover the npm channel" |
| 28 | required: false |
| 29 | default: true |
| 30 | type: boolean |
| 31 | publish_desktop: |
| 32 | description: "Recover the Desktop/R2 channel" |
| 33 | required: false |
| 34 | default: true |
| 35 | type: boolean |
| 36 | allow_recovery: |
| 37 | description: "Allow an existing stable tag on main-v2 history (manual recovery only)" |
| 38 | required: false |
| 39 | default: true |
| 40 | type: boolean |
| 41 | |
| 42 | concurrency: |
| 43 | group: stable-release-${{ inputs.tag || github.ref_name }} |
| 44 | cancel-in-progress: false |
| 45 | |
| 46 | # Reusable release workflows can only reduce caller permissions, so the |
| 47 | # orchestrator grants the union needed by CLI/Desktop publication. |
| 48 | permissions: |
| 49 | actions: write |
| 50 | contents: write |
| 51 | |
| 52 | jobs: |
| 53 | preflight: |
| 54 | name: validate stable release set |
| 55 | runs-on: ubuntu-latest |
| 56 | permissions: |
| 57 | actions: read |
| 58 | contents: read |
| 59 | outputs: |
| 60 | version: ${{ steps.release.outputs.version }} |
| 61 | cli_tag: ${{ steps.release.outputs.cli_tag }} |
| 62 | npm_tag: ${{ steps.release.outputs.npm_tag }} |
| 63 | desktop_tag: ${{ steps.release.outputs.desktop_tag }} |
| 64 | sha: ${{ steps.release.outputs.sha }} |
| 65 | steps: |
| 66 | - uses: actions/checkout@v7 |
| 67 | with: |
| 68 | fetch-depth: 0 |
| 69 | # A recovery dispatch uses the fixed workflow/scripts from protected |
| 70 | # main-v2. Publishers still check out the immutable approved tag SHA. |
| 71 | ref: ${{ github.sha }} |
| 72 | - uses: actions/setup-go@v7 |
| 73 | with: |
| 74 | go-version-file: go.mod |
| 75 | cache: true |
| 76 | - uses: actions/setup-node@v7 |
| 77 | with: |
| 78 | node-version: "22" |
| 79 | - name: Resolve stable release |
| 80 | id: release |
| 81 | env: |
| 82 | RELEASE_TAG: ${{ inputs.tag || github.ref_name }} |
| 83 | ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }} |
| 84 | run: bash scripts/resolve-stable-release.sh |
| 85 | - name: Revalidate normal release candidate and exact push CI |
| 86 | if: ${{ !inputs.allow_recovery }} |
| 87 | env: |
| 88 | GH_TOKEN: ${{ github.token }} |
| 89 | RELEASE_REPOSITORY: ${{ github.repository }} |
| 90 | RELEASE_CI_WAIT_SECONDS: 60 |
| 91 | RELEASE_VERSION: ${{ steps.release.outputs.version }} |
| 92 | RELEASE_SHA: ${{ steps.release.outputs.sha }} |
| 93 | run: | |
| 94 | bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA" |
| 95 | bash scripts/verify-release-push-ci.sh "$RELEASE_SHA" |
| 96 | - name: Validate reviewed release notes |
| 97 | run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md |
| 98 | # Recovery builds deliberately check out the immutable tagged candidate, |
| 99 | # which can predate its reviewed release-note entry. Carry the exact file |
| 100 | # validated by this protected control-plane job into both publishers. |
| 101 | - name: Upload reviewed release notes |
| 102 | uses: actions/upload-artifact@v7 |
| 103 | with: |
| 104 | name: orchestrator-reviewed-release-notes |
| 105 | path: /tmp/release-notes.md |
| 106 | if-no-files-found: error |
| 107 | retention-days: 1 |
| 108 | - name: Cache hit guard |
| 109 | run: ./scripts/cache-guard.sh |
| 110 | |
| 111 | authorize: |
| 112 | name: approve stable release |
| 113 | needs: preflight |
| 114 | runs-on: ubuntu-latest |
| 115 | environment: release |
| 116 | permissions: |
| 117 | contents: read |
| 118 | outputs: |
| 119 | version: ${{ steps.approved.outputs.version }} |
| 120 | cli_tag: ${{ steps.approved.outputs.cli_tag }} |
| 121 | npm_tag: ${{ steps.approved.outputs.npm_tag }} |
| 122 | desktop_tag: ${{ steps.approved.outputs.desktop_tag }} |
| 123 | sha: ${{ steps.approved.outputs.sha }} |
| 124 | steps: |
| 125 | - name: Record approved release |
| 126 | id: approved |
| 127 | env: |
| 128 | VERSION: ${{ needs.preflight.outputs.version }} |
| 129 | CLI_TAG: ${{ needs.preflight.outputs.cli_tag }} |
| 130 | NPM_TAG: ${{ needs.preflight.outputs.npm_tag }} |
| 131 | DESKTOP_TAG: ${{ needs.preflight.outputs.desktop_tag }} |
| 132 | RELEASE_SHA: ${{ needs.preflight.outputs.sha }} |
| 133 | run: | |
| 134 | { |
| 135 | echo "version=$VERSION" |
| 136 | echo "cli_tag=$CLI_TAG" |
| 137 | echo "npm_tag=$NPM_TAG" |
| 138 | echo "desktop_tag=$DESKTOP_TAG" |
| 139 | echo "sha=$RELEASE_SHA" |
| 140 | } >> "$GITHUB_OUTPUT" |
| 141 | echo "Approved stable release $VERSION at $RELEASE_SHA" |
| 142 | |
| 143 | cli: |
| 144 | name: publish CLI and Homebrew |
| 145 | needs: [authorize, signpath-preflight] |
| 146 | if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_cli) }} |
| 147 | uses: ./.github/workflows/release.yml |
| 148 | with: |
| 149 | tag: ${{ needs.authorize.outputs.cli_tag }} |
| 150 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 151 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 152 | orchestrated: true |
| 153 | secrets: inherit |
| 154 | |
| 155 | npm: |
| 156 | name: publish npm |
| 157 | needs: [authorize, signpath-preflight] |
| 158 | if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_npm) }} |
| 159 | uses: ./.github/workflows/release-npm.yml |
| 160 | with: |
| 161 | channel: stable |
| 162 | base_version: ${{ needs.authorize.outputs.version }} |
| 163 | tag: ${{ needs.authorize.outputs.npm_tag }} |
| 164 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 165 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 166 | orchestrated: true |
| 167 | secrets: inherit |
| 168 | |
| 169 | signpath-preflight: |
| 170 | name: verify stable SignPath control plane |
| 171 | needs: authorize |
| 172 | if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }} |
| 173 | uses: ./.github/workflows/release-desktop.yml |
| 174 | with: |
| 175 | channel: stable |
| 176 | tag: ${{ needs.authorize.outputs.desktop_tag }} |
| 177 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 178 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 179 | orchestrated: true |
| 180 | signing_preflight: true |
| 181 | secrets: inherit |
| 182 | |
| 183 | desktop: |
| 184 | name: publish desktop |
| 185 | needs: [authorize, signpath-preflight] |
| 186 | if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish_desktop) }} |
| 187 | uses: ./.github/workflows/release-desktop.yml |
| 188 | with: |
| 189 | channel: stable |
| 190 | tag: ${{ needs.authorize.outputs.desktop_tag }} |
| 191 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 192 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 193 | orchestrated: true |
| 194 | signing_preflight_verified: true |
| 195 | secrets: inherit |
| 196 | |
| 197 | postflight: |
| 198 | name: verify stable release artifacts |
| 199 | needs: [authorize, cli, npm, desktop] |
| 200 | if: ${{ always() && !cancelled() }} |
| 201 | runs-on: ubuntu-latest |
| 202 | permissions: |
| 203 | actions: write |
| 204 | contents: write |
| 205 | steps: |
| 206 | - name: Require every publisher to succeed |
| 207 | env: |
| 208 | PUBLISH_CLI: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_cli }} |
| 209 | PUBLISH_NPM: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }} |
| 210 | PUBLISH_DESKTOP: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }} |
| 211 | CLI_RESULT: ${{ needs.cli.result }} |
| 212 | NPM_RESULT: ${{ needs.npm.result }} |
| 213 | DESKTOP_RESULT: ${{ needs.desktop.result }} |
| 214 | run: | |
| 215 | set -euo pipefail |
| 216 | for channel in cli npm desktop; do |
| 217 | selected_var="PUBLISH_${channel^^}" |
| 218 | result_var="${channel^^}_RESULT" |
| 219 | selected="${!selected_var}" |
| 220 | result="${!result_var}" |
| 221 | if [ "$selected" != "true" ]; then |
| 222 | echo "$channel recovery skipped; public postflight will still verify it" |
| 223 | continue |
| 224 | fi |
| 225 | if [ "$result" != "success" ]; then |
| 226 | echo "::error::$channel stable publisher result is $result, expected success" |
| 227 | exit 1 |
| 228 | fi |
| 229 | done |
| 230 | - uses: actions/checkout@v7 |
| 231 | with: |
| 232 | # Postflight belongs to the trusted control plane, not the old build |
| 233 | # candidate, which may predate this verifier. |
| 234 | ref: ${{ github.sha }} |
| 235 | - uses: actions/setup-node@v7 |
| 236 | with: |
| 237 | node-version: "22" |
| 238 | - name: Verify public artifacts and npm latest |
| 239 | env: |
| 240 | GH_TOKEN: ${{ github.token }} |
| 241 | RELEASE_REPOSITORY: ${{ github.repository }} |
| 242 | RELEASE_VERSION: ${{ needs.authorize.outputs.version }} |
| 243 | CLI_TAG: ${{ needs.authorize.outputs.cli_tag }} |
| 244 | DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }} |
| 245 | run: bash scripts/verify-stable-release-artifacts.sh |
| 246 | - name: Publish exact Stable release record |
| 247 | env: |
| 248 | GH_TOKEN: ${{ github.token }} |
| 249 | VERSION: ${{ needs.authorize.outputs.version }} |
| 250 | CLI_TAG: ${{ needs.authorize.outputs.cli_tag }} |
| 251 | RELEASE_SHA: ${{ needs.authorize.outputs.sha }} |
| 252 | run: | |
| 253 | set -euo pipefail |
| 254 | if ! node -e ' |
| 255 | const catalog = require("./release-notes/releases.json"); |
| 256 | const release = catalog.releases.find((item) => item.version === process.env.VERSION); |
| 257 | process.exit(release?.status === "reviewed" ? 0 : 1); |
| 258 | '; then |
| 259 | echo "Legacy Stable notes do not require a publication marker" |
| 260 | exit 0 |
| 261 | fi |
| 262 | node scripts/release-event.mjs generate \ |
| 263 | --version "$VERSION" --sha "$RELEASE_SHA" \ |
| 264 | --published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \ |
| 265 | --output /tmp/release-event.json |
| 266 | existing="$(mktemp -d)" |
| 267 | if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then |
| 268 | cmp -s /tmp/release-event.json "$existing/release-event.json" || { |
| 269 | echo "::error::published release-event.json differs from the approved Stable event" |
| 270 | exit 1 |
| 271 | } |
| 272 | else |
| 273 | gh release upload "$CLI_TAG" /tmp/release-event.json |
| 274 | fi |
| 275 | - name: Refresh public changelog |
| 276 | env: |
| 277 | GH_TOKEN: ${{ github.token }} |
| 278 | run: gh workflow run pages.yml --ref main-v2 |
| 279 |