| 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-${{ inputs.tag || github.ref_name }} |
| 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 | |
| 59 | jobs: |
| 60 | preflight: |
| 61 | name: validate stable release set |
| 62 | runs-on: ubuntu-latest |
| 63 | permissions: |
| 64 | actions: read |
| 65 | contents: read |
| 66 | outputs: |
| 67 | version: ${{ steps.release.outputs.version }} |
| 68 | cli_tag: ${{ steps.release.outputs.cli_tag }} |
| 69 | npm_tag: ${{ steps.release.outputs.npm_tag }} |
| 70 | desktop_tag: ${{ steps.release.outputs.desktop_tag }} |
| 71 | sha: ${{ steps.release.outputs.sha }} |
| 72 | steps: |
| 73 | - uses: actions/checkout@v7 |
| 74 | with: |
| 75 | fetch-depth: 0 |
| 76 | # A recovery dispatch uses the fixed workflow/scripts from protected |
| 77 | # main-v2. Publishers still check out the immutable approved tag SHA. |
| 78 | ref: ${{ github.sha }} |
| 79 | - uses: actions/setup-go@v7 |
| 80 | with: |
| 81 | go-version-file: go.mod |
| 82 | cache: true |
| 83 | - uses: actions/setup-node@v7 |
| 84 | with: |
| 85 | node-version: "22" |
| 86 | - name: Resolve stable release |
| 87 | id: release |
| 88 | env: |
| 89 | RELEASE_TAG: ${{ inputs.tag || github.ref_name }} |
| 90 | ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }} |
| 91 | run: bash scripts/resolve-stable-release.sh |
| 92 | # scripts/manual-desktop-exception.sh owns which tags may ship without |
| 93 | # Windows Authenticode. A tag approved before its candidate exists keeps |
| 94 | # the normal candidate and push-CI validation below. |
| 95 | - name: Restrict manual Desktop distribution |
| 96 | if: ${{ inputs.desktop_manual_only }} |
| 97 | run: | |
| 98 | bash scripts/manual-desktop-exception.sh validate \ |
| 99 | "${{ steps.release.outputs.desktop_tag }}" \ |
| 100 | "${{ steps.release.outputs.sha }}" \ |
| 101 | "${{ inputs.allow_recovery }}" |
| 102 | - name: Revalidate normal release candidate and exact push CI |
| 103 | if: ${{ !inputs.allow_recovery }} |
| 104 | env: |
| 105 | GH_TOKEN: ${{ github.token }} |
| 106 | RELEASE_REPOSITORY: ${{ github.repository }} |
| 107 | RELEASE_CI_WAIT_SECONDS: 60 |
| 108 | RELEASE_VERSION: ${{ steps.release.outputs.version }} |
| 109 | RELEASE_SHA: ${{ steps.release.outputs.sha }} |
| 110 | run: | |
| 111 | bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA" |
| 112 | bash scripts/verify-release-push-ci.sh "$RELEASE_SHA" |
| 113 | - name: Validate reviewed release notes |
| 114 | run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md |
| 115 | # Recovery builds deliberately check out the immutable tagged candidate, |
| 116 | # which can predate its reviewed release-note entry. Carry the exact file |
| 117 | # validated by this protected control-plane job into both publishers. |
| 118 | - name: Upload reviewed release notes |
| 119 | uses: actions/upload-artifact@v7 |
| 120 | with: |
| 121 | name: orchestrator-reviewed-release-notes |
| 122 | path: /tmp/release-notes.md |
| 123 | if-no-files-found: error |
| 124 | retention-days: 1 |
| 125 | - name: Cache hit guard |
| 126 | run: ./scripts/cache-guard.sh |
| 127 | |
| 128 | authorize: |
| 129 | name: approve stable release |
| 130 | needs: preflight |
| 131 | runs-on: ubuntu-latest |
| 132 | environment: release |
| 133 | permissions: |
| 134 | contents: read |
| 135 | outputs: |
| 136 | version: ${{ steps.approved.outputs.version }} |
| 137 | cli_tag: ${{ steps.approved.outputs.cli_tag }} |
| 138 | npm_tag: ${{ steps.approved.outputs.npm_tag }} |
| 139 | desktop_tag: ${{ steps.approved.outputs.desktop_tag }} |
| 140 | sha: ${{ steps.approved.outputs.sha }} |
| 141 | steps: |
| 142 | - name: Record approved release |
| 143 | id: approved |
| 144 | env: |
| 145 | VERSION: ${{ needs.preflight.outputs.version }} |
| 146 | CLI_TAG: ${{ needs.preflight.outputs.cli_tag }} |
| 147 | NPM_TAG: ${{ needs.preflight.outputs.npm_tag }} |
| 148 | DESKTOP_TAG: ${{ needs.preflight.outputs.desktop_tag }} |
| 149 | RELEASE_SHA: ${{ needs.preflight.outputs.sha }} |
| 150 | run: | |
| 151 | { |
| 152 | echo "version=$VERSION" |
| 153 | echo "cli_tag=$CLI_TAG" |
| 154 | echo "npm_tag=$NPM_TAG" |
| 155 | echo "desktop_tag=$DESKTOP_TAG" |
| 156 | echo "sha=$RELEASE_SHA" |
| 157 | } >> "$GITHUB_OUTPUT" |
| 158 | echo "Approved stable release $VERSION at $RELEASE_SHA" |
| 159 | |
| 160 | cli: |
| 161 | name: publish CLI and Homebrew |
| 162 | needs: [authorize, signpath-preflight] |
| 163 | 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) }} |
| 164 | uses: ./.github/workflows/release.yml |
| 165 | with: |
| 166 | tag: ${{ needs.authorize.outputs.cli_tag }} |
| 167 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 168 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 169 | orchestrated: true |
| 170 | secrets: inherit |
| 171 | |
| 172 | npm: |
| 173 | name: publish npm |
| 174 | needs: [authorize, signpath-preflight] |
| 175 | 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) }} |
| 176 | # A reusable workflow only receives permissions the caller grants here. |
| 177 | permissions: |
| 178 | contents: read |
| 179 | id-token: write |
| 180 | uses: ./.github/workflows/release-npm.yml |
| 181 | with: |
| 182 | channel: stable |
| 183 | base_version: ${{ needs.authorize.outputs.version }} |
| 184 | tag: ${{ needs.authorize.outputs.npm_tag }} |
| 185 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 186 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 187 | orchestrated: true |
| 188 | secrets: inherit |
| 189 | |
| 190 | signpath-preflight: |
| 191 | name: verify stable SignPath control plane |
| 192 | needs: authorize |
| 193 | if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }} |
| 194 | uses: ./.github/workflows/release-desktop.yml |
| 195 | with: |
| 196 | channel: stable |
| 197 | tag: ${{ needs.authorize.outputs.desktop_tag }} |
| 198 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 199 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 200 | orchestrated: true |
| 201 | signing_preflight: true |
| 202 | desktop_manual_only: ${{ inputs.desktop_manual_only || false }} |
| 203 | reuse_manual_artifacts: ${{ inputs.reuse_manual_artifacts || false }} |
| 204 | secrets: inherit |
| 205 | |
| 206 | desktop: |
| 207 | name: publish desktop |
| 208 | needs: [authorize, signpath-preflight] |
| 209 | if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish_desktop) }} |
| 210 | uses: ./.github/workflows/release-desktop.yml |
| 211 | with: |
| 212 | channel: stable |
| 213 | tag: ${{ needs.authorize.outputs.desktop_tag }} |
| 214 | approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }} |
| 215 | approved_sha: ${{ needs.authorize.outputs.sha }} |
| 216 | orchestrated: true |
| 217 | signing_preflight_verified: true |
| 218 | desktop_manual_only: ${{ inputs.desktop_manual_only || false }} |
| 219 | reuse_manual_artifacts: ${{ inputs.reuse_manual_artifacts || false }} |
| 220 | preflight_artifact_prefix: ${{ needs.signpath-preflight.outputs.artifact_prefix }} |
| 221 | secrets: inherit |
| 222 | |
| 223 | postflight: |
| 224 | name: verify stable release artifacts |
| 225 | needs: [authorize, cli, npm, desktop] |
| 226 | if: ${{ always() && !cancelled() }} |
| 227 | runs-on: ubuntu-latest |
| 228 | permissions: |
| 229 | actions: write |
| 230 | contents: write |
| 231 | steps: |
| 232 | - name: Require every publisher to succeed |
| 233 | env: |
| 234 | PUBLISH_CLI: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_cli }} |
| 235 | PUBLISH_NPM: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }} |
| 236 | PUBLISH_DESKTOP: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }} |
| 237 | CLI_RESULT: ${{ needs.cli.result }} |
| 238 | NPM_RESULT: ${{ needs.npm.result }} |
| 239 | DESKTOP_RESULT: ${{ needs.desktop.result }} |
| 240 | run: | |
| 241 | set -euo pipefail |
| 242 | for channel in cli npm desktop; do |
| 243 | selected_var="PUBLISH_${channel^^}" |
| 244 | result_var="${channel^^}_RESULT" |
| 245 | selected="${!selected_var}" |
| 246 | result="${!result_var}" |
| 247 | if [ "$selected" != "true" ]; then |
| 248 | echo "$channel recovery skipped; public postflight will still verify it" |
| 249 | continue |
| 250 | fi |
| 251 | if [ "$result" != "success" ]; then |
| 252 | echo "::error::$channel stable publisher result is $result, expected success" |
| 253 | exit 1 |
| 254 | fi |
| 255 | done |
| 256 | - uses: actions/checkout@v7 |
| 257 | with: |
| 258 | # Postflight belongs to the trusted control plane, not the old build |
| 259 | # candidate, which may predate this verifier. |
| 260 | ref: ${{ github.sha }} |
| 261 | - uses: actions/setup-node@v7 |
| 262 | with: |
| 263 | node-version: "22" |
| 264 | - name: Verify public artifacts and npm latest |
| 265 | env: |
| 266 | GH_TOKEN: ${{ github.token }} |
| 267 | RELEASE_REPOSITORY: ${{ github.repository }} |
| 268 | RELEASE_VERSION: ${{ needs.authorize.outputs.version }} |
| 269 | CLI_TAG: ${{ needs.authorize.outputs.cli_tag }} |
| 270 | DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }} |
| 271 | DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }} |
| 272 | run: bash scripts/verify-stable-release-artifacts.sh |
| 273 | - name: Publish exact Stable release record |
| 274 | env: |
| 275 | GH_TOKEN: ${{ github.token }} |
| 276 | VERSION: ${{ needs.authorize.outputs.version }} |
| 277 | CLI_TAG: ${{ needs.authorize.outputs.cli_tag }} |
| 278 | RELEASE_SHA: ${{ needs.authorize.outputs.sha }} |
| 279 | run: | |
| 280 | set -euo pipefail |
| 281 | if ! node -e ' |
| 282 | const catalog = require("./release-notes/releases.json"); |
| 283 | const release = catalog.releases.find((item) => item.version === process.env.VERSION); |
| 284 | process.exit(release?.status === "reviewed" ? 0 : 1); |
| 285 | '; then |
| 286 | echo "Legacy Stable notes do not require a publication marker" |
| 287 | exit 0 |
| 288 | fi |
| 289 | node scripts/release-event.mjs generate \ |
| 290 | --version "$VERSION" --sha "$RELEASE_SHA" \ |
| 291 | --published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \ |
| 292 | --output /tmp/release-event.json |
| 293 | existing="$(mktemp -d)" |
| 294 | if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then |
| 295 | cmp -s /tmp/release-event.json "$existing/release-event.json" || { |
| 296 | echo "::error::published release-event.json differs from the approved Stable event" |
| 297 | exit 1 |
| 298 | } |
| 299 | else |
| 300 | gh release upload "$CLI_TAG" /tmp/release-event.json |
| 301 | fi |
| 302 | - name: Refresh public changelog |
| 303 | env: |
| 304 | GH_TOKEN: ${{ github.token }} |
| 305 | run: gh workflow run pages.yml --ref main-v2 |
| 306 |