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