返回 DeepSeek-Reasonix
release.yml
根目录 / .github / workflows / release.yml
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
189 with:
190 ref: ${{ needs.resolve.outputs.sha }}
191 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # 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 permissions:
216 contents: write
217 issues: read # release-notes credits read PR and issue authors
218 pull-requests: read
219 # The Stable caller has already passed the single GitHub release approval.
220 # Official standalone recovery passes release-gate above. This job therefore
221 # must not add a second GitHub environment approval.
222 steps:
223 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
224 with:
225 fetch-depth: 0
226 ref: ${{ needs.resolve.outputs.sha }}
227 # Recovery may build an immutable tag that predates the current recovery
228 # policy. Keep product sources pinned above, but execute publication
229 # decisions from the protected workflow commit.
230 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
231 with:
232 fetch-depth: 0
233 path: release-control
234 ref: ${{ github.workflow_sha }}
235 - name: Isolate release-control checkout from product git state
236 run: |
237 set -euo pipefail
238 git_common_dir="$(git rev-parse --path-format=absolute --git-common-dir)"
239 exclude_file="$git_common_dir/info/exclude"
240 if ! grep -qxF '/release-control/' "$exclude_file"; then
241 printf '%s\n' '/release-control/' >> "$exclude_file"
242 fi
243 git check-ignore -q release-control/
244 dirty="$(git status --porcelain --untracked-files=all)"
245 if [ -n "$dirty" ]; then
246 printf 'product checkout is dirty before release:\n%s\n' "$dirty" >&2
247 exit 1
248 fi
249 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
250 with:
251 go-version-file: go.mod
252 cache: true
253 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
254 with:
255 node-version: "22"
256 - name: Download sealed release candidate
257 if: ${{ inputs.candidate_artifact_name != '' }}
258 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
259 with:
260 name: ${{ inputs.candidate_artifact_name }}
261 path: ${{ runner.temp }}/release-candidate
262 - name: Verify prepared CLI checksums
263 if: ${{ inputs.candidate_artifact_name != '' }}
264 working-directory: ${{ runner.temp }}/release-candidate/cli
265 run: sha256sum -c SHA256SUMS
266 - name: Download orchestrator-reviewed release notes
267 if: ${{ inputs.orchestrated }}
268 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
269 with:
270 name: orchestrator-reviewed-release-notes
271 path: /tmp/orchestrator-reviewed-release-notes
272 - name: Use orchestrator-reviewed release notes
273 if: ${{ inputs.orchestrated }}
274 run: |
275 test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md
276 cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md
277 - name: Render reviewed release notes
278 if: ${{ !inputs.orchestrated }}
279 env:
280 RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
281 GH_TOKEN: ${{ github.token }}
282 run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
283 - name: Revalidate approved release ref
284 env:
285 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
286 APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
287 run: bash scripts/verify-release-tag.sh
288 - name: Decide whether CLI artifacts need publication
289 id: publication
290 env:
291 GH_TOKEN: ${{ github.token }}
292 TAG: ${{ needs.resolve.outputs.tag }}
293 CHANNEL: ${{ needs.resolve.outputs.channel }}
294 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
295 run: |
296 set -euo pipefail
297 validation_channel="$CHANNEL"
298 if [ "$CHANNEL" = "stable" ] && [ "$PRERELEASE" = "true" ]; then
299 validation_channel=any
300 fi
301 release_json=/tmp/existing-cli-release.json
302 release_error=/tmp/existing-cli-release.error
303 checksums=/tmp/existing-cli-release-SHA256SUMS
304 if gh api "repos/${{ github.repository }}/releases/tags/$TAG" \
305 >"$release_json" 2>"$release_error"; then
306 gh release download "$TAG" -R "${{ github.repository }}" \
307 --pattern SHA256SUMS --output "$checksums"
308 decision="$(
309 bash release-control/scripts/decide-cli-release-publication.sh \
310 "$validation_channel" "$TAG" "${{ github.repository }}" \
311 "$release_json" "$checksums"
312 )"
313 if [ -n "${{ inputs.candidate_artifact_name }}" ]; then
314 cmp -s "$checksums" "$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" || {
315 echo "::error::existing CLI release differs from the sealed candidate"
316 exit 1
317 }
318 fi
319 echo "existing CLI release $TAG is complete and checksum-bound; reusing it"
320 elif grep -Eiq 'HTTP 404|Not Found' "$release_error"; then
321 decision="$(
322 bash release-control/scripts/decide-cli-release-publication.sh \
323 "$validation_channel" "$TAG" "${{ github.repository }}" - -
324 )"
325 echo "CLI release $TAG does not exist; GoReleaser will publish it"
326 else
327 cat "$release_error" >&2
328 exit 1
329 fi
330 test "$decision" = "publish" -o "$decision" = "reuse"
331 echo "decision=$decision" >> "$GITHUB_OUTPUT"
332 - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
333 if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }}
334 with:
335 version: '~> v2'
336 args: release --clean${{ vars.CLI_PUBLISH_FROZEN == 'true' && ' --skip=homebrew' || '' }}
337 env:
338 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
339 HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' && secrets.HOMEBREW_TAP_TOKEN || '' }}
340 # workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
341 # though checkout is on the release tag. Pin GoReleaser explicitly, and
342 # avoid ambiguity from the three release tags sharing one commit.
343 GORELEASER_CURRENT_TAG: ${{ needs.resolve.outputs.tag }}
344
345 - name: Publish prepared CLI archives
346 if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name != '' }}
347 env:
348 GH_TOKEN: ${{ github.token }}
349 TAG: ${{ needs.resolve.outputs.tag }}
350 run: |
351 gh release create "$TAG" \
352 "$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-amd64.tar.gz" \
353 "$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-arm64.tar.gz" \
354 "$RUNNER_TEMP/release-candidate/cli/reasonix-linux-amd64.tar.gz" \
355 "$RUNNER_TEMP/release-candidate/cli/reasonix-linux-arm64.tar.gz" \
356 "$RUNNER_TEMP/release-candidate/cli/reasonix-windows-amd64.zip" \
357 "$RUNNER_TEMP/release-candidate/cli/reasonix-windows-arm64.zip" \
358 "$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" \
359 --title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
360
361 - name: Publish prepared Homebrew cask
362 if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
363 env:
364 HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
365 run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
366
367 - name: Publish product release notes
368 env:
369 GH_TOKEN: ${{ github.token }}
370 TAG: ${{ needs.resolve.outputs.tag }}
371 run: gh release edit "$TAG" --notes-file /tmp/release-notes.md
372
373 - name: Publish CLI release metadata to R2
374 env:
375 GH_TOKEN: ${{ github.token }}
376 TAG: ${{ needs.resolve.outputs.tag }}
377 NOTES_TAG: ${{ needs.resolve.outputs.notes_version }}
378 CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
379 HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
380 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
381 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
382 AWS_DEFAULT_REGION: auto
383 R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
384 R2_BUCKET: ${{ secrets.R2_BUCKET }}
385 run: |
386 set -euo pipefail
387 if [ "$HAS_R2" != "true" ]; then
388 echo "R2 secrets not configured; skipping CLI release metadata"
389 exit 0
390 fi
391
392 channel=""
393 if [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
394 channel="stable"
395 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
396 channel="preview"
397 else
398 echo "internal CLI release $TAG; publishing only immutable metadata"
399 fi
400
401 required_assets='[
402 "reasonix-darwin-amd64.tar.gz",
403 "reasonix-darwin-arm64.tar.gz",
404 "reasonix-linux-amd64.tar.gz",
405 "reasonix-linux-arm64.tar.gz",
406 "reasonix-windows-amd64.zip",
407 "reasonix-windows-arm64.zip",
408 "SHA256SUMS"
409 ]'
410 gh api "repos/${{ github.repository }}/releases/tags/$TAG" > /tmp/cli-release.raw.json
411 jq --arg tag "$TAG" --arg notes_tag "$NOTES_TAG" --argjson required "$required_assets" '
412 if .tag_name != $tag then error("release tag mismatch") else . end |
413 if .draft then error("draft release cannot be published") else . end |
414 . as $release |
415 ($release.assets | map({key: .name, value: .}) | from_entries) as $assets |
416 if ($required | all(. as $name | $assets[$name] != null))
417 then {
418 tag_name: $release.tag_name,
419 prerelease: $release.prerelease,
420 html_url: $release.html_url,
421 release_notes_url: ("https://reasonix.io/changelog/" + $notes_tag + "/"),
422 assets: [
423 $required[] as $name |
424 $assets[$name] |
425 {
426 name: .name,
427 browser_download_url: .browser_download_url,
428 size: .size
429 }
430 ]
431 }
432 else error("release is missing one or more required CLI assets")
433 end
434 ' /tmp/cli-release.raw.json > /tmp/cli-release.json
435 if [ -n "$channel" ]; then
436 bash scripts/validate-cli-release-manifest.sh \
437 "$channel" "$TAG" "${{ github.repository }}" /tmp/cli-release.json "$NOTES_TAG"
438 else
439 bash scripts/validate-cli-release-manifest.sh \
440 any "$TAG" "${{ github.repository }}" /tmp/cli-release.json "$NOTES_TAG"
441 fi
442
443 endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
444 validation_channel="${channel:-any}"
445 immutable_key="cli/releases/${TAG}/latest.json"
446 immutable_error="$(mktemp)"
447 if aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
448 --endpoint-url "$endpoint" 2>"$immutable_error"; then
449 bash scripts/validate-cli-release-manifest.sh \
450 "legacy-${validation_channel}" "$TAG" "${{ github.repository }}" \
451 /tmp/cli-release.immutable.json "$NOTES_TAG"
452 if ! bash scripts/compare-cli-release-manifests.sh \
453 /tmp/cli-release.json /tmp/cli-release.immutable.json; then
454 echo "::error::immutable CLI release metadata for $TAG already exists with different content"
455 exit 1
456 fi
457 echo "immutable CLI release metadata for $TAG already exists; preserving it"
458 elif grep -Eiq '404|NoSuchKey|Not Found' "$immutable_error"; then
459 aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/${immutable_key}" \
460 --endpoint-url "$endpoint" \
461 --content-type "application/json; charset=utf-8" \
462 --cache-control "public, max-age=31536000, immutable"
463 else
464 cat "$immutable_error" >&2
465 exit 1
466 fi
467 rm -f "$immutable_error"
468
469 aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
470 --endpoint-url "$endpoint"
471 bash scripts/validate-cli-release-manifest.sh \
472 "legacy-${validation_channel}" "$TAG" "${{ github.repository }}" \
473 /tmp/cli-release.immutable.json "$NOTES_TAG"
474 bash scripts/compare-cli-release-manifests.sh \
475 /tmp/cli-release.json /tmp/cli-release.immutable.json
476
477 if [ -z "$channel" ]; then
478 echo "internal CLI release $TAG; Stable and Preview pointers remain unchanged"
479 exit 0
480 fi
481
482 if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
483 echo "CLI publication is frozen; the $channel pointer stays where it is and $TAG keeps only its immutable record"
484 exit 0
485 fi
486
487 current_tag=""
488 pointer_error="$(mktemp)"
489 if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
490 --endpoint-url "$endpoint" 2>"$pointer_error"; then
491 current_tag="$(jq -er '.tag_name | strings' /tmp/cli-release.pointer.json)"
492 bash scripts/validate-cli-release-manifest.sh \
493 "legacy-${channel}" "$current_tag" "${{ github.repository }}" \
494 /tmp/cli-release.pointer.json "$current_tag"
495 elif grep -Eiq '404|NoSuchKey|Not Found' "$pointer_error"; then
496 echo "CLI $channel pointer does not exist yet"
497 else
498 cat "$pointer_error" >&2
499 exit 1
500 fi
501 rm -f "$pointer_error"
502
503 pointer_manifest=-
504 if [ -n "$current_tag" ]; then
505 pointer_manifest=/tmp/cli-release.pointer.json
506 fi
507 pointer_decision="$(
508 bash scripts/decide-cli-pointer-update.sh \
509 "$channel" /tmp/cli-release.json "$pointer_manifest"
510 )"
511 if [ "$pointer_decision" = "skip" ]; then
512 echo "CLI $channel pointer remains ${current_tag:-unset}; candidate $TAG is not newer and needs no repair"
513 exit 0
514 fi
515 aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/cli/${channel}/latest.json" \
516 --endpoint-url "$endpoint" \
517 --content-type "application/json; charset=utf-8" \
518 --cache-control "public, max-age=300, stale-if-error=86400"
519
520 aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
521 --endpoint-url "$endpoint"
522 bash scripts/validate-cli-release-manifest.sh \
523 "$channel" "$TAG" "${{ github.repository }}" \
524 /tmp/cli-release.pointer.json "$NOTES_TAG"
525 cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json
526 echo "CLI $channel pointer -> $TAG"
527
528 - name: Attach desktop manifest compatibility asset
529 env:
530 GH_TOKEN: ${{ github.token }}
531 TAG: ${{ needs.resolve.outputs.tag }}
532 HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
533 R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
534 R2_BUCKET: ${{ secrets.R2_BUCKET }}
535 run: |
536 set -euo pipefail
537 case "$TAG" in
538 *-*)
539 echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
540 exit 0
541 ;;
542 esac
543 if [ "$HAS_R2" != "true" ]; then
544 echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
545 exit 0
546 fi
547 # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
548 # protection), so read the manifest over the authenticated S3 API instead
549 # of the public edge.
550 aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
551 aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
552 aws configure set region auto
553 aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
554 --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
555 jq '.download_page = "https://reasonix.io/?download=desktop#start"' latest.raw.json > latest.json
556 jq -e '
557 ([.platforms[] | (.url, .sig)] |
558 all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
559 ' latest.json >/dev/null
560 gh release upload "$TAG" latest.json --clobber
561
562 # Verifies what the CLI release actually serves after the compatibility
563 # upload: exactly the approved asset set, each uploaded, with the size and
564 # sha256 digest GitHub recorded, SHA256SUMS downloaded from the release,
565 # and the compatibility latest.json byte-identical to the one uploaded
566 # above. Any mismatch or API failure fails the job.
567 - name: Verify published CLI release assets
568 env:
569 GH_TOKEN: ${{ github.token }}
570 TAG: ${{ needs.resolve.outputs.tag }}
571 HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
572 run: |
573 set -euo pipefail
574 case "$TAG" in
575 *-*)
576 echo "prerelease $TAG — no compatibility asset uploaded; skipping"
577 exit 0
578 ;;
579 esac
580 if [ "$HAS_R2" != "true" ]; then
581 echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
582 exit 0
583 fi
584 release_json=/tmp/published-cli-release.json
585 checksums=/tmp/published-cli-SHA256SUMS
586 gh api "repos/${{ github.repository }}/releases/tags/$TAG" >"$release_json"
587 gh release download "$TAG" -R "${{ github.repository }}" \
588 --pattern SHA256SUMS --output "$checksums" --clobber
589 decision="$(bash release-control/scripts/decide-cli-release-publication.sh \
590 stable "$TAG" "${{ github.repository }}" "$release_json" "$checksums")"
591 test "$decision" = "reuse"
592 jq -e '[.assets[].name] | index("latest.json") != null' "$release_json" >/dev/null
593 want="sha256:$(sha256sum latest.json | cut -d' ' -f1)"
594 got="$(jq -r '.assets[] | select(.name == "latest.json") | .digest' "$release_json")"
595 test "$want" = "$got"
596 echo "published CLI release $TAG assets verified against SHA256SUMS and the uploaded manifest"
597
598 # A stable CLI release must never leave the npm line behind: v1.17.5
599 # shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
600 # (#5822) — every `npm update -g` user was silently downgraded to a
601 # months-old version, and nothing noticed because the npm line
602 # (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
603 # stable npm tag was simply never pushed. release-npm.yml's own verify
604 # step only guards runs that happen; this guard catches the run that
605 # DIDN'T.
606 #
607 # Two distinct states, two responses (the approved orchestrator starts the
608 # CLI and npm reusable workflows concurrently, and npm dist-tags propagate
609 # asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
610 # mid-release state, not a failure):
611 # - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
612 # nobody pushed the npm release at all.
613 # - tag pushed, latest lagging -> poll briefly, then WARN and pass.
614 # The npm job may still be publishing; release-npm.yml's verify step
615 # owns asserting the dist-tag lands.
616 - name: Check npm latest dist-tag freshness
617 env:
618 TAG: ${{ needs.resolve.outputs.tag }}
619 CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
620 run: |
621 set -euo pipefail
622 case "$TAG" in
623 *-*)
624 echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
625 exit 0
626 ;;
627 esac
628 version="${TAG#v}"
629 if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
630 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)."
631 exit 1
632 fi
633 if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
634 echo "CLI publication is frozen; npm latest belongs to another line, only the npm-v$version tag is required"
635 exit 0
636 fi
637 for attempt in 1 2 3 4 5 6; do
638 got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
639 if [ -n "$got" ]; then
640 newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
641 if [ "$newest" = "$got" ]; then
642 echo "npm latest -> $got (>= $version) OK"
643 exit 0
644 fi
645 fi
646 echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
647 sleep 10
648 done
649 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."
650 exit 0
651
651 lines YAML