| 1 | name: Release |
| 2 | |
| 3 | # Native CLI binary line. Official releases are called by the protected Stable |
| 4 | # orchestrator. Manual dispatch remains available only for official recovery; |
| 5 | # historical Preview inputs below are workflow-call compatibility, not a public |
| 6 | # publication entrypoint. |
| 7 | on: |
| 8 | workflow_dispatch: |
| 9 | inputs: |
| 10 | channel: |
| 11 | description: "Standalone CLI recovery channel" |
| 12 | required: true |
| 13 | default: stable |
| 14 | type: choice |
| 15 | options: [stable] |
| 16 | tag: |
| 17 | description: "Existing official CLI tag (for example v1.18.0)" |
| 18 | required: true |
| 19 | type: string |
| 20 | workflow_call: |
| 21 | inputs: |
| 22 | channel: |
| 23 | description: "Native CLI release channel selected by the approved orchestrator" |
| 24 | required: false |
| 25 | default: stable |
| 26 | type: string |
| 27 | tag: |
| 28 | description: "Existing CLI tag selected by the approved release orchestrator" |
| 29 | required: true |
| 30 | type: string |
| 31 | approved_cli_tag: |
| 32 | description: "Stable CLI tag recorded by the approved orchestrator" |
| 33 | required: true |
| 34 | type: string |
| 35 | approved_sha: |
| 36 | description: "Immutable commit recorded by the approved orchestrator" |
| 37 | required: true |
| 38 | type: string |
| 39 | orchestrated: |
| 40 | description: "True only when called by an approved release orchestrator" |
| 41 | required: false |
| 42 | default: false |
| 43 | type: boolean |
| 44 | orchestrator: |
| 45 | description: "Trusted release orchestrator (legacy Preview calls remain readable)" |
| 46 | required: false |
| 47 | default: stable |
| 48 | type: string |
| 49 | allow_preview_recovery: |
| 50 | description: "Legacy compatibility for already-created Preview runs" |
| 51 | required: false |
| 52 | default: false |
| 53 | type: boolean |
| 54 | candidate_artifact_name: |
| 55 | description: "Same-run artifact containing a verified sealed release candidate" |
| 56 | required: false |
| 57 | default: "" |
| 58 | type: string |
| 59 | candidate_verified: |
| 60 | description: "Protected Stable preflight verified candidate provenance and bytes" |
| 61 | required: false |
| 62 | default: false |
| 63 | type: boolean |
| 64 | |
| 65 | permissions: |
| 66 | contents: write # create the release and upload archives |
| 67 | |
| 68 | concurrency: |
| 69 | # A channel pointer is a monotonic public state machine. Serialize all |
| 70 | # publishers for the same channel so an older recovery run cannot pass its |
| 71 | # read-before-write window after a newer release has published. |
| 72 | group: release-cli-${{ inputs.channel || 'stable' }} |
| 73 | cancel-in-progress: false |
| 74 | |
| 75 | jobs: |
| 76 | resolve: |
| 77 | name: resolve CLI release |
| 78 | runs-on: ubuntu-latest |
| 79 | outputs: |
| 80 | tag: ${{ steps.release.outputs.tag }} |
| 81 | version: ${{ steps.release.outputs.version }} |
| 82 | base_version: ${{ steps.release.outputs.base_version }} |
| 83 | notes_version: ${{ steps.release.outputs.notes_version }} |
| 84 | channel: ${{ steps.release.outputs.channel }} |
| 85 | prerelease: ${{ steps.release.outputs.prerelease }} |
| 86 | sha: ${{ steps.candidate.outputs.sha }} |
| 87 | steps: |
| 88 | - uses: actions/checkout@v7 |
| 89 | with: |
| 90 | fetch-depth: 0 |
| 91 | ref: ${{ github.sha }} |
| 92 | - name: Resolve channel and tag |
| 93 | id: release |
| 94 | env: |
| 95 | EVENT_NAME: ${{ github.event_name }} |
| 96 | IN_ORCHESTRATED: ${{ inputs.orchestrated }} |
| 97 | IN_CHANNEL: ${{ inputs.channel }} |
| 98 | IN_TAG: ${{ inputs.tag }} |
| 99 | REF_NAME: ${{ github.ref_name }} |
| 100 | CALLER_REF: ${{ github.ref }} |
| 101 | CALLER_REF_PROTECTED: ${{ github.ref_protected }} |
| 102 | run: bash scripts/resolve-cli-release.sh |
| 103 | - name: Record immutable candidate |
| 104 | id: candidate |
| 105 | env: |
| 106 | RELEASE_TAG: ${{ steps.release.outputs.tag }} |
| 107 | RELEASE_CHANNEL: ${{ steps.release.outputs.channel }} |
| 108 | IN_ORCHESTRATED: ${{ inputs.orchestrated }} |
| 109 | IN_ORCHESTRATOR: ${{ inputs.orchestrator }} |
| 110 | ALLOW_PREVIEW_RECOVERY: ${{ inputs.allow_preview_recovery }} |
| 111 | run: | |
| 112 | set -euo pipefail |
| 113 | git fetch origin main-v2 |
| 114 | sha="$(git rev-parse "$RELEASE_TAG^{commit}")" |
| 115 | if ! git merge-base --is-ancestor "$sha" origin/main-v2; then |
| 116 | echo "::error::$RELEASE_TAG points to $sha, which is not on main-v2 history" |
| 117 | exit 1 |
| 118 | fi |
| 119 | if [ "$ALLOW_PREVIEW_RECOVERY" = "true" ]; then |
| 120 | if [ "$IN_ORCHESTRATED" != "true" ] || [ "$IN_ORCHESTRATOR" != "preview" ] || [ "$RELEASE_CHANNEL" != "preview" ]; then |
| 121 | echo "::error::Preview recovery requires the approved Preview orchestrator" |
| 122 | exit 1 |
| 123 | fi |
| 124 | elif [ "$RELEASE_CHANNEL" = "preview" ] && [ "$sha" != "$(git rev-parse origin/main-v2)" ]; then |
| 125 | echo "::error::CLI Preview must tag current main-v2; $RELEASE_TAG points to $sha" |
| 126 | exit 1 |
| 127 | fi |
| 128 | echo "sha=$sha" >> "$GITHUB_OUTPUT" |
| 129 | - name: Verify existing protected tag |
| 130 | env: |
| 131 | RELEASE_TAG: ${{ steps.release.outputs.tag }} |
| 132 | APPROVED_SHA: ${{ steps.candidate.outputs.sha }} |
| 133 | VERIFY_RELEASE_CHECKOUT: false |
| 134 | run: bash scripts/verify-release-tag.sh |
| 135 | |
| 136 | orchestration-guard: |
| 137 | name: verify approved orchestrator |
| 138 | needs: resolve |
| 139 | if: ${{ inputs.orchestrated }} |
| 140 | runs-on: ubuntu-latest |
| 141 | permissions: |
| 142 | contents: read |
| 143 | steps: |
| 144 | - uses: actions/checkout@v7 |
| 145 | with: |
| 146 | fetch-depth: 0 |
| 147 | ref: ${{ github.sha }} |
| 148 | - name: Verify caller and approved release ref |
| 149 | env: |
| 150 | ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} |
| 151 | EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-{1}.yml@{2}', github.repository, inputs.orchestrator, github.ref) }} |
| 152 | CALLER_EVENT_NAME: ${{ github.event_name }} |
| 153 | CALLER_REF: ${{ github.ref }} |
| 154 | CALLER_REF_PROTECTED: ${{ github.ref_protected }} |
| 155 | CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} |
| 156 | CALLER_SHA: ${{ github.sha }} |
| 157 | APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }} |
| 158 | APPROVED_SHA: ${{ inputs.approved_sha }} |
| 159 | APPROVED_CHANNEL: ${{ inputs.orchestrator == 'promote' && 'stable' || inputs.orchestrator }} |
| 160 | RELEASE_TAG: ${{ inputs.tag }} |
| 161 | VERIFY_RELEASE_CHECKOUT: false |
| 162 | run: | |
| 163 | bash scripts/verify-release-authorization.sh |
| 164 | bash scripts/verify-release-tag.sh |
| 165 | if [ -n "${{ inputs.candidate_artifact_name }}" ] && [ "${{ inputs.candidate_verified }}" != "true" ]; then |
| 166 | echo "::error::prepared CLI artifacts require verified candidate provenance" |
| 167 | exit 1 |
| 168 | fi |
| 169 | |
| 170 | release-gate: |
| 171 | name: approve standalone CLI release |
| 172 | needs: resolve |
| 173 | if: ${{ !inputs.orchestrated }} |
| 174 | runs-on: ubuntu-latest |
| 175 | environment: release |
| 176 | steps: |
| 177 | - env: |
| 178 | RELEASE_TAG: ${{ needs.resolve.outputs.tag }} |
| 179 | RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }} |
| 180 | run: echo "Approved standalone CLI $RELEASE_CHANNEL release $RELEASE_TAG" |
| 181 | |
| 182 | cache-guard: |
| 183 | name: cache hit guard |
| 184 | needs: [resolve, orchestration-guard, release-gate] |
| 185 | if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }} |
| 186 | runs-on: ubuntu-latest |
| 187 | steps: |
| 188 | - uses: actions/checkout@v7 |
| 189 | with: |
| 190 | ref: ${{ needs.resolve.outputs.sha }} |
| 191 | - uses: actions/setup-go@v7 |
| 192 | if: ${{ !inputs.candidate_verified }} |
| 193 | with: |
| 194 | go-version-file: go.mod |
| 195 | cache: true |
| 196 | - if: ${{ !inputs.candidate_verified }} |
| 197 | run: ./scripts/cache-guard.sh |
| 198 | - name: Verify embedded documentation identity |
| 199 | if: ${{ !inputs.candidate_verified }} |
| 200 | env: |
| 201 | DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.tag }} |
| 202 | DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }} |
| 203 | run: | |
| 204 | if [ ! -f scripts/verify-embedded-docs.sh ]; then |
| 205 | echo "Legacy candidate predates the embedded docs contract; skipping." |
| 206 | exit 0 |
| 207 | fi |
| 208 | bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION" |
| 209 | |
| 210 | goreleaser: |
| 211 | name: archives + checksums + homebrew tap |
| 212 | needs: [resolve, cache-guard] |
| 213 | if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }} |
| 214 | runs-on: ubuntu-latest |
| 215 | # The Stable caller has already passed the single GitHub release approval. |
| 216 | # Official standalone recovery passes release-gate above. This job therefore |
| 217 | # must not add a second GitHub environment approval. |
| 218 | steps: |
| 219 | - uses: actions/checkout@v7 |
| 220 | with: |
| 221 | fetch-depth: 0 |
| 222 | ref: ${{ needs.resolve.outputs.sha }} |
| 223 | # Recovery may build an immutable tag that predates the current recovery |
| 224 | # policy. Keep product sources pinned above, but execute publication |
| 225 | # decisions from the protected workflow commit. |
| 226 | - uses: actions/checkout@v7 |
| 227 | with: |
| 228 | fetch-depth: 0 |
| 229 | path: release-control |
| 230 | ref: ${{ github.workflow_sha }} |
| 231 | - name: Isolate release-control checkout from product git state |
| 232 | run: | |
| 233 | set -euo pipefail |
| 234 | git_common_dir="$(git rev-parse --path-format=absolute --git-common-dir)" |
| 235 | exclude_file="$git_common_dir/info/exclude" |
| 236 | if ! grep -qxF '/release-control/' "$exclude_file"; then |
| 237 | printf '%s\n' '/release-control/' >> "$exclude_file" |
| 238 | fi |
| 239 | git check-ignore -q release-control/ |
| 240 | dirty="$(git status --porcelain --untracked-files=all)" |
| 241 | if [ -n "$dirty" ]; then |
| 242 | printf 'product checkout is dirty before release:\n%s\n' "$dirty" >&2 |
| 243 | exit 1 |
| 244 | fi |
| 245 | - uses: actions/setup-go@v7 |
| 246 | with: |
| 247 | go-version-file: go.mod |
| 248 | cache: true |
| 249 | - uses: actions/setup-node@v7 |
| 250 | with: |
| 251 | node-version: "22" |
| 252 | - name: Download sealed release candidate |
| 253 | if: ${{ inputs.candidate_artifact_name != '' }} |
| 254 | uses: actions/download-artifact@v8 |
| 255 | with: |
| 256 | name: ${{ inputs.candidate_artifact_name }} |
| 257 | path: ${{ runner.temp }}/release-candidate |
| 258 | - name: Verify prepared CLI checksums |
| 259 | if: ${{ inputs.candidate_artifact_name != '' }} |
| 260 | working-directory: ${{ runner.temp }}/release-candidate/cli |
| 261 | run: sha256sum -c SHA256SUMS |
| 262 | - name: Download orchestrator-reviewed release notes |
| 263 | if: ${{ inputs.orchestrated }} |
| 264 | uses: actions/download-artifact@v8 |
| 265 | with: |
| 266 | name: orchestrator-reviewed-release-notes |
| 267 | path: /tmp/orchestrator-reviewed-release-notes |
| 268 | - name: Use orchestrator-reviewed release notes |
| 269 | if: ${{ inputs.orchestrated }} |
| 270 | run: | |
| 271 | test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md |
| 272 | cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md |
| 273 | - name: Render reviewed release notes |
| 274 | if: ${{ !inputs.orchestrated }} |
| 275 | env: |
| 276 | RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }} |
| 277 | run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md |
| 278 | - name: Revalidate approved release ref |
| 279 | env: |
| 280 | RELEASE_TAG: ${{ needs.resolve.outputs.tag }} |
| 281 | APPROVED_SHA: ${{ needs.resolve.outputs.sha }} |
| 282 | run: bash scripts/verify-release-tag.sh |
| 283 | - name: Decide whether CLI artifacts need publication |
| 284 | id: publication |
| 285 | env: |
| 286 | GH_TOKEN: ${{ github.token }} |
| 287 | TAG: ${{ needs.resolve.outputs.tag }} |
| 288 | CHANNEL: ${{ needs.resolve.outputs.channel }} |
| 289 | PRERELEASE: ${{ needs.resolve.outputs.prerelease }} |
| 290 | run: | |
| 291 | set -euo pipefail |
| 292 | validation_channel="$CHANNEL" |
| 293 | if [ "$CHANNEL" = "stable" ] && [ "$PRERELEASE" = "true" ]; then |
| 294 | validation_channel=any |
| 295 | fi |
| 296 | release_json=/tmp/existing-cli-release.json |
| 297 | release_error=/tmp/existing-cli-release.error |
| 298 | checksums=/tmp/existing-cli-release-SHA256SUMS |
| 299 | if gh api "repos/${{ github.repository }}/releases/tags/$TAG" \ |
| 300 | >"$release_json" 2>"$release_error"; then |
| 301 | gh release download "$TAG" -R "${{ github.repository }}" \ |
| 302 | --pattern SHA256SUMS --output "$checksums" |
| 303 | decision="$( |
| 304 | bash release-control/scripts/decide-cli-release-publication.sh \ |
| 305 | "$validation_channel" "$TAG" "${{ github.repository }}" \ |
| 306 | "$release_json" "$checksums" |
| 307 | )" |
| 308 | if [ -n "${{ inputs.candidate_artifact_name }}" ]; then |
| 309 | cmp -s "$checksums" "$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" || { |
| 310 | echo "::error::existing CLI release differs from the sealed candidate" |
| 311 | exit 1 |
| 312 | } |
| 313 | fi |
| 314 | echo "existing CLI release $TAG is complete and checksum-bound; reusing it" |
| 315 | elif grep -Eiq 'HTTP 404|Not Found' "$release_error"; then |
| 316 | decision="$( |
| 317 | bash release-control/scripts/decide-cli-release-publication.sh \ |
| 318 | "$validation_channel" "$TAG" "${{ github.repository }}" - - |
| 319 | )" |
| 320 | echo "CLI release $TAG does not exist; GoReleaser will publish it" |
| 321 | else |
| 322 | cat "$release_error" >&2 |
| 323 | exit 1 |
| 324 | fi |
| 325 | test "$decision" = "publish" -o "$decision" = "reuse" |
| 326 | echo "decision=$decision" >> "$GITHUB_OUTPUT" |
| 327 | - uses: goreleaser/goreleaser-action@v7 |
| 328 | if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }} |
| 329 | with: |
| 330 | version: '~> v2' |
| 331 | args: release --clean |
| 332 | env: |
| 333 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 334 | HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }} |
| 335 | # workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even |
| 336 | # though checkout is on the release tag. Pin GoReleaser explicitly, and |
| 337 | # avoid ambiguity from the three release tags sharing one commit. |
| 338 | GORELEASER_CURRENT_TAG: ${{ needs.resolve.outputs.tag }} |
| 339 | |
| 340 | - name: Publish prepared CLI archives |
| 341 | if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name != '' }} |
| 342 | env: |
| 343 | GH_TOKEN: ${{ github.token }} |
| 344 | TAG: ${{ needs.resolve.outputs.tag }} |
| 345 | run: | |
| 346 | gh release create "$TAG" \ |
| 347 | "$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-amd64.tar.gz" \ |
| 348 | "$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-arm64.tar.gz" \ |
| 349 | "$RUNNER_TEMP/release-candidate/cli/reasonix-linux-amd64.tar.gz" \ |
| 350 | "$RUNNER_TEMP/release-candidate/cli/reasonix-linux-arm64.tar.gz" \ |
| 351 | "$RUNNER_TEMP/release-candidate/cli/reasonix-windows-amd64.zip" \ |
| 352 | "$RUNNER_TEMP/release-candidate/cli/reasonix-windows-arm64.zip" \ |
| 353 | "$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" \ |
| 354 | --title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false |
| 355 | |
| 356 | - name: Publish prepared Homebrew cask |
| 357 | if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' }} |
| 358 | env: |
| 359 | HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} |
| 360 | run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb" |
| 361 | |
| 362 | - name: Publish product release notes |
| 363 | env: |
| 364 | GH_TOKEN: ${{ github.token }} |
| 365 | TAG: ${{ needs.resolve.outputs.tag }} |
| 366 | run: gh release edit "$TAG" --notes-file /tmp/release-notes.md |
| 367 | |
| 368 | - name: Publish CLI release metadata to R2 |
| 369 | env: |
| 370 | GH_TOKEN: ${{ github.token }} |
| 371 | TAG: ${{ needs.resolve.outputs.tag }} |
| 372 | NOTES_TAG: ${{ needs.resolve.outputs.notes_version }} |
| 373 | HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} |
| 374 | AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} |
| 375 | AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} |
| 376 | AWS_DEFAULT_REGION: auto |
| 377 | R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} |
| 378 | R2_BUCKET: ${{ secrets.R2_BUCKET }} |
| 379 | run: | |
| 380 | set -euo pipefail |
| 381 | if [ "$HAS_R2" != "true" ]; then |
| 382 | echo "R2 secrets not configured; skipping CLI release metadata" |
| 383 | exit 0 |
| 384 | fi |
| 385 | |
| 386 | channel="" |
| 387 | if [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then |
| 388 | channel="stable" |
| 389 | elif [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$ ]]; then |
| 390 | channel="preview" |
| 391 | else |
| 392 | echo "internal CLI release $TAG; publishing only immutable metadata" |
| 393 | fi |
| 394 | |
| 395 | required_assets='[ |
| 396 | "reasonix-darwin-amd64.tar.gz", |
| 397 | "reasonix-darwin-arm64.tar.gz", |
| 398 | "reasonix-linux-amd64.tar.gz", |
| 399 | "reasonix-linux-arm64.tar.gz", |
| 400 | "reasonix-windows-amd64.zip", |
| 401 | "reasonix-windows-arm64.zip", |
| 402 | "SHA256SUMS" |
| 403 | ]' |
| 404 | gh api "repos/${{ github.repository }}/releases/tags/$TAG" > /tmp/cli-release.raw.json |
| 405 | jq --arg tag "$TAG" --arg notes_tag "$NOTES_TAG" --argjson required "$required_assets" ' |
| 406 | if .tag_name != $tag then error("release tag mismatch") else . end | |
| 407 | if .draft then error("draft release cannot be published") else . end | |
| 408 | . as $release | |
| 409 | ($release.assets | map({key: .name, value: .}) | from_entries) as $assets | |
| 410 | if ($required | all(. as $name | $assets[$name] != null)) |
| 411 | then { |
| 412 | tag_name: $release.tag_name, |
| 413 | prerelease: $release.prerelease, |
| 414 | html_url: $release.html_url, |
| 415 | release_notes_url: ("https://reasonix.io/changelog/" + $notes_tag + "/"), |
| 416 | assets: [ |
| 417 | $required[] as $name | |
| 418 | $assets[$name] | |
| 419 | { |
| 420 | name: .name, |
| 421 | browser_download_url: .browser_download_url, |
| 422 | size: .size |
| 423 | } |
| 424 | ] |
| 425 | } |
| 426 | else error("release is missing one or more required CLI assets") |
| 427 | end |
| 428 | ' /tmp/cli-release.raw.json > /tmp/cli-release.json |
| 429 | if [ -n "$channel" ]; then |
| 430 | bash scripts/validate-cli-release-manifest.sh \ |
| 431 | "$channel" "$TAG" "${{ github.repository }}" /tmp/cli-release.json "$NOTES_TAG" |
| 432 | else |
| 433 | bash scripts/validate-cli-release-manifest.sh \ |
| 434 | any "$TAG" "${{ github.repository }}" /tmp/cli-release.json "$NOTES_TAG" |
| 435 | fi |
| 436 | |
| 437 | endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" |
| 438 | validation_channel="${channel:-any}" |
| 439 | immutable_key="cli/releases/${TAG}/latest.json" |
| 440 | immutable_error="$(mktemp)" |
| 441 | if aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \ |
| 442 | --endpoint-url "$endpoint" 2>"$immutable_error"; then |
| 443 | bash scripts/validate-cli-release-manifest.sh \ |
| 444 | "legacy-${validation_channel}" "$TAG" "${{ github.repository }}" \ |
| 445 | /tmp/cli-release.immutable.json "$NOTES_TAG" |
| 446 | if ! bash scripts/compare-cli-release-manifests.sh \ |
| 447 | /tmp/cli-release.json /tmp/cli-release.immutable.json; then |
| 448 | echo "::error::immutable CLI release metadata for $TAG already exists with different content" |
| 449 | exit 1 |
| 450 | fi |
| 451 | echo "immutable CLI release metadata for $TAG already exists; preserving it" |
| 452 | elif grep -Eiq '404|NoSuchKey|Not Found' "$immutable_error"; then |
| 453 | aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/${immutable_key}" \ |
| 454 | --endpoint-url "$endpoint" \ |
| 455 | --content-type "application/json; charset=utf-8" \ |
| 456 | --cache-control "public, max-age=31536000, immutable" |
| 457 | else |
| 458 | cat "$immutable_error" >&2 |
| 459 | exit 1 |
| 460 | fi |
| 461 | rm -f "$immutable_error" |
| 462 | |
| 463 | aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \ |
| 464 | --endpoint-url "$endpoint" |
| 465 | bash scripts/validate-cli-release-manifest.sh \ |
| 466 | "legacy-${validation_channel}" "$TAG" "${{ github.repository }}" \ |
| 467 | /tmp/cli-release.immutable.json "$NOTES_TAG" |
| 468 | bash scripts/compare-cli-release-manifests.sh \ |
| 469 | /tmp/cli-release.json /tmp/cli-release.immutable.json |
| 470 | |
| 471 | if [ -z "$channel" ]; then |
| 472 | echo "internal CLI release $TAG; Stable and Preview pointers remain unchanged" |
| 473 | exit 0 |
| 474 | fi |
| 475 | |
| 476 | current_tag="" |
| 477 | pointer_error="$(mktemp)" |
| 478 | if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \ |
| 479 | --endpoint-url "$endpoint" 2>"$pointer_error"; then |
| 480 | current_tag="$(jq -er '.tag_name | strings' /tmp/cli-release.pointer.json)" |
| 481 | bash scripts/validate-cli-release-manifest.sh \ |
| 482 | "legacy-${channel}" "$current_tag" "${{ github.repository }}" \ |
| 483 | /tmp/cli-release.pointer.json "$current_tag" |
| 484 | elif grep -Eiq '404|NoSuchKey|Not Found' "$pointer_error"; then |
| 485 | echo "CLI $channel pointer does not exist yet" |
| 486 | else |
| 487 | cat "$pointer_error" >&2 |
| 488 | exit 1 |
| 489 | fi |
| 490 | rm -f "$pointer_error" |
| 491 | |
| 492 | pointer_manifest=- |
| 493 | if [ -n "$current_tag" ]; then |
| 494 | pointer_manifest=/tmp/cli-release.pointer.json |
| 495 | fi |
| 496 | pointer_decision="$( |
| 497 | bash scripts/decide-cli-pointer-update.sh \ |
| 498 | "$channel" /tmp/cli-release.json "$pointer_manifest" |
| 499 | )" |
| 500 | if [ "$pointer_decision" = "skip" ]; then |
| 501 | echo "CLI $channel pointer remains ${current_tag:-unset}; candidate $TAG is not newer and needs no repair" |
| 502 | exit 0 |
| 503 | fi |
| 504 | aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/cli/${channel}/latest.json" \ |
| 505 | --endpoint-url "$endpoint" \ |
| 506 | --content-type "application/json; charset=utf-8" \ |
| 507 | --cache-control "public, max-age=300, stale-if-error=86400" |
| 508 | |
| 509 | aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \ |
| 510 | --endpoint-url "$endpoint" |
| 511 | bash scripts/validate-cli-release-manifest.sh \ |
| 512 | "$channel" "$TAG" "${{ github.repository }}" \ |
| 513 | /tmp/cli-release.pointer.json "$NOTES_TAG" |
| 514 | cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json |
| 515 | echo "CLI $channel pointer -> $TAG" |
| 516 | |
| 517 | - name: Attach desktop manifest compatibility asset |
| 518 | env: |
| 519 | GH_TOKEN: ${{ github.token }} |
| 520 | TAG: ${{ needs.resolve.outputs.tag }} |
| 521 | HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} |
| 522 | R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} |
| 523 | R2_BUCKET: ${{ secrets.R2_BUCKET }} |
| 524 | run: | |
| 525 | set -euo pipefail |
| 526 | case "$TAG" in |
| 527 | *-*) |
| 528 | echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset" |
| 529 | exit 0 |
| 530 | ;; |
| 531 | esac |
| 532 | if [ "$HAS_R2" != "true" ]; then |
| 533 | echo "R2 secrets not configured; skipping desktop manifest compatibility asset" |
| 534 | exit 0 |
| 535 | fi |
| 536 | # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot |
| 537 | # protection), so read the manifest over the authenticated S3 API instead |
| 538 | # of the public edge. |
| 539 | aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}" |
| 540 | aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}" |
| 541 | aws configure set region auto |
| 542 | aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \ |
| 543 | --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" |
| 544 | jq '.download_page = "https://reasonix.io/?download=desktop#start"' latest.raw.json > latest.json |
| 545 | jq -e ' |
| 546 | ([.platforms[] | (.url, .sig)] | |
| 547 | all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not))) |
| 548 | ' latest.json >/dev/null |
| 549 | gh release upload "$TAG" latest.json --clobber |
| 550 | |
| 551 | # The compatibility asset exists for pre-v1.16 desktop updaters that |
| 552 | # still poll GitHub's repository-wide latest URL. Desktop releases now |
| 553 | # own that Latest badge, but this check still exercises the public fallback |
| 554 | # path exactly the way those clients fetch it: anonymously, over the public |
| 555 | # edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection |
| 556 | # 403s Actions egress — see the R2 note above), GitHub serves its own |
| 557 | # runners, so this can hard-fail. #5826/#5858 shipped a broken update check |
| 558 | # for weeks precisely because nothing exercised the public path. Retries |
| 559 | # cover the release CDN propagating the freshly uploaded asset. |
| 560 | - name: Smoke public compatibility manifest |
| 561 | env: |
| 562 | TAG: ${{ needs.resolve.outputs.tag }} |
| 563 | HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} |
| 564 | run: | |
| 565 | set -euo pipefail |
| 566 | case "$TAG" in |
| 567 | *-*) |
| 568 | echo "prerelease $TAG — no compatibility asset uploaded; skipping" |
| 569 | exit 0 |
| 570 | ;; |
| 571 | esac |
| 572 | if [ "$HAS_R2" != "true" ]; then |
| 573 | echo "R2 secrets not configured; no compatibility asset uploaded; skipping" |
| 574 | exit 0 |
| 575 | fi |
| 576 | url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json" |
| 577 | for attempt in 1 2 3 4 5 6; do |
| 578 | if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then |
| 579 | jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null |
| 580 | echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))" |
| 581 | exit 0 |
| 582 | fi |
| 583 | echo "attempt $attempt failed; retrying in 10s" |
| 584 | sleep 10 |
| 585 | done |
| 586 | echo "::error::public compatibility manifest unreachable at $url" |
| 587 | exit 1 |
| 588 | |
| 589 | # A stable CLI release must never leave the npm line behind: v1.17.5 |
| 590 | # shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2 |
| 591 | # (#5822) — every `npm update -g` user was silently downgraded to a |
| 592 | # months-old version, and nothing noticed because the npm line |
| 593 | # (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the |
| 594 | # stable npm tag was simply never pushed. release-npm.yml's own verify |
| 595 | # step only guards runs that happen; this guard catches the run that |
| 596 | # DIDN'T. |
| 597 | # |
| 598 | # Two distinct states, two responses (the approved orchestrator starts the |
| 599 | # CLI and npm reusable workflows concurrently, and npm dist-tags propagate |
| 600 | # asynchronously, so "tag pushed but latest not moved yet" is a NORMAL |
| 601 | # mid-release state, not a failure): |
| 602 | # - npm-v<version> tag missing -> hard fail. This is the #5822 gap: |
| 603 | # nobody pushed the npm release at all. |
| 604 | # - tag pushed, latest lagging -> poll briefly, then WARN and pass. |
| 605 | # The npm job may still be publishing; release-npm.yml's verify step |
| 606 | # owns asserting the dist-tag lands. |
| 607 | - name: Check npm latest dist-tag freshness |
| 608 | env: |
| 609 | TAG: ${{ needs.resolve.outputs.tag }} |
| 610 | run: | |
| 611 | set -euo pipefail |
| 612 | case "$TAG" in |
| 613 | *-*) |
| 614 | echo "prerelease $TAG — npm latest does not move on prereleases; skipping" |
| 615 | exit 0 |
| 616 | ;; |
| 617 | esac |
| 618 | version="${TAG#v}" |
| 619 | if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then |
| 620 | echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)." |
| 621 | exit 1 |
| 622 | fi |
| 623 | for attempt in 1 2 3 4 5 6; do |
| 624 | got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)" |
| 625 | if [ -n "$got" ]; then |
| 626 | newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)" |
| 627 | if [ "$newest" = "$got" ]; then |
| 628 | echo "npm latest -> $got (>= $version) OK" |
| 629 | exit 0 |
| 630 | fi |
| 631 | fi |
| 632 | echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)" |
| 633 | sleep 10 |
| 634 | done |
| 635 | echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the concurrent npm publish is likely still running or propagating. Monitor the npm job; its verify step asserts the dist-tag lands." |
| 636 | exit 0 |
| 637 |