mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
25 files changed
+110
-1255
No files matched your search
@@ -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 \
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 / 标签发布身份
|
||||
|
||||
|
||||
@@ -21,5 +21,5 @@ Desktop 文件、执行最终安装包的原生验收并封存记录。`Publish
|
||||
文件可以跨 run 复用,不等于公开发布,也不能代替下一次正式发布的公开验收。
|
||||
|
||||
候选文件保留 30 天,记录和验收证据保留 90 天。尚未发布时若文件过期,需
|
||||
重新准备候选。正式发布后的标签、npm、Desktop 更新、Homebrew 与官网
|
||||
渲染结果仍以发布 skill 的公开 postflight 验证为准。
|
||||
重新准备候选。正式发布后的标签、CLI 与 Desktop Release、Desktop 更新与
|
||||
官网渲染结果仍以发布 skill 的公开 postflight 验证为准。
|
||||
+23
-21
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,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");
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user