chore(release): the 1.x line stops publishing the CLI to npm, Homebrew, and the CLI pointers (#10757)

The Studio line now owns the CLI install channels: npm `reasonix` with its
`@reasonix/cli-*` platform packages, the esengine/homebrew-reasonix cask, and
the R2 CLI update pointers (`cli/{stable,preview}/latest.json`) that
`reasonix upgrade` and the site read through the release gateway. A 1.x
release must never write any of them again.

- release-npm.yml is removed, and release-promote/release-stable no longer
  call it or wait on it; the legacy recovery input publish_npm goes with it.
- release.yml no longer hands GoReleaser a tap token, no longer pushes the
  prepared cask, no longer advances the CLI channel pointers, and no longer
  requires npm `latest` to have moved. It still creates the vX.Y.Z GitHub
  release, its notes, the immutable cli/releases/<tag>/latest.json record,
  and the Desktop compatibility asset.
- .goreleaser.yaml drops homebrew_casks.
- verify-stable-release-artifacts.sh and the publication ledger stop
  requiring npm package identities, npm dist-tags, and the cask version.
- Publisher helpers with no remaining caller are removed:
  publish-homebrew-cask, finalize-npm-official-release, resolve-npm-release,
  npm/publish-candidate, decide-cli-pointer-update, compare-cli-release-tags.
- release-workflows.test.sh now fails if any workflow holds NPM_TOKEN or
  HOMEBREW_TAP_TOKEN, names the tap, runs npm publish or dist-tag, or
  writes a CLI channel pointer.

Candidate preparation, tags (including npm-vX.Y.Z), Desktop publication, the
Stable manifest, Pages, and the release-event record are unchanged.
This commit is contained in:
YHH authored and GitHub committed 2026-09-24 22:55:29 -07:00
1 parent 6845b6de3c
commit 1aa4a1fb31
25 files changed
+110 -1255

No files matched your search

+1 -2
View File
@@ -1638,7 +1638,6 @@ jobs:
.github/workflows/release-verify.yml \
.github/workflows/release-site-recovery.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml \
.github/workflows/pages.yml \
.github/workflows/apple-notary-log.yml \
@@ -1648,7 +1647,7 @@ jobs:
.github/workflows/ci.yml
node --test scripts/release-candidate.test.mjs scripts/resolve-release-candidate.test.mjs \
scripts/verify-release-artifact-archive.test.mjs \
scripts/build-release-cli-candidate.test.mjs scripts/publish-homebrew-cask.test.mjs \
scripts/build-release-cli-candidate.test.mjs \
scripts/desktop-release-artifacts.test.mjs scripts/release-publication-ledger.test.mjs \
scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs scripts/macos-go-tests.test.mjs \
scripts/desktop-windows-go-tests.test.mjs \
-243
View File
@@ -1,243 +0,0 @@
name: Release npm
# npm line. Stable releases are called by release-stable.yml after its single
# GitHub environment approval. Direct prerelease tag publication is disabled;
# manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Historical canary and next package identities remain readable,
# but normal publication advances only the official release.
on:
workflow_dispatch:
inputs:
channel:
description: "Standalone npm recovery channel"
required: true
default: stable
type: choice
options:
- stable
base_version:
description: "Stable version to recover exactly"
required: true
type: string
tag:
description: "stable: existing npm tag to publish (for example npm-v1.18.0)"
required: false
type: string
workflow_call:
inputs:
channel:
description: "Publish channel selected by the approved release orchestrator"
required: true
type: string
base_version:
description: "Version to publish"
required: true
type: string
tag:
description: "Existing npm tag to check out for stable publication"
required: false
default: ""
type: string
approved_cli_tag:
description: "Stable CLI tag recorded by the approved orchestrator"
required: true
type: string
approved_sha:
description: "Immutable commit recorded by the approved orchestrator"
required: true
type: string
orchestrated:
description: "True only when called by an approved release orchestrator"
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
candidate_artifact_name:
description: "Same-run artifact containing a verified sealed release candidate"
required: false
default: ""
type: string
candidate_verified:
description: "Protected Stable preflight verified candidate provenance and bytes"
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
# Serialize every publisher for one npm dist-tag. Historical canary calls
# remain ordered even though normal publication now uses Stable only.
group: release-npm-${{ inputs.channel || 'next' }}
cancel-in-progress: false
jobs:
orchestration-guard:
name: verify approved orchestrator
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-{1}.yml@{2}', github.repository, inputs.orchestrator, github.ref) }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator == 'promote' && 'stable' || inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
if [ -n "${{ inputs.candidate_artifact_name }}" ] && [ "${{ inputs.candidate_verified }}" != "true" ]; then
echo "::error::prepared npm packages require verified candidate provenance"
exit 1
fi
release-gate:
name: approve standalone npm release
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
environment: release
steps:
- env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: echo "Approved standalone npm release $RELEASE_TAG"
cache-guard:
name: cache hit guard
needs: [orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v7
if: ${{ !inputs.candidate_verified }}
with:
go-version-file: go.mod
cache: true
- if: ${{ !inputs.candidate_verified }}
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
if: ${{ !inputs.candidate_verified }}
env:
DOCS_BUILD_VERSION: v${{ inputs.base_version }}
DOCS_SOURCE_REVISION: ${{ inputs.approved_sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
revision="$DOCS_SOURCE_REVISION"
if [ -z "$revision" ]; then revision="$(git rev-parse HEAD)"; fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$revision"
npm:
name: publish npm packages
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# id-token lets npm publish --provenance attach a Sigstore attestation; the
# registry token still authenticates the publish itself.
permissions:
contents: read
id-token: write
# Orchestrated releases have already passed their GitHub environment
# approval. Direct prereleases and manual Stable recovery pass release-gate.
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- name: Load approved npm publication control plane
env:
RECOVERY_CONTROL_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "$RECOVERY_CONTROL_SHA"
git restore --source="$RECOVERY_CONTROL_SHA" -- \
npm/publish.mjs \
npm/publish-candidate.mjs \
scripts/finalize-npm-official-release.mjs
- uses: actions/setup-go@v7
if: ${{ inputs.candidate_artifact_name == '' }}
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v7
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Download sealed release candidate
if: ${{ inputs.candidate_artifact_name != '' }}
uses: actions/download-artifact@v8
with:
name: ${{ inputs.candidate_artifact_name }}
path: ${{ runner.temp }}/release-candidate
# Stable publication uses the exact npm-v* tag. build.mjs strips the
# leading `npm-`/`v`; historical Preview inputs remain only for old
# workflow-call compatibility and are not reachable from a public entry.
- name: Resolve version
id: ver
env:
EVENT_NAME: ${{ github.event_name }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_CHANNEL: ${{ inputs.channel }}
IN_BASE_VERSION: ${{ inputs.base_version }}
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
IN_PREVIEW_NUMBER: ${{ inputs.preview_number }}
run: bash scripts/resolve-npm-release.sh
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- name: Publish or recover immutable npm packages
if: ${{ inputs.candidate_artifact_name == '' }}
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
- name: Publish or recover prepared npm packages
if: ${{ inputs.candidate_artifact_name != '' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/publish-candidate.mjs "$RUNNER_TEMP/release-candidate/npm" "$VERSION" "$APPROVED_SHA"
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
candidate="$APPROVED_SHA"
if [ -z "$candidate" ]; then candidate="$(git rev-parse HEAD)"; fi
EXPECTED_SHA="$candidate" node scripts/finalize-npm-official-release.mjs "$VERSION"
+4 -24
View File
@@ -244,7 +244,7 @@ jobs:
bash scripts/release-candidate-tags.sh activate "$OPERATION" "$VERSION" "$SOURCE_SHA"
cli:
name: publish CLI and Homebrew from candidate
name: publish CLI from candidate
needs: [preflight, activate]
uses: ./.github/workflows/release.yml
with:
@@ -257,25 +257,6 @@ jobs:
candidate_verified: true
secrets: inherit
npm:
name: publish npm from candidate
needs: [preflight, activate]
permissions:
contents: read
id-token: write
uses: ./.github/workflows/release-npm.yml
with:
channel: stable
base_version: ${{ needs.preflight.outputs.version }}
tag: npm-v${{ needs.preflight.outputs.version }}
approved_cli_tag: v${{ needs.preflight.outputs.version }}
approved_sha: ${{ needs.preflight.outputs.source_sha }}
orchestrated: true
orchestrator: promote
candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }}
candidate_verified: true
secrets: inherit
desktop:
name: publish Desktop from candidate
needs: [preflight, activate]
@@ -300,7 +281,7 @@ jobs:
postflight:
name: verify every public Stable surface
timeout-minutes: 45
needs: [preflight, cli, npm, desktop]
needs: [preflight, cli, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
@@ -310,10 +291,9 @@ jobs:
- name: Require every publisher
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do test "$result" = success; done
for result in "$CLI_RESULT" "$DESKTOP_RESULT"; do test "$result" = success; done
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
@@ -345,7 +325,7 @@ jobs:
metrics:
name: record publication timing
needs: [preflight, authorize, activate, cli, npm, desktop, postflight]
needs: [preflight, authorize, activate, cli, desktop, postflight]
if: ${{ always() && !cancelled() }}
continue-on-error: true
runs-on: ubuntu-latest
+7 -35
View File
@@ -16,12 +16,7 @@ on:
required: true
type: string
publish_cli:
description: "Recover the CLI/Homebrew channel"
required: false
default: true
type: boolean
publish_npm:
description: "Recover the npm channel"
description: "Recover the CLI GitHub release"
required: false
default: true
type: boolean
@@ -51,7 +46,8 @@ concurrency:
cancel-in-progress: false
# Reusable release workflows can only reduce caller permissions, so the
# orchestrator grants the union needed by CLI/Desktop publication.
# orchestrator grants the union needed by CLI/Desktop publication. npm and
# Homebrew belong to the Studio line; this line never publishes to them.
permissions:
actions: write
contents: write
@@ -66,7 +62,6 @@ jobs:
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
npm_tag: ${{ steps.release.outputs.npm_tag }}
desktop_tag: ${{ steps.release.outputs.desktop_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
@@ -135,7 +130,6 @@ jobs:
outputs:
version: ${{ steps.approved.outputs.version }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
npm_tag: ${{ steps.approved.outputs.npm_tag }}
desktop_tag: ${{ steps.approved.outputs.desktop_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
@@ -144,21 +138,19 @@ jobs:
env:
VERSION: ${{ needs.preflight.outputs.version }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
NPM_TAG: ${{ needs.preflight.outputs.npm_tag }}
DESKTOP_TAG: ${{ needs.preflight.outputs.desktop_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "cli_tag=$CLI_TAG"
echo "npm_tag=$NPM_TAG"
echo "desktop_tag=$DESKTOP_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved stable release $VERSION at $RELEASE_SHA"
cli:
name: publish CLI and Homebrew
name: publish CLI
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_cli) }}
uses: ./.github/workflows/release.yml
@@ -169,24 +161,6 @@ jobs:
orchestrated: true
secrets: inherit
npm:
name: publish npm
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_npm) }}
# A reusable workflow only receives permissions the caller grants here.
permissions:
contents: read
id-token: write
uses: ./.github/workflows/release-npm.yml
with:
channel: stable
base_version: ${{ needs.authorize.outputs.version }}
tag: ${{ needs.authorize.outputs.npm_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
secrets: inherit
signpath-preflight:
name: verify stable SignPath control plane
needs: authorize
@@ -222,7 +196,7 @@ jobs:
postflight:
name: verify stable release artifacts
needs: [authorize, cli, npm, desktop]
needs: [authorize, cli, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
@@ -232,14 +206,12 @@ jobs:
- name: Require every publisher to succeed
env:
PUBLISH_CLI: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_cli }}
PUBLISH_NPM: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
PUBLISH_DESKTOP: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }}
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for channel in cli npm desktop; do
for channel in cli desktop; do
selected_var="PUBLISH_${channel^^}"
result_var="${channel^^}_RESULT"
selected="${!selected_var}"
@@ -265,7 +237,7 @@ jobs:
with:
go-version-file: go.mod
cache: false
- name: Verify public artifacts and npm latest
- name: Verify public artifacts
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
+6 -107
View File
@@ -3,7 +3,9 @@ name: Release
# Native CLI binary line. Official releases are called by the protected Stable
# orchestrator. Manual dispatch remains available only for official recovery;
# historical Preview inputs below are workflow-call compatibility, not a public
# publication entrypoint.
# publication entrypoint. The GitHub release and its immutable R2 record are
# this line's whole output: npm, the Homebrew tap, and the CLI update pointers
# belong to the Studio line.
on:
workflow_dispatch:
inputs:
@@ -66,9 +68,8 @@ permissions:
contents: write # create the release and upload archives
concurrency:
# A channel pointer is a monotonic public state machine. Serialize all
# publishers for the same channel so an older recovery run cannot pass its
# read-before-write window after a newer release has published.
# Publication reads the existing release before writing it. Serialize all
# publishers for the same channel so two runs cannot share that window.
group: release-cli-${{ inputs.channel || 'stable' }}
cancel-in-progress: false
@@ -208,7 +209,7 @@ jobs:
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
goreleaser:
name: archives + checksums + homebrew tap
name: archives + checksums
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
@@ -331,7 +332,6 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }}
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
# though checkout is on the release tag. Pin GoReleaser explicitly, and
# avoid ambiguity from the three release tags sharing one commit.
@@ -353,12 +353,6 @@ jobs:
"$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" \
--title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
- name: Publish prepared Homebrew cask
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' }}
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
- name: Publish product release notes
env:
GH_TOKEN: ${{ github.token }}
@@ -468,52 +462,6 @@ jobs:
bash scripts/compare-cli-release-manifests.sh \
/tmp/cli-release.json /tmp/cli-release.immutable.json
if [ -z "$channel" ]; then
echo "internal CLI release $TAG; Stable and Preview pointers remain unchanged"
exit 0
fi
current_tag=""
pointer_error="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
--endpoint-url "$endpoint" 2>"$pointer_error"; then
current_tag="$(jq -er '.tag_name | strings' /tmp/cli-release.pointer.json)"
bash scripts/validate-cli-release-manifest.sh \
"legacy-${channel}" "$current_tag" "${{ github.repository }}" \
/tmp/cli-release.pointer.json "$current_tag"
elif grep -Eiq '404|NoSuchKey|Not Found' "$pointer_error"; then
echo "CLI $channel pointer does not exist yet"
else
cat "$pointer_error" >&2
exit 1
fi
rm -f "$pointer_error"
pointer_manifest=-
if [ -n "$current_tag" ]; then
pointer_manifest=/tmp/cli-release.pointer.json
fi
pointer_decision="$(
bash scripts/decide-cli-pointer-update.sh \
"$channel" /tmp/cli-release.json "$pointer_manifest"
)"
if [ "$pointer_decision" = "skip" ]; then
echo "CLI $channel pointer remains ${current_tag:-unset}; candidate $TAG is not newer and needs no repair"
exit 0
fi
aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/cli/${channel}/latest.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
--endpoint-url "$endpoint"
bash scripts/validate-cli-release-manifest.sh \
"$channel" "$TAG" "${{ github.repository }}" \
/tmp/cli-release.pointer.json "$NOTES_TAG"
cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json
echo "CLI $channel pointer -> $TAG"
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
@@ -585,52 +533,3 @@ jobs:
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
# (#5822) — every `npm update -g` user was silently downgraded to a
# months-old version, and nothing noticed because the npm line
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
# stable npm tag was simply never pushed. release-npm.yml's own verify
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the approved orchestrator starts the
# CLI and npm reusable workflows concurrently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm job may still be publishing; release-npm.yml's verify step
# owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
exit 0
;;
esac
version="${TAG#v}"
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
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)."
exit 1
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
if [ "$newest" = "$got" ]; then
echo "npm latest -> $got (>= $version) OK"
exit 0
fi
fi
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
sleep 10
done
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."
exit 0
-29
View File
@@ -33,35 +33,6 @@ checksum:
name_template: SHA256SUMS
algorithm: sha256
homebrew_casks:
- name: reasonix
ids: [reasonix]
# Prereleases (including v*-preview.* and v*-rc*) must not update the stable
# tap; brew has no separate prerelease channel, so uploading either cask
# would make it the default `brew install`.
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
skip_upload: "auto"
repository:
owner: esengine
name: homebrew-reasonix
branch: main
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
description: "Cache-first DeepSeek coding agent for the terminal."
commit_author:
name: reasonix
email: [email protected]
# The CLI archive is not notarized, so the cask still strips quarantine.
# GoReleaser's hooks field renders the deprecated `postflight do` stanza;
# emit Homebrew's `postflight_steps` directly until upstream supports it.
custom_block: |
postflight_steps do
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"]
end
end
release:
prerelease: auto
make_latest: false
+3 -2
View File
@@ -68,8 +68,9 @@ not a publication or a substitute for a later formal release's public checks.
The candidate payload lasts 30 days and its record/evidence 90 days. If the
payload expires before publication, prepare a new candidate. The release
skill's public postflight remains the authority for tags, npm, Desktop
updates, Homebrew, and the hydrated website after an authorized publication.
skill's public postflight remains the authority for tags, the CLI and
Desktop releases, Desktop updates, and the hydrated website after an
authorized publication.
## Tag publisher identity / 标签发布身份
+2 -2
View File
@@ -21,5 +21,5 @@ Desktop 文件、执行最终安装包的原生验收并封存记录。`Publish
文件可以跨 run 复用,不等于公开发布,也不能代替下一次正式发布的公开验收。
候选文件保留 30 天,记录和验收证据保留 90 天。尚未发布时若文件过期,需
重新准备候选。正式发布后的标签、npm、Desktop 更新、Homebrew 与官网
渲染结果仍以发布 skill 的公开 postflight 验证为准。
重新准备候选。正式发布后的标签、CLI 与 Desktop Release、Desktop 更新与
官网渲染结果仍以发布 skill 的公开 postflight 验证为准。
+23 -21
View File
@@ -8,10 +8,15 @@ The public identity remains compatible with existing clients:
| Surface | Immutable tag | Public result |
| --- | --- | --- |
| CLI | `vX.Y.Z` | GitHub Release and Homebrew |
| npm | `npm-vX.Y.Z` | root and six platform packages; official aliases |
| CLI | `vX.Y.Z` | GitHub Release and an immutable R2 record |
| npm | `npm-vX.Y.Z` | none; the tag records identity only |
| Desktop | `desktop-vX.Y.Z` | signed GitHub Release, immutable R2 directory, and Stable manifest |
This line does not publish the CLI to npm, the Homebrew tap, or the R2 CLI
update pointers (`cli/stable/latest.json`, `cli/preview/latest.json`). Those
install channels belong to the Studio line, and no workflow here holds their
credentials.
All three tags must identify the candidate product SHA. They are created in one
atomic push and must never be moved, deleted, or recreated. The Go SDK module
(`sdk/go`) retains its independent `sdk/go/vX.Y.Z` version line.
@@ -38,7 +43,7 @@ atomic push and must never be moved, deleted, or recreated. The Go SDK module
5. Review the candidate ID, full product SHA, Notes digest, signing policy,
platform receipts, and payload hashes in **Publish release candidate**.
Approve its `release` environment once.
6. Wait for CLI, npm, and Desktop publication, Stable pointer convergence, the
6. Wait for CLI and Desktop publication, Stable pointer convergence, the
owned Pages deployment, hydrated download verification, and the publication
ledger.
@@ -66,10 +71,10 @@ To revoke an unpublished candidate, add its exact ID to the comma- or
whitespace-separated repository variable `RELEASE_REVOKED_CANDIDATES`.
Preparation reuse and publication both fail closed for listed IDs.
The six CLI binaries are each built once. CLI archives, Homebrew checksums, and
npm platform tarballs reuse those bytes. Windows architectures build in
parallel, then share one Certum session; completed architecture bundles can be
reused by a failed-job rerun. Windows native acceptance runs in parallel after
The six CLI binaries are each built once. CLI archives, the cask file, and npm
platform tarballs in the sealed payload reuse those bytes; only the archives
are published. Windows architectures build in parallel, then share one Certum
session; completed architecture bundles can be reused by a failed-job rerun. Windows native acceptance runs in parallel after
signing. Desktop platforms do not wait for unrelated platform acceptance before
starting their own downstream work.
@@ -78,7 +83,7 @@ starting their own downstream work.
Publication verifies the record attestation, exact artifact IDs, payload
attestations and hashes, Notes identity, protected source ancestry, signatures,
and acceptance receipts before requesting approval. It then atomically creates
the three tags and publishes CLI, npm, and Desktop in parallel from the sealed
the three tags and publishes CLI and Desktop in parallel from the sealed
payload. Tag creation no longer starts a second legacy release pipeline.
For any interrupted publication, run:
@@ -100,25 +105,23 @@ Each publisher re-reads its external state:
- ambiguous requests are queried before retrying;
- conflicting immutable content stops the stage;
- signing and native acceptance are not repeated;
- a newer npm, R2, Homebrew, or site pointer is never rolled back by an older
candidate recovery;
- a newer R2 or site pointer is never rolled back by an older candidate
recovery;
- a site-only failure reruns Pages and hydrated-site verification without
rebuilding product files.
The publication ledger records observed tag SHAs, every CLI and Desktop release
asset, all seven npm package identities and registry integrity values, pointer
outcomes, Stable manifest, Homebrew, changelog, and homepage state. Recovery
always queries the actual service again; the ledger is evidence, not a source of
truth for later mutations.
asset, and the Stable manifest, changelog, and homepage state. Recovery always
queries the actual service again; the ledger is evidence, not a source of truth
for later mutations.
## Verification and timing
Run **Verify release** with `X.Y.Z` for a read-only public check. It validates
the immutable tags, GitHub release contents, all npm packages and candidate
identity, the current Stable manifest when the version owns it, Homebrew,
changelog, and the browser-hydrated download DOM. For an older version, newer
public pointers are preserved and reported rather than treated as a reason to
roll them back.
the immutable tags, GitHub release contents, the current Stable manifest when
the version owns it, changelog, and the browser-hydrated download DOM. For an
older version, newer public pointers are preserved and reported rather than
treated as a reason to roll them back.
Candidate and publication workflows upload JSON timing evidence and summarize
queue, runner, build, signing, acceptance, upload, site deployment, and total
@@ -133,8 +136,7 @@ guards. New releases must use candidate IDs; do not add hard-coded run IDs or
version exceptions to the new workflows.
Historical Preview, Canary, and RC artifacts remain readable, but those paths
are not normal publication entrypoints. npm `canary` and `next` remain
compatibility aliases for the official line.
are not normal publication entrypoints.
The release is complete only when the immutable files, current public pointers,
hydrated website, publication ledger, and read-only verification all agree.
+1 -1
View File
@@ -22,7 +22,7 @@ if (!tag) {
console.error("usage: node npm/build.mjs <tag> (e.g. v1.0.0 or npm-v1.0.0)");
process.exit(1);
}
// npm ships on its own `npm-v*` tag (release-npm.yml); also accept a bare `v*`.
// A release carries an `npm-v*` tag beside `v*`; accept either form.
const version = tag.replace(/^(npm-)?v/, "");
const binaryVersion = `v${version}`;
const publish = process.argv.includes("--publish");
-20
View File
@@ -1,20 +0,0 @@
import { execFileSync } from "node:child_process";
import { readdirSync } from "node:fs";
import path from "node:path";
import { publishPackages } from "./publish.mjs";
const [directory, version, candidateSha] = process.argv.slice(2);
if (!directory || !version || !candidateSha) {
throw new Error("usage: publish-candidate.mjs DIRECTORY VERSION CANDIDATE_SHA");
}
const packages = readdirSync(directory)
.filter(name => name.endsWith(".tgz"))
.sort()
.map(name => {
const tarball = path.resolve(directory, name);
const manifest = JSON.parse(execFileSync("tar", ["-xOf", tarball, "package/package.json"], { encoding: "utf8" }));
return { name: manifest.name, manifest, tarball, dir: process.cwd() };
});
publishPackages({ packages, version, candidateSha });
+1 -1
View File
@@ -428,7 +428,7 @@ test("reuse never moves artifact verification past public mutation or trusts can
assert.ok(!publisher.includes("merge-multiple: true"));
const stable = workflow("release-stable");
assert.ok(job(stable, "desktop").includes("preflight_artifact_prefix: ${{ needs.signpath-preflight.outputs.artifact_prefix }}"));
for (const name of ["desktop", "cli", "npm"]) assert.ok(job(stable, name).includes("needs: [authorize, signpath-preflight]"));
for (const name of ["desktop", "cli"]) assert.ok(job(stable, name).includes("needs: [authorize, signpath-preflight]"));
});
test("all desktop consumers verify the prepared build and reject a failed preparation", () => {
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Compare two public CLI tags in one release channel. The result is:
# update - candidate is newer, or the channel pointer does not exist yet
# skip - candidate is equal to or older than the current pointer
#
# Stable ordering is major.minor.patch. Preview ordering appends the numeric
# preview ordinal. Tags from another channel or any non-canonical tag fail
# closed instead of being compared lexically.
export LC_ALL=C
channel="${1:-}"
candidate="${2:-}"
current="${3:-}"
case "$channel" in
stable)
tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
;;
preview)
tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
;;
*)
echo "unsupported CLI release channel: $channel" >&2
exit 2
;;
esac
parse_tag() {
local tag="$1"
if [[ "$tag" =~ $tag_pattern ]]; then
if [ "$channel" = "stable" ]; then
printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
else
printf '%s %s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}"
fi
return 0
fi
echo "invalid $channel CLI release tag: $tag" >&2
return 1
}
candidate_parts="$(parse_tag "$candidate")"
if [ -z "$current" ]; then
echo update
exit 0
fi
current_parts="$(parse_tag "$current")"
# `sort -V` is intentionally avoided: it accepts loose versions and does not
# enforce the channel grammar above. Compare canonical decimal components by
# length and then byte order so even components larger than a shell integer
# retain strict numeric ordering.
candidate_is_newer=0
read -r -a candidate_values <<<"$candidate_parts"
read -r -a current_values <<<"$current_parts"
for i in "${!candidate_values[@]}"; do
candidate_value="${candidate_values[$i]}"
current_value="${current_values[$i]}"
if [ "${#candidate_value}" -gt "${#current_value}" ] ||
{ [ "${#candidate_value}" -eq "${#current_value}" ] && [[ "$candidate_value" > "$current_value" ]]; }; then
candidate_is_newer=1
break
fi
if [ "${#candidate_value}" -lt "${#current_value}" ] ||
{ [ "${#candidate_value}" -eq "${#current_value}" ] && [[ "$candidate_value" < "$current_value" ]]; }; then
break
fi
done
if [ "$candidate_is_newer" -eq 1 ]; then
echo update
else
echo skip
fi
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Decide whether a validated CLI release manifest may and should replace one
# public channel pointer. Newer tags advance the pointer, older tags never do,
# and equal tags repair the pointer when its exact metadata differs.
if [ "$#" -ne 3 ]; then
echo "usage: $0 CHANNEL CANDIDATE_MANIFEST CURRENT_MANIFEST|-" >&2
exit 2
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
compare="$script_dir/compare-cli-release-tags.sh"
channel="$1"
candidate_file="$2"
current_file="$3"
case "$channel" in
stable | preview) ;;
*)
echo "unsupported CLI pointer channel: $channel" >&2
exit 2
;;
esac
if [ ! -f "$candidate_file" ]; then
echo "candidate CLI manifest is not a readable file: $candidate_file" >&2
exit 2
fi
candidate_tag="$(jq -er '.tag_name | strings' "$candidate_file")"
bash "$compare" "$channel" "$candidate_tag" "" >/dev/null
if [ "$current_file" = "-" ]; then
echo update
exit 0
fi
if [ ! -f "$current_file" ]; then
echo "current CLI manifest is not a readable file: $current_file" >&2
exit 2
fi
current_tag="$(jq -er '.tag_name | strings' "$current_file")"
decision="$(bash "$compare" "$channel" "$candidate_tag" "$current_tag")"
if [ "$current_tag" != "$candidate_tag" ] && [ "$decision" = "skip" ]; then
echo skip
elif cmp -s "$candidate_file" "$current_file"; then
echo skip
else
echo update
fi
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const version = process.argv[2];
const expectedSha = process.env.EXPECTED_SHA || "";
const verifyDelayMs = Number.parseInt(process.env.NPM_TAG_VERIFY_DELAY_MS || "2000", 10);
const verifyAttempts = Number.parseInt(process.env.NPM_TAG_VERIFY_ATTEMPTS || "15", 10);
if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version || "")) {
throw new Error("usage: finalize-npm-official-release.mjs MAJOR.MINOR.PATCH");
}
if (expectedSha && !/^[0-9a-f]{40}$/.test(expectedSha)) {
throw new Error("EXPECTED_SHA must be a full lowercase commit SHA");
}
if (!Number.isSafeInteger(verifyDelayMs) || verifyDelayMs < 0) {
throw new Error("NPM_TAG_VERIFY_DELAY_MS must be a non-negative integer");
}
if (!Number.isSafeInteger(verifyAttempts) || verifyAttempts < 1) {
throw new Error("NPM_TAG_VERIFY_ATTEMPTS must be a positive integer");
}
const packages = [
"reasonix",
"@reasonix/cli-darwin-arm64", "@reasonix/cli-darwin-x64",
"@reasonix/cli-linux-arm64", "@reasonix/cli-linux-x64",
"@reasonix/cli-win32-arm64", "@reasonix/cli-win32-x64",
];
const OFFICIAL_ALIASES = ["latest", "canary", "next"];
// The publisher stages a stable release under "<dist-tag>-staging" and removes
// it once the official aliases land (npm/publish.mjs). This cleanup used to name
// "official-staging", which nothing creates, so it never removed anything and a
// staging alias left behind by a failed publish stayed on the registry.
const stagingTag = "latest-staging";
function metadata(name) {
const output = execFileSync(
"npm",
["view", `${name}@${version}`, "name", "version", "gitHead", "reasonixCandidateSha", "--json"],
{ encoding: "utf8" },
);
return JSON.parse(output);
}
function distTags(name) {
return JSON.parse(execFileSync("npm", ["view", name, "dist-tags", "--json"], { encoding: "utf8" }));
}
function removeStagingTag(name) {
try {
execFileSync("npm", ["dist-tag", "rm", name, stagingTag], { encoding: "utf8" });
} catch (error) {
const detail = [error?.message, error?.stdout, error?.stderr]
.filter(Boolean)
.join("\n");
if (!/\bE403\b|\b403 Forbidden\b/.test(detail)) throw error;
console.warn(
`npm refused cleanup of ${name} dist-tag ${stagingTag} with E403; official aliases are already verified`,
);
}
}
async function waitForOfficialAliases(name) {
let tags = {};
for (let attempt = 1; attempt <= verifyAttempts; attempt += 1) {
tags = distTags(name);
if (OFFICIAL_ALIASES.every((tag) => tags[tag] === version)) return tags;
if (attempt < verifyAttempts) await delay(verifyDelayMs);
}
const actual = OFFICIAL_ALIASES
.map((tag) => `${tag}=${tags[tag] || "<missing>"}`)
.join(", ");
throw new Error(`${name} aliases did not converge to ${version}: ${actual}`);
}
for (const name of packages) {
const actual = metadata(name);
if (actual.name !== name || actual.version !== version) {
throw new Error(`${name}@${version} is unavailable or resolved to another package`);
}
if (expectedSha && actual.gitHead !== expectedSha) {
throw new Error(`${name}@${version} gitHead ${actual.gitHead || "<missing>"} does not match ${expectedSha}`);
}
if (expectedSha && actual.reasonixCandidateSha !== expectedSha) {
throw new Error(`${name}@${version} reasonixCandidateSha ${actual.reasonixCandidateSha || "<missing>"} does not match ${expectedSha}`);
}
}
// Recovery reruns this after an alias write already succeeded — including after
// someone realigned the aliases by hand because the automation could not. Every
// dist-tag write is a registry mutation that can be refused (npm treats them as
// sensitive operations, so an automation token gets a 403 where an interactive
// session does not), and refusing a write we did not need would fail a rerun
// that had nothing left to do. So read first and only write what is missing.
for (const name of packages) {
const before = distTags(name);
for (const tag of OFFICIAL_ALIASES) {
if (before[tag] === version) continue;
execFileSync("npm", ["dist-tag", "add", `${name}@${version}`, tag], { stdio: "inherit" });
}
const tags = await waitForOfficialAliases(name);
if (tags[stagingTag] === version) {
removeStagingTag(name);
}
}
@@ -1,133 +0,0 @@
import assert from "node:assert/strict";
import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const candidate = "c46e3af1c2732fe2b3dedb0bd47eb39a629357d2";
// The fake npm records every dist-tag mutation so a test can assert not just
// the outcome but how many registry writes it took to get there — the whole
// point of the idempotent path is that a rerun performs none.
function run(
expectedSha = candidate,
publishedSha = candidate,
staleReads = 0,
{ staging = false, forbidRemoval = false } = {},
) {
const directory = mkdtempSync(join(tmpdir(), "reasonix-npm-alias-test-"));
const npm = join(directory, "npm");
const state = join(directory, "state");
const writes = join(directory, "writes");
writeFileSync(npm, `#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
if (args[0] === "view" && args[1].includes("@1.19.2") && args.at(-1) === "--json") {
const name = args[1].slice(0, -"@1.19.2".length);
console.log(JSON.stringify({ name, version: "1.19.2", gitHead: ${JSON.stringify(publishedSha)}, reasonixCandidateSha: ${JSON.stringify(publishedSha)} }));
} else if (args[0] === "view" && args[2] === "dist-tags") {
const statePath = process.env.NPM_FAKE_STATE;
const reads = fs.existsSync(statePath) ? Number(fs.readFileSync(statePath, "utf8")) : 0;
fs.writeFileSync(statePath, String(reads + 1));
const stale = reads < Number(process.env.NPM_FAKE_STALE_READS || 0);
const tags = stale
? { latest: "1.19.2", canary: "1.19.2-canary.1", next: "1.19.0-rc.3" }
: { latest: "1.19.2", canary: "1.19.2", next: "1.19.2" };
if (process.env.NPM_FAKE_STAGING === "1") tags["latest-staging"] = "1.19.2";
console.log(JSON.stringify(tags));
} else if (args[0] === "dist-tag" && (args[1] === "add" || args[1] === "rm")) {
fs.appendFileSync(process.env.NPM_FAKE_WRITES, JSON.stringify(args) + "\\n");
if (args[1] === "rm" && process.env.NPM_FAKE_FORBID_REMOVAL === "1") {
console.error("npm error code E403");
console.error("npm error 403 Forbidden");
process.exit(1);
}
process.exit(0);
} else {
console.error("unexpected npm arguments", JSON.stringify(args));
process.exit(2);
}
`);
chmodSync(npm, 0o755);
const result = spawnSync(process.execPath, ["scripts/finalize-npm-official-release.mjs", "1.19.2"], {
cwd: new URL("..", import.meta.url),
encoding: "utf8",
env: {
...process.env,
EXPECTED_SHA: expectedSha,
NPM_FAKE_STATE: state,
NPM_FAKE_STALE_READS: String(staleReads),
NPM_FAKE_STAGING: staging ? "1" : "0",
NPM_FAKE_FORBID_REMOVAL: forbidRemoval ? "1" : "0",
NPM_FAKE_WRITES: writes,
NPM_TAG_VERIFY_DELAY_MS: "1",
PATH: `${directory}:${process.env.PATH}`,
},
});
result.writes = existsSync(writes)
? readFileSync(writes, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
: [];
return result;
}
test("completes after exact package provenance validation", () => {
const result = run();
assert.equal(result.status, 0, result.stderr);
});
test("waits for npm alias propagation before continuing", () => {
const result = run(candidate, candidate, 2);
assert.equal(result.status, 0, result.stderr);
});
test("fails closed before alias mutation when provenance differs", () => {
const result = run(candidate, "a".repeat(40));
assert.notEqual(result.status, 0);
assert.match(result.stderr, /does not match/);
assert.deepEqual(result.writes, [], "provenance mismatch must not touch the registry");
});
// Recovery reruns this after the aliases are already correct — including after a
// manual realignment, which is exactly what happens when the automation token is
// refused. Writing anyway turned a rerun with nothing to do into a 403 (#7342
// cluster), so an already-aligned release must perform zero registry writes.
test("writes nothing when the official aliases already point at the release", () => {
const result = run();
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.writes, [], `unexpected registry writes: ${JSON.stringify(result.writes)}`);
});
// Only the aliases that are actually behind get written.
test("writes only the aliases that are missing", () => {
const result = run(candidate, candidate, 1);
assert.equal(result.status, 0, result.stderr);
const added = result.writes.filter((args) => args[1] === "add").map((args) => args.at(-1));
assert.ok(added.length > 0, "a stale alias must still be written");
assert.ok(!added.includes("latest"), `latest was already current: ${JSON.stringify(result.writes)}`);
assert.deepEqual([...new Set(added)].sort(), ["canary", "next"]);
});
// The publisher stages under "<dist-tag>-staging"; cleanup used to name
// "official-staging", which nothing creates, so a leftover staging alias
// survived on the registry.
test("removes the staging alias the publisher actually creates", () => {
const result = run(candidate, candidate, 0, { staging: true });
assert.equal(result.status, 0, result.stderr);
const removed = result.writes.filter((args) => args[1] === "rm");
assert.equal(removed.length, 7, `expected one removal per package: ${JSON.stringify(removed)}`);
for (const args of removed) {
assert.equal(args.at(-1), "latest-staging");
}
});
test("completes when npm forbids removal after official aliases converge", () => {
const result = run(candidate, candidate, 0, {
staging: true,
forbidRemoval: true,
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stderr, /official aliases are already verified/);
const removals = result.writes.filter((args) => args[1] === "rm");
assert.equal(removals.length, 7);
});
-55
View File
@@ -1,55 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const VERSION_RE = /version "(\d+)\.(\d+)\.(\d+)"/;
function compareVersion(left, right) {
const a = left.map(Number); const b = right.map(Number);
for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
return 0;
}
export function decideCaskUpdate(existing, candidate) {
if (existing === candidate) return "reuse";
const current = existing.match(VERSION_RE);
const next = candidate.match(VERSION_RE);
if (!next) throw new Error("candidate cask has no canonical version");
if (current && compareVersion(next.slice(1), current.slice(1)) < 0) throw new Error("refusing to move Homebrew cask backwards");
if (current && compareVersion(next.slice(1), current.slice(1)) === 0) throw new Error("existing Homebrew version has conflicting content");
return "publish";
}
async function request(url, token, options = {}) {
const response = await fetch(url, {
...options,
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", ...options.headers },
});
if (!response.ok) throw new Error(`GitHub API ${response.status}: ${await response.text()}`);
return response.json();
}
async function publish(file) {
const token = process.env.HOMEBREW_TAP_TOKEN;
if (!token) throw new Error("HOMEBREW_TAP_TOKEN is required");
const candidate = readFileSync(file, "utf8");
const api = "https://api.github.com/repos/esengine/homebrew-reasonix/contents/Casks/reasonix.rb";
const current = await request(api, token);
const existing = Buffer.from(current.content, "base64").toString("utf8");
if (decideCaskUpdate(existing, candidate) === "reuse") return;
await request(api, token, {
method: "PUT",
body: JSON.stringify({
message: `chore: update Reasonix to ${candidate.match(VERSION_RE).slice(1).join(".")}`,
content: Buffer.from(candidate).toString("base64"),
sha: current.sha,
branch: "main",
committer: { name: "reasonix", email: "[email protected]" },
}),
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
if (!process.argv[2]) throw new Error("usage: publish-homebrew-cask.mjs CASK_FILE");
await publish(process.argv[2]);
}
-12
View File
@@ -1,12 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideCaskUpdate } from "./publish-homebrew-cask.mjs";
const cask = version => `cask "reasonix" do\n version "${version}"\nend\n`;
test("reuses an identical cask", () => assert.equal(decideCaskUpdate(cask("1.2.3"), cask("1.2.3")), "reuse"));
test("publishes a newer cask", () => assert.equal(decideCaskUpdate(cask("1.2.3"), cask("1.2.4")), "publish"));
test("rejects downgrade and same-version conflict", () => {
assert.throws(() => decideCaskUpdate(cask("2.0.0"), cask("1.9.9")), /backwards/);
assert.throws(() => decideCaskUpdate(`${cask("1.2.3")}# old`, cask("1.2.3")), /conflicting/);
});
+5 -48
View File
@@ -5,16 +5,6 @@ import { pathToFileURL } from "node:url";
const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
const SHA_RE = /^[0-9a-f]{40}$/;
export const npmPackageNames = [
"reasonix",
"@reasonix/cli-darwin-arm64",
"@reasonix/cli-darwin-x64",
"@reasonix/cli-linux-arm64",
"@reasonix/cli-linux-x64",
"@reasonix/cli-win32-arm64",
"@reasonix/cli-win32-x64",
];
function compareStable(a, b) {
if (!VERSION_RE.test(a) || !VERSION_RE.test(b)) throw new Error("invalid stable version in publication observation");
const aa = a.split(".").map(Number);
@@ -53,39 +43,8 @@ function releaseAssets(release, surface) {
})).sort((a, b) => a.name.localeCompare(b.name));
}
export function createCoreLedger({ version, sourceSHA, operation, cliRelease, desktopRelease, npmPackages, observedAt = new Date().toISOString() }) {
export function createCoreLedger({ version, sourceSHA, operation, cliRelease, desktopRelease, observedAt = new Date().toISOString() }) {
requireIdentity(version, sourceSHA, operation);
if (!Array.isArray(npmPackages) || npmPackages.length !== npmPackageNames.length) {
throw new Error("publication ledger requires all npm packages");
}
const packages = npmPackages.map(item => {
if (!npmPackageNames.includes(item.name) || item.version !== version || !item.integrity) {
throw new Error(`invalid npm publication observation: ${item.name ?? "<unknown>"}`);
}
if ((item.reasonixCandidateSha && item.reasonixCandidateSha !== sourceSHA)
|| (item.gitHead && item.gitHead !== sourceSHA)
|| (!item.reasonixCandidateSha && !item.gitHead)) {
throw new Error(`npm package does not match the candidate: ${item.name}`);
}
const pointerComparison = compareStable(item.latest, version);
if (pointerComparison < 0 || (operation === "publish" && pointerComparison !== 0)) {
throw new Error(`npm latest is inconsistent for ${item.name}: ${item.latest}`);
}
return {
name: item.name,
version: item.version,
integrity: item.integrity,
latest: item.latest,
state: "identity-verified",
pointerState: pointerComparison === 0 ? "public-entry-updated" : "newer-entry-preserved",
};
}).sort((a, b) => a.name.localeCompare(b.name));
if (new Set(packages.map(item => item.name)).size !== npmPackageNames.length) {
throw new Error("publication ledger contains duplicate npm packages");
}
for (const name of npmPackageNames) {
if (!packages.some(item => item.name === name)) throw new Error(`publication ledger is missing npm package: ${name}`);
}
return {
schema: 1,
version,
@@ -98,7 +57,6 @@ export function createCoreLedger({ version, sourceSHA, operation, cliRelease, de
items: [`v${version}`, `npm-v${version}`, `desktop-v${version}`].map(name => ({ name, sha: sourceSHA })),
},
cli: { state: "identity-verified", assets: releaseAssets(cliRelease, "CLI") },
npm: { state: "identity-verified", packages },
desktop: { state: "identity-verified", assets: releaseAssets(desktopRelease, "Desktop") },
},
};
@@ -117,7 +75,6 @@ export function createSiteLedger({ version, sourceSHA, operation, manifest, obse
stableManifest: { state: "public-entry-updated", version: manifest.version },
homepage: { state: "public-entry-updated", version: `v${version}` },
changelog: { state: "public-entry-updated", version: `v${version}` },
homebrew: { state: "public-entry-updated", version },
},
};
}
@@ -139,9 +96,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
if (command === "site-owner" && args.length === 3) {
const [version, operation, manifestPath] = args;
console.log(ownsPublicSite(version, operation, read(manifestPath)));
} else if (command === "core" && args.length === 7) {
const [version, sourceSHA, operation, cliPath, desktopPath, npmPath, output] = args;
writeFileSync(output, `${JSON.stringify(createCoreLedger({ version, sourceSHA, operation, cliRelease: read(cliPath), desktopRelease: read(desktopPath), npmPackages: read(npmPath) }), null, 2)}\n`);
} else if (command === "core" && args.length === 6) {
const [version, sourceSHA, operation, cliPath, desktopPath, output] = args;
writeFileSync(output, `${JSON.stringify(createCoreLedger({ version, sourceSHA, operation, cliRelease: read(cliPath), desktopRelease: read(desktopPath) }), null, 2)}\n`);
} else if (command === "site" && args.length === 5) {
const [version, sourceSHA, operation, manifestPath, output] = args;
writeFileSync(output, `${JSON.stringify(createSiteLedger({ version, sourceSHA, operation, manifest: read(manifestPath) }), null, 2)}\n`);
@@ -149,6 +106,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
const [corePath, sitePath, output] = args;
writeFileSync(output, `${JSON.stringify(mergeLedgers(read(corePath), read(sitePath)), null, 2)}\n`);
} else {
throw new Error("usage: release-publication-ledger.mjs core VERSION SHA OPERATION CLI DESKTOP NPM OUTPUT | site VERSION SHA OPERATION MANIFEST OUTPUT | merge CORE SITE OUTPUT");
throw new Error("usage: release-publication-ledger.mjs core VERSION SHA OPERATION CLI DESKTOP OUTPUT | site VERSION SHA OPERATION MANIFEST OUTPUT | merge CORE SITE OUTPUT");
}
}
+8 -23
View File
@@ -5,50 +5,35 @@ import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createCoreLedger, createSiteLedger, mergeLedgers, npmPackageNames, ownsPublicSite } from "./release-publication-ledger.mjs";
import { createCoreLedger, createSiteLedger, mergeLedgers, ownsPublicSite } from "./release-publication-ledger.mjs";
const sha = "a".repeat(40);
const release = { isDraft: false, isPrerelease: false, assets: [{ name: "asset.zip", size: 42, digest: "sha256:abc" }] };
const packages = latest => npmPackageNames.map(name => ({
name,
version: "1.2.3",
reasonixCandidateSha: sha,
gitHead: sha,
integrity: "sha512-example",
latest,
}));
test("records immutable files and exact current pointers", () => {
test("records immutable files for the GitHub releases this line owns", () => {
const core = createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.2.3"),
cliRelease: release, desktopRelease: release,
});
assert.equal(core.surfaces.tags.items.length, 3);
assert.ok(core.surfaces.npm.packages.every(item => item.pointerState === "public-entry-updated"));
assert.deepEqual(Object.keys(core.surfaces).sort(), ["cli", "desktop", "tags"]);
assert.equal(core.surfaces.cli.assets[0].digest, "sha256:abc");
});
test("recovery preserves newer public pointers", () => {
const core = createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "recover",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.3.0"),
});
assert.ok(core.surfaces.npm.packages.every(item => item.pointerState === "newer-entry-preserved"));
assert.throws(() => createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.3.0"),
}), /npm latest is inconsistent/);
cliRelease: { ...release, isDraft: true }, desktopRelease: release,
}), /CLI release is not a public final release/);
});
test("site evidence merges only for the same candidate", () => {
const core = createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.2.3"),
cliRelease: release, desktopRelease: release,
});
const site = createSiteLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish", manifest: { version: "v1.2.3" },
});
assert.equal(mergeLedgers(core, site).surfaces.homepage.state, "public-entry-updated");
assert.equal(mergeLedgers(core, site).surfaces.homebrew, undefined);
assert.throws(() => mergeLedgers(core, { ...site, sourceSHA: "b".repeat(40) }), /one release/);
});
+25 -130
View File
@@ -150,8 +150,18 @@ test -x "$repo_root/scripts/finalize-windows-signed-candidate.sh"
for retired in release-preview.yml release-cli-trigger.yml release-desktop-trigger.yml; do
test ! -e "$repo_root/.github/workflows/$retired"
done
npm_events="$(sed -n '/^on:/,/^permissions:/p' "$repo_root/.github/workflows/release-npm.yml")"
! grep -Eq 'push:|npm-v\*-\*' <<<"$npm_events"
# npm, the Homebrew tap, and the CLI update pointers belong to the Studio line.
# No workflow on this line may hold their credentials or write their locations.
test ! -e "$repo_root/.github/workflows/release-npm.yml"
for retired in scripts/publish-homebrew-cask.mjs scripts/finalize-npm-official-release.mjs \
scripts/resolve-npm-release.sh scripts/decide-cli-pointer-update.sh npm/publish-candidate.mjs; do
test ! -e "$repo_root/$retired"
done
if grep -REn 'NPM_TOKEN|HOMEBREW_TAP_TOKEN|homebrew-reasonix|npm publish|npm/build\.mjs .*--publish|npm dist-tag|cli/\$\{?channel\}?/latest\.json|cli/(stable|preview)/latest\.json' \
"$repo_root/.github/workflows" "$repo_root/.goreleaser.yaml"; then
echo "a workflow on this line still publishes the CLI to npm, Homebrew, or the CLI update pointers" >&2
exit 1
fi
grep -Eq '^name: Prepare release$' "$repo_root/.github/workflows/prepare-release-notes.yml"
[ "$(grep -Fc 'git merge --no-edit origin/main-v2' "$repo_root/.github/workflows/prepare-release-notes.yml")" = "0" ]
grep -Fq 'RELEASE_NOTES_SOURCE_SHA=$source_sha' "$repo_root/.github/workflows/prepare-release-notes.yml"
@@ -166,16 +176,14 @@ if grep -Eq '^ push:$' "$repo_root/.github/workflows/release-stable.yml" ||
echo "production workflow must be dispatched on protected main-v2, not run on a tag origin" >&2
exit 1
fi
for workflow in release.yml release-npm.yml release-desktop.yml; do
for workflow in release.yml release-desktop.yml; do
grep -Eq 'github\.workflow_ref' "$repo_root/.github/workflows/$workflow"
grep -Eq 'github\.ref_protected' "$repo_root/.github/workflows/$workflow"
grep -Eq 'inputs\.approved_sha' "$repo_root/.github/workflows/$workflow"
grep -Eq 'verify-release-tag\.sh' "$repo_root/.github/workflows/$workflow"
grep -Eq 'release-\{1\}\.yml' "$repo_root/.github/workflows/$workflow"
done
for workflow in release.yml release-npm.yml; do
grep -Eq "needs\.cache-guard\.result == 'success'" "$repo_root/.github/workflows/$workflow"
done
grep -Eq "needs\.cache-guard\.result == 'success'" "$repo_root/.github/workflows/release.yml"
if grep -Eq '^ cache-guard:|needs\.cache-guard' "$repo_root/.github/workflows/release-desktop.yml"; then
echo "Desktop must reuse the orchestrator cache/docs evidence instead of executing candidate guard scripts twice" >&2
exit 1
@@ -191,7 +199,6 @@ grep -Eq 'git merge-base --is-ancestor.*origin/main-v2' "$repo_root/.github/work
grep -Eq 'CLI Preview must tag current main-v2' "$repo_root/.github/workflows/release.yml"
grep -Fq 'ALLOW_PREVIEW_RECOVERY: ${{ inputs.allow_preview_recovery }}' "$repo_root/.github/workflows/release.yml"
grep -Eq 'Preview recovery requires the approved Preview orchestrator' "$repo_root/.github/workflows/release.yml"
grep -Eq "channel == 'stable'.*HOMEBREW_TAP_TOKEN" "$repo_root/.github/workflows/release.yml"
grep -Fq 'name: Isolate release-control checkout from product git state' \
"$repo_root/.github/workflows/release.yml"
grep -Fq "grep -qxF '/release-control/'" "$repo_root/.github/workflows/release.yml"
@@ -321,36 +328,6 @@ grep -Eq 'IN_PRODUCTION_SIGNING_SMOKE:.*inputs\.production_signing_smoke' \
"$repo_root/.github/workflows/release-desktop.yml"
grep -Eq 'IN_SIGNING_PREFLIGHT:.*inputs\.signing_preflight' \
"$repo_root/.github/workflows/release-desktop.yml"
grep -Fq "group: release-npm-\${{ inputs.channel || 'next' }}" \
"$repo_root/.github/workflows/release-npm.yml"
npm_dispatch="$(sed -n '/^ workflow_dispatch:/,/^ workflow_call:/p' "$repo_root/.github/workflows/release-npm.yml")"
grep -Eq 'default: stable' <<<"$npm_dispatch"
if grep -Eq '^ - canary$' <<<"$npm_dispatch"; then
echo "Standalone npm dispatch must not expose public Canary publication" >&2
exit 1
fi
grep -Fq 'if: ${{ !inputs.orchestrated }}' "$repo_root/.github/workflows/release-npm.yml"
grep -Fq 'Publish or recover immutable npm packages' "$repo_root/.github/workflows/release-npm.yml"
npm_cache_guard="$(sed -n '/^ cache-guard:/,/^ npm:/p' "$repo_root/.github/workflows/release-npm.yml")"
if grep -Fq 'RECOVERY_CONTROL_SHA' <<<"$npm_cache_guard"; then
echo "npm recovery control plane must load in the publisher job after candidate checkout" >&2
exit 1
fi
npm_job="$(sed -n '/^ npm:/,$p' "$repo_root/.github/workflows/release-npm.yml")"
grep -Fq 'RECOVERY_CONTROL_SHA: ${{ github.workflow_sha }}' <<<"$npm_job"
grep -Fq 'git restore --source="$RECOVERY_CONTROL_SHA"' <<<"$npm_job"
for recovery_script in npm/publish.mjs scripts/finalize-npm-official-release.mjs; do
grep -Fq "$recovery_script" <<<"$npm_job"
done
# Orchestrated Stable recovery needs the same protected publisher repair as a
# standalone run; the immutable product checkout must not select the old helper.
npm_control_step="$(sed -n '/ - name: Load approved npm publication control plane/,/ - uses: actions\/setup-go@v7/p' "$repo_root/.github/workflows/release-npm.yml")"
[ -n "$npm_control_step" ]
if grep -q 'if:' <<<"$npm_control_step"; then
echo "npm publication control plane must load for orchestrated recovery too" >&2
exit 1
fi
grep -Fq 'publishPackages' "$repo_root/npm/build.mjs"
grep -Fq 'thumbprint: ${{ secrets.CERTUM_KEY_ID }}' "$repo_root/.github/workflows/release-desktop.yml"
grep -Eq 'SIGNPATH_RELEASE_SIGNING_ATTESTATION does not match' "$repo_root/.github/workflows/release-desktop.yml"
grep -Eq '^ signing_preflight:$' "$repo_root/.github/workflows/release-desktop.yml"
@@ -441,10 +418,16 @@ grep -Eq '^ postflight:$' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'verify-stable-release-artifacts\.sh' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'name: Upload reviewed release notes' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'name: orchestrator-reviewed-release-notes' "$repo_root/.github/workflows/release-stable.yml"
for channel in cli npm desktop; do
for channel in cli desktop; do
grep -Eq '^ publish_'"$channel"':' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq "inputs\.publish_$channel" "$repo_root/.github/workflows/release-stable.yml"
done
for workflow in release-stable.yml release-promote.yml; do
if grep -Eq '^ npm:$|publish_npm|needs\.npm' "$repo_root/.github/workflows/$workflow"; then
echo "$workflow still orchestrates an npm publisher" >&2
exit 1
fi
done
# The checked-in SignPath policy contract is the single source of truth for the
# provider allowlist and every top-level workflow that can reach signing.
@@ -458,15 +441,12 @@ for workflow in release.yml release-desktop.yml; do
grep -Eq 'if: \$\{\{ !inputs\.orchestrated' "$repo_root/.github/workflows/$workflow"
done
# CLI clients and the website consume separate Stable and Preview pointers. The
# publisher must validate every public archive before moving a pointer, retain an
# immutable per-tag record, and leave both public pointers untouched for RCs.
# The publisher validates every public archive before retaining an immutable
# per-tag record. The public CLI pointers are the Studio line's and stay unwritten.
cli_release_workflow="$repo_root/.github/workflows/release.yml"
grep -Eq 'name: Publish CLI release metadata to R2' "$cli_release_workflow"
grep -Fq 'cli/releases/${TAG}/latest.json' "$cli_release_workflow"
grep -Fq 'cli/${channel}/latest.json' "$cli_release_workflow"
grep -Fq "group: release-cli-\${{ inputs.channel || 'stable' }}" "$cli_release_workflow"
grep -Fq 'scripts/decide-cli-pointer-update.sh' "$cli_release_workflow"
grep -Fq 'scripts/validate-cli-release-manifest.sh' "$cli_release_workflow"
grep -Fq 'scripts/compare-cli-release-manifests.sh' "$cli_release_workflow"
grep -Eq 'name: Decide whether CLI artifacts need publication' "$cli_release_workflow"
@@ -481,8 +461,6 @@ fi
grep -Fq "steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == ''" "$cli_release_workflow"
grep -Fq "steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name != ''" "$cli_release_workflow"
grep -Fq 'immutable CLI release metadata for $TAG already exists with different content' "$cli_release_workflow"
grep -Fq 'cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json' "$cli_release_workflow"
grep -Eq 'internal CLI release .*Stable and Preview pointers remain unchanged' "$cli_release_workflow"
cli_public_validation_line="$(
grep -n -m1 'if \[ -n "\$channel" \]' "$cli_release_workflow" | cut -d: -f1
)"
@@ -490,49 +468,6 @@ cli_immutable_upload_line="$(
grep -n -m1 'cli/releases/${TAG}/latest.json' "$cli_release_workflow" | cut -d: -f1
)"
[ "$cli_public_validation_line" -lt "$cli_immutable_upload_line" ]
pointer_compare="$repo_root/scripts/compare-cli-release-tags.sh"
test -x "$pointer_compare"
[ "$(bash "$pointer_compare" stable v1.2.4 v1.2.3)" = "update" ]
[ "$(bash "$pointer_compare" stable v1.2.3 v1.2.3)" = "skip" ]
[ "$(bash "$pointer_compare" stable v1.2.2 v1.2.3)" = "skip" ]
[ "$(bash "$pointer_compare" stable v100000000000000000000.0.0 v99999999999999999999.999.999)" = "update" ]
[ "$(bash "$pointer_compare" preview v1.2.3-preview.11 v1.2.3-preview.9)" = "update" ]
[ "$(bash "$pointer_compare" preview v1.2.3-preview.9 v1.2.3-preview.9)" = "skip" ]
[ "$(bash "$pointer_compare" preview v1.2.3-preview.8 v1.2.3-preview.9)" = "skip" ]
[ "$(bash "$pointer_compare" stable v1.2.3 "")" = "update" ]
if bash "$pointer_compare" stable v1.2.3 v1.2.3-preview.1 >/dev/null 2>&1; then
echo "stable comparator accepted a preview pointer" >&2
exit 1
fi
if bash "$pointer_compare" preview v1.2.3-preview.1 v1.2.3 >/dev/null 2>&1; then
echo "preview comparator accepted a stable pointer" >&2
exit 1
fi
if bash "$pointer_compare" stable v01.2.3 v1.2.2 >/dev/null 2>&1; then
echo "stable comparator accepted a non-canonical tag" >&2
exit 1
fi
cli_pointer_decider="$repo_root/scripts/decide-cli-pointer-update.sh"
test -x "$cli_pointer_decider"
cli_pointer_candidate="$test_root/cli-pointer-candidate.json"
cli_pointer_older="$test_root/cli-pointer-older.json"
cli_pointer_equal="$test_root/cli-pointer-equal.json"
cli_pointer_equal_different="$test_root/cli-pointer-equal-different.json"
cli_pointer_newer="$test_root/cli-pointer-newer.json"
printf '{"tag_name":"v1.3.0","marker":"candidate"}\n' >"$cli_pointer_candidate"
printf '{"tag_name":"v1.2.9","marker":"older"}\n' >"$cli_pointer_older"
cp "$cli_pointer_candidate" "$cli_pointer_equal"
printf '{"tag_name":"v1.3.0","marker":"different"}\n' >"$cli_pointer_equal_different"
printf '{"tag_name":"v1.3.1","marker":"newer"}\n' >"$cli_pointer_newer"
[ "$(bash "$cli_pointer_decider" stable "$cli_pointer_candidate" -)" = "update" ]
[ "$(bash "$cli_pointer_decider" stable "$cli_pointer_candidate" "$cli_pointer_older")" = "update" ]
[ "$(bash "$cli_pointer_decider" stable "$cli_pointer_candidate" "$cli_pointer_equal")" = "skip" ]
[ "$(bash "$cli_pointer_decider" stable "$cli_pointer_candidate" "$cli_pointer_equal_different")" = "update" ]
[ "$(bash "$cli_pointer_decider" stable "$cli_pointer_candidate" "$cli_pointer_newer")" = "skip" ]
if bash "$cli_pointer_decider" stable "$cli_pointer_candidate" "$test_root/missing-cli-pointer.json" >/dev/null 2>&1; then
echo "CLI pointer decider accepted an unreadable manifest path" >&2
exit 1
fi
for asset in \
reasonix-darwin-amd64.tar.gz \
reasonix-darwin-arm64.tar.gz \
@@ -1734,39 +1669,6 @@ if EVENT_NAME=workflow_dispatch IN_ORCHESTRATED=false IN_CHANNEL=preview \
fi
grep -Eq 'manual CLI releases must run from protected main-v2' "$test_root/cli-unprotected.log"
EVENT_NAME=push IN_ORCHESTRATED=false IN_CHANNEL='' IN_BASE_VERSION='' IN_TAG='' \
REF_NAME=npm-v1.4.0-rc.1 RUN_NUMBER=50 GITHUB_OUTPUT="$test_root/npm-rc.out" \
bash "$repo_root/scripts/resolve-npm-release.sh"
grep -Eq '^arg=npm-v1\.4\.0-rc\.1$' "$test_root/npm-rc.out"
EVENT_NAME=push IN_ORCHESTRATED=true IN_CHANNEL=stable IN_BASE_VERSION=1.5.0 \
IN_TAG=npm-v1.5.0 REF_NAME=v1.5.0 RUN_NUMBER=51 GITHUB_OUTPUT="$test_root/npm-stable.out" \
bash "$repo_root/scripts/resolve-npm-release.sh"
grep -Eq '^arg=v1\.5\.0$' "$test_root/npm-stable.out"
EVENT_NAME=workflow_call IN_ORCHESTRATED=true IN_CHANNEL=canary IN_BASE_VERSION=1.5.0 \
IN_TAG='' REF_NAME=main-v2 RUN_NUMBER=999 IN_PREVIEW_NUMBER=42 \
GITHUB_OUTPUT="$test_root/npm-canary.out" bash "$repo_root/scripts/resolve-npm-release.sh"
grep -Eq '^arg=v1\.5\.0-canary\.42$' "$test_root/npm-canary.out"
if EVENT_NAME=workflow_dispatch IN_ORCHESTRATED=false IN_CHANNEL=canary IN_BASE_VERSION=1.5.0 \
IN_TAG='' REF_NAME=main-v2 RUN_NUMBER=52 GITHUB_OUTPUT="$test_root/npm-canary-direct.out" \
bash "$repo_root/scripts/resolve-npm-release.sh" \
>"$test_root/npm-canary-direct.log" 2>&1; then
echo "standalone public npm Canary unexpectedly passed" >&2
exit 1
fi
grep -Eq 'public npm Canary releases must be dispatched by release-preview.yml' \
"$test_root/npm-canary-direct.log"
if EVENT_NAME=workflow_dispatch IN_ORCHESTRATED=false IN_CHANNEL=stable IN_BASE_VERSION=1.5.0 \
IN_TAG=npm-v1.5.1 REF_NAME=main-v2 RUN_NUMBER=52 GITHUB_OUTPUT="$test_root/npm-mismatch.out" \
bash "$repo_root/scripts/resolve-npm-release.sh" >"$test_root/npm-mismatch.log" 2>&1; then
echo "mismatched npm stable dispatch unexpectedly passed" >&2
exit 1
fi
grep -Eq 'does not match requested version' "$test_root/npm-mismatch.log"
e2e_workflow="$repo_root/.github/workflows/e2e-bot.yml"
grep -Fq 'REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home' "$e2e_workflow"
grep -Fq 'cp /tmp/reasonix-e2e.toml "$REASONIX_HOME/config.toml"' "$e2e_workflow"
@@ -1781,7 +1683,6 @@ if grep -Fq 'exit 0' <<<"$e2e_missing_key"; then
fi
node --test "$repo_root/npm/publish.test.mjs"
node --test "$repo_root/scripts/finalize-npm-official-release.test.mjs"
node --test "$repo_root/scripts/package-desktop-dmg.test.mjs"
node "$repo_root/scripts/check-desktop-build-contract.mjs"
bash "$repo_root/scripts/release-stable.test.sh"
@@ -1790,19 +1691,13 @@ bash "$repo_root/scripts/check-docs-impact.test.sh"
# Each build orchestrator must gate on the compiled docs identity once. The
# Desktop child consumes the candidate evidence instead of rerunning the guard.
for workflow in release.yml release-npm.yml release-candidate.yml; do
for workflow in release.yml release-candidate.yml; do
grep -Fq 'bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION"' \
"$repo_root/.github/workflows/$workflow"
done
grep -Fq 'reasonix/internal/productdocs.linkedVersion={{ .Tag }}' "$repo_root/.goreleaser.yaml"
grep -Fq 'reasonix/internal/productdocs.linkedRevision={{ .Commit }}' "$repo_root/.goreleaser.yaml"
# The Homebrew cask must keep stripping quarantine from the unsigned CLI, but
# through Homebrew's current postflight_steps stanza, never the deprecated
# `postflight do` that GoReleaser's hooks field renders.
homebrew_config="$(sed -n '/^homebrew_casks:/,/^release:/p' "$repo_root/.goreleaser.yaml")"
grep -Fq 'postflight_steps do' <<<"$homebrew_config"
grep -Fq 'com.apple.quarantine' <<<"$homebrew_config"
! grep -Eq '^\s+hooks:|^\s+post:' <<<"$homebrew_config"
! grep -Eq '^(homebrew_casks|brews):' "$repo_root/.goreleaser.yaml"
grep -Fq 'reasonix/internal/productdocs.linkedVersion=${binaryVersion}' "$repo_root/npm/build.mjs"
grep -Fq 'product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION' \
"$repo_root/scripts/desktop-build.sh"
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bash
# Resolve and validate the version argument passed to npm/build.mjs. Keeping
# workflow inputs in environment variables avoids interpolating dispatch input
# into shell source.
set -euo pipefail
stable_semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
release_semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ] || [ "${IN_ORCHESTRATED:-false}" = "true" ]; then
base="${IN_BASE_VERSION:?dispatch/call requires base_version}"
base="${base#v}"
if [[ ! "$base" =~ $stable_semver_re ]]; then
echo "::error::base version must be MAJOR.MINOR.PATCH, got: $base" >&2
exit 1
fi
if [ "${IN_CHANNEL:-stable}" = "canary" ]; then
if [ "${IN_ORCHESTRATED:-false}" != "true" ]; then
echo "::error::public npm Canary releases must be dispatched by release-preview.yml" >&2
exit 1
fi
preview_number="${IN_PREVIEW_NUMBER:-${RUN_NUMBER:-}}"
if [[ ! "$preview_number" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "::error::canary preview number must be a non-negative integer, got: $preview_number" >&2
exit 1
fi
arg="v${base}-canary.${preview_number}"
else
tag="${IN_TAG:-}"
if [ -z "$tag" ]; then
echo "::error::stable publication requires an existing npm tag" >&2
exit 1
fi
if [ "$tag" != "npm-v${base}" ]; then
echo "::error::tag $tag does not match requested version npm-v${base}" >&2
exit 1
fi
arg="v${base}"
fi
else
tag="${REF_NAME:?tag push requires ref name}"
if [[ ! "$tag" =~ ^npm-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $release_semver_re ]]; then
echo "::error::npm release tag must be npm-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag" >&2
exit 1
fi
arg="$tag"
fi
echo "arg=$arg" >>"${GITHUB_OUTPUT:-/dev/stdout}"
echo "npm release resolved: $arg"
+5 -5
View File
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSy
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { createCoreLedger, createSiteLedger, npmPackageNames } from "./release-publication-ledger.mjs";
import { createCoreLedger, createSiteLedger } from "./release-publication-ledger.mjs";
const sha = "a".repeat(40);
function fixture(t) {
@@ -14,7 +14,7 @@ function fixture(t) {
mkdirSync(scripts); mkdirSync(bin);
for (const file of ["sync-release-site.sh", "observe-release-site.sh", "fetch-stable-release-manifest.sh", "check-release-public-access.sh", "release-publication-ledger.mjs"]) copyFileSync(`scripts/${file}`, path.join(scripts, file));
const release = { isDraft: false, isPrerelease: false, assets: [] };
writeFileSync(path.join(root, "core.json"), JSON.stringify(createCoreLedger({ version: "1.2.3", sourceSHA: sha, operation: "recover", cliRelease: release, desktopRelease: release, npmPackages: npmPackageNames.map(name => ({ name, version: "1.2.3", latest: "1.2.3", gitHead: sha, integrity: "sha512-example" })) })));
writeFileSync(path.join(root, "core.json"), JSON.stringify(createCoreLedger({ version: "1.2.3", sourceSHA: sha, operation: "recover", cliRelease: release, desktopRelease: release })));
writeFileSync(path.join(root, "site.json"), JSON.stringify(createSiteLedger({ version: "1.2.3", sourceSHA: sha, operation: "recover", manifest: { version: "v1.2.3" } })));
writeFileSync(path.join(scripts, "verify-stable-release-artifacts.sh"), `#!/usr/bin/env bash
set -euo pipefail
@@ -69,12 +69,12 @@ else process.exit(4);
};
}
test("site recovery correlates its own Pages run and merges all eight observed surfaces", t => {
test("site recovery correlates its own Pages run and merges all six observed surfaces", t => {
const f = fixture(t), result = f.run({});
assert.equal(result.status, 0, result.stderr);
const ledger = f.ledger();
assert.equal(ledger.completionState, "complete");
assert.equal(Object.keys(ledger.surfaces).length, 8);
assert.equal(Object.keys(ledger.surfaces).length, 6);
assert.equal(ledger.verificationContext.pagesRunId, "901");
assert.doesNotMatch(f.calls(), /"(?:upload|publish|push)"/);
});
@@ -123,7 +123,7 @@ test("public access preflight accepts the previous stable version but rejects ch
}
});
test("real public verifier rejects tag drift before inspecting any packages", t => {
test("real public verifier rejects tag drift before inspecting any release", t => {
const f = fixture(t);
writeFileSync(path.join(f.root, "bin/git"), `#!/usr/bin/env node\nconsole.log('${"b".repeat(40)}\\trefs/tags/v1.2.3');`, { mode: 0o755 });
const result = spawnSync("bash", ["scripts/verify-stable-release-artifacts.sh"], { env: { ...f.env, CLI_TAG: "v1.2.3", DESKTOP_TAG: "desktop-v1.2.3" }, encoding: "utf8" });
@@ -10,7 +10,6 @@ required=(
.github/workflows/release-site-recovery.yml
.github/workflows/release-desktop.yml
.signpath/contracts/release-signing.yml
npm/publish-candidate.mjs
scripts/build-release-cli-candidate.mjs
scripts/desktop-release-artifacts.mjs
scripts/finalize-windows-signed-candidate.sh
@@ -57,7 +56,6 @@ node --check "$root/scripts/release-candidate.mjs"
node --check "$root/scripts/verify-release-artifact-archive.mjs"
node --check "$root/scripts/resolve-release-candidate.mjs"
node --check "$root/scripts/build-release-cli-candidate.mjs"
node --check "$root/npm/publish-candidate.mjs"
bash -n "$root/scripts/finalize-windows-signed-candidate.sh"
bash -n "$root/scripts/release-candidate-tags.sh"
bash -n "$root/scripts/package-windows-desktop.sh"
+19 -76
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Verify that a stable orchestration produced every public release channel.
# Verify that a stable orchestration produced every public release channel this
# line owns: the CLI and Desktop GitHub releases and the Desktop site surfaces.
set -euo pipefail
repository="${RELEASE_REPOSITORY:?RELEASE_REPOSITORY is required}"
@@ -7,8 +8,6 @@ version="${RELEASE_VERSION:?RELEASE_VERSION is required}"
cli_tag="${CLI_TAG:?CLI_TAG is required}"
desktop_tag="${DESKTOP_TAG:?DESKTOP_TAG is required}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
attempts="${VERIFY_ATTEMPTS:-6}"
delay="${VERIFY_DELAY_SECONDS:-10}"
verify_homepage="${VERIFY_HOMEPAGE:-false}"
site_only="${VERIFY_PUBLIC_SITE_ONLY:-false}"
operation="${RELEASE_OPERATION:-publish}"
@@ -50,7 +49,6 @@ verify_site() {
local manifest="$tmp_dir/desktop-pointer.json"
local homepage="$tmp_dir/homepage.html"
local changelog="$tmp_dir/changelog.html"
local cask="$tmp_dir/reasonix.rb"
bash "$script_dir/fetch-stable-release-manifest.sh" "$version" "$manifest"
jq -e --arg version "v$version" '
.version == $version and
@@ -61,8 +59,6 @@ verify_site() {
! grep -Eq 'href="[^"]*(tag|download)/desktop-v[0-9]+\.[0-9]+\.[0-9]+' "$homepage"
go run "$script_dir/release-site-fetch/main.go" changelog "$version" "$changelog"
grep -Fq "v$version" "$changelog"
curl -fsSL https://raw.githubusercontent.com/esengine/homebrew-reasonix/main/Casks/reasonix.rb >"$cask"
grep -Eq "version ['\"]$version['\"]" "$cask"
local browser="${CHROME_BIN:-}"
if [ -z "$browser" ]; then
for candidate in google-chrome google-chrome-stable chromium chromium-browser; do
@@ -133,74 +129,21 @@ if [ "${DESKTOP_MANUAL_ONLY:-false}" = "true" ]; then
gh release view "$desktop_tag" --repo "$repository" --json body --jq .body | grep -F 'manual-download only'
fi
npm_names=(
"reasonix"
"@reasonix/cli-darwin-arm64"
"@reasonix/cli-darwin-x64"
"@reasonix/cli-linux-arm64"
"@reasonix/cli-linux-x64"
"@reasonix/cli-win32-arm64"
"@reasonix/cli-win32-x64"
)
for attempt in $(seq 1 "$attempts"); do
rm -rf "$tmp_dir/npm"
mkdir -p "$tmp_dir/npm"
visible=true
for index in "${!npm_names[@]}"; do
package="${npm_names[$index]}"
raw="$tmp_dir/npm/$index.raw.json"
if ! npm view "$package@$version" name version reasonixCandidateSha gitHead dist.integrity dist-tags.latest --json >"$raw" 2>/dev/null; then
visible=false
continue
fi
jq --arg name "$package" '
{
name: (.name // $name),
version,
reasonixCandidateSha,
gitHead,
integrity: (."dist.integrity" // .dist.integrity),
latest: (."dist-tags.latest" // ."dist-tags".latest)
}
' "$raw" >"$tmp_dir/npm/$index.json"
jq -e --arg name "$package" --arg version "$version" --arg sha "$cli_sha" '
.name == $name and .version == $version and
((.reasonixCandidateSha == null or .reasonixCandidateSha == $sha) and
(.gitHead == null or .gitHead == $sha) and
((.reasonixCandidateSha // .gitHead) == $sha)) and
(.integrity | type == "string" and length > 0) and
(.latest | type == "string" and length > 0)
' "$tmp_dir/npm/$index.json" >/dev/null || {
echo "::error::npm package identity differs from the release candidate: $package@$version" >&2
exit 1
}
done
if [ "$visible" = "true" ]; then
jq -s '.' "$tmp_dir"/npm/[0-9].json >"$tmp_dir/npm.json"
if node "$script_dir/release-publication-ledger.mjs" core "$version" "$cli_sha" "$operation" \
"$tmp_dir/cli.json" "$tmp_dir/desktop.json" "$tmp_dir/npm.json" "$tmp_dir/core-ledger.json"; then
if [ "$verify_homepage" = "true" ]; then
owns_site="$(bash "$script_dir/observe-release-site.sh" "$version" "$operation")"
if [ "$owns_site" = true ]; then
verify_site
node "$script_dir/release-publication-ledger.mjs" site "$version" "$cli_sha" "$operation" \
"$tmp_dir/desktop-pointer.json" "$tmp_dir/site-ledger.json"
node "$script_dir/release-publication-ledger.mjs" merge "$tmp_dir/core-ledger.json" \
"$tmp_dir/site-ledger.json" "${ledger_output:-$tmp_dir/publication-ledger.json}"
else
echo "A verified newer Stable release owns the site; recovered immutable v$version files only."
[ -z "$ledger_output" ] || cp "$tmp_dir/core-ledger.json" "$ledger_output"
fi
elif [ -n "$ledger_output" ]; then
cp "$tmp_dir/core-ledger.json" "$ledger_output"
fi
echo "stable release postflight OK: cli=$cli_tag desktop=$desktop_tag npm-packages=${#npm_names[@]}"
exit 0
fi
node "$script_dir/release-publication-ledger.mjs" core "$version" "$cli_sha" "$operation" \
"$tmp_dir/cli.json" "$tmp_dir/desktop.json" "$tmp_dir/core-ledger.json"
if [ "$verify_homepage" = "true" ]; then
owns_site="$(bash "$script_dir/observe-release-site.sh" "$version" "$operation")"
if [ "$owns_site" = true ]; then
verify_site
node "$script_dir/release-publication-ledger.mjs" site "$version" "$cli_sha" "$operation" \
"$tmp_dir/desktop-pointer.json" "$tmp_dir/site-ledger.json"
node "$script_dir/release-publication-ledger.mjs" merge "$tmp_dir/core-ledger.json" \
"$tmp_dir/site-ledger.json" "${ledger_output:-$tmp_dir/publication-ledger.json}"
else
echo "A verified newer Stable release owns the site; recovered immutable v$version files only."
[ -z "$ledger_output" ] || cp "$tmp_dir/core-ledger.json" "$ledger_output"
fi
echo "npm publication has not converged (attempt $attempt/$attempts)"
if [ "$attempt" -lt "$attempts" ]; then sleep "$delay"; fi
done
echo "::error::npm package set or public pointers did not converge for $version" >&2
exit 1
elif [ -n "$ledger_output" ]; then
cp "$tmp_dir/core-ledger.json" "$ledger_output"
fi
echo "stable release postflight OK: cli=$cli_tag desktop=$desktop_tag"