返回 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
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
582 lines YAML