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