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