feat(release): separate Stable and Preview histories

Context: Stable and Preview need independent reviewed identities while CLI, Desktop, npm, and changelog publication remain one protected release event.

Changes:
- Add a protected Preview orchestrator with a shared ordinal and SignPath preflight.
- Bind reviewed release notes to immutable all-surface publication markers.
- Route downloads and changelog archives by channel with accessible theme-aware navigation.
- Harden release authorization, manifests, tests, and operator documentation.

Compatibility:
- Legacy catalog entries without managed status remain readable and public.
- Existing Stable pointers remain unchanged, and Preview continues mirroring the legacy canary pointer for older Desktop clients.
- No persisted user state or Wails JSON contract changes.

Verification:
- bash scripts/release-workflows.test.sh
- node --test scripts/release-notes.test.mjs
- cd desktop && go test ./cmd/sign ./internal/update
- cd site && npm test
- cd site && npm run build
- actionlint for all release workflows
This commit is contained in:
SivanCola committed 2026-07-31 01:12:23 +08:00
1 parent e498c4a796
commit b6da01dbdc
41 files changed
+954 -105

No files matched your search

+2
View File
@@ -7,6 +7,8 @@
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-preview.yml @SivanCola @esengine
/.github/workflows/release-cli-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-desktop-trigger.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
+1
View File
@@ -398,6 +398,7 @@ jobs:
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/release-preview.yml \
.github/workflows/release-cli-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "Release version without a tag prefix (for example 1.18.0)"
description: "Exact release version without a tag prefix (for example 1.18.0 or 1.19.0-preview.3)"
required: true
type: string
from_tag:
+3 -8
View File
@@ -29,16 +29,11 @@ jobs:
return;
}
const tag = process.env.RELEASE_TAG;
const channel = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag)
? 'preview'
: 'stable';
const preview = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release.yml',
workflow_id: preview ? 'release-preview.yml' : 'release.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
channel,
tag,
},
inputs: preview ? { tag } : { channel: 'stable', tag },
});
+19 -7
View File
@@ -74,6 +74,16 @@ on:
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
required: false
default: ""
type: string
signing_preflight:
description: "Verify both Windows signing stages without publishing"
required: false
@@ -121,6 +131,7 @@ jobs:
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
IN_PREVIEW_NUMBER: ${{ inputs.preview_number }}
run: bash scripts/resolve-desktop-release.sh
- name: Resolve immutable candidate
@@ -152,7 +163,7 @@ jobs:
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.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 }}
@@ -160,7 +171,8 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
@@ -598,14 +610,14 @@ jobs:
if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
uses: actions/download-artifact@v8
with:
name: stable-reviewed-release-notes
path: /tmp/stable-reviewed-release-notes
name: orchestrator-reviewed-release-notes
path: /tmp/orchestrator-reviewed-release-notes
- name: Use orchestrator-reviewed release notes
if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
run: |
test -s /tmp/stable-reviewed-release-notes/release-notes.md
cp /tmp/stable-reviewed-release-notes/release-notes.md /tmp/release-notes.md
test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md
cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md
# Preview never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the preview-dist artifact below. Stable publishes a
@@ -617,7 +629,7 @@ jobs:
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
+15 -3
View File
@@ -63,6 +63,16 @@ on:
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
required: false
default: ""
type: string
permissions:
contents: read
@@ -82,7 +92,7 @@ jobs:
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.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 }}
@@ -90,7 +100,8 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
@@ -154,11 +165,12 @@ jobs:
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.tag }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- env:
+210
View File
@@ -0,0 +1,210 @@
name: Release preview
run-name: Release preview ${{ inputs.tag }}
# One protected Preview event owns the public CLI, Desktop, npm, and changelog
# identities. The CLI tag supplies the canonical preview ordinal; child
# publishers retain their native version syntax but use that same ordinal.
on:
workflow_dispatch:
inputs:
tag:
description: "Existing protected CLI Preview tag, e.g. v1.19.0-preview.3"
required: true
type: string
concurrency:
group: preview-release-${{ inputs.tag }}
cancel-in-progress: false
permissions:
actions: write
contents: write
jobs:
preflight:
name: validate Preview release event
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
base_version: ${{ steps.release.outputs.base_version }}
preview_number: ${{ steps.release.outputs.preview_number }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Resolve Preview release
id: release
env:
RELEASE_TAG: ${{ inputs.tag }}
run: bash scripts/resolve-preview-release.sh
- name: Validate exact reviewed Preview notes
env:
VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
node scripts/release-event.mjs generate \
--version "$VERSION" \
--sha "$RELEASE_SHA" \
--published-at 2000-01-01T00:00:00.000Z \
--output /tmp/release-event.json
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
with:
name: orchestrator-reviewed-release-notes
path: /tmp/release-notes.md
if-no-files-found: error
retention-days: 1
authorize:
name: approve Preview release
needs: preflight
runs-on: ubuntu-latest
environment: canary
outputs:
version: ${{ steps.approved.outputs.version }}
base_version: ${{ steps.approved.outputs.base_version }}
preview_number: ${{ steps.approved.outputs.preview_number }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
- name: Record approved Preview event
id: approved
env:
VERSION: ${{ needs.preflight.outputs.version }}
BASE_VERSION: ${{ needs.preflight.outputs.base_version }}
PREVIEW_NUMBER: ${{ needs.preflight.outputs.preview_number }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "base_version=$BASE_VERSION"
echo "preview_number=$PREVIEW_NUMBER"
echo "cli_tag=$CLI_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved Preview release $VERSION at $RELEASE_SHA"
signpath-preflight:
name: verify Preview SignPath control plane
needs: authorize
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight: true
secrets: inherit
cli:
name: publish CLI Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release.yml
with:
channel: preview
tag: ${{ needs.authorize.outputs.cli_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
secrets: inherit
npm:
name: publish npm Canary
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-npm.yml
with:
channel: canary
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
secrets: inherit
desktop:
name: publish Desktop Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight_verified: true
secrets: inherit
postflight:
name: publish Preview release record
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every Preview publisher to succeed
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for surface in CLI NPM DESKTOP; do
result_var="${surface}_RESULT"
if [ "${!result_var}" != "success" ]; then
echo "::error::$surface Preview publisher result is ${!result_var}, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Upload immutable public release marker
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
set -euo pipefail
node scripts/release-event.mjs generate \
--version "$VERSION" --sha "$RELEASE_SHA" \
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
--output /tmp/release-event.json
existing="$(mktemp -d)"
if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then
cmp -s /tmp/release-event.json "$existing/release-event.json" || {
echo "::error::published release-event.json differs from the approved Preview event"
exit 1
}
else
gh release upload "$CLI_TAG" /tmp/release-event.json
fi
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+36 -2
View File
@@ -89,7 +89,7 @@ jobs:
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
with:
name: stable-reviewed-release-notes
name: orchestrator-reviewed-release-notes
path: /tmp/release-notes.md
if-no-files-found: error
retention-days: 1
@@ -188,7 +188,8 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
contents: write
steps:
- name: Require every publisher to succeed
env:
@@ -230,3 +231,36 @@ jobs:
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
run: bash scripts/verify-stable-release-artifacts.sh
- name: Publish exact Stable release record
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
set -euo pipefail
if ! node -e '
const catalog = require("./release-notes/releases.json");
const release = catalog.releases.find((item) => item.version === process.env.VERSION);
process.exit(release?.status === "reviewed" ? 0 : 1);
'; then
echo "Legacy Stable notes do not require a publication marker"
exit 0
fi
node scripts/release-event.mjs generate \
--version "$VERSION" --sha "$RELEASE_SHA" \
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
--output /tmp/release-event.json
existing="$(mktemp -d)"
if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then
cmp -s /tmp/release-event.json "$existing/release-event.json" || {
echo "::error::published release-event.json differs from the approved Stable event"
exit 1
}
else
gh release upload "$CLI_TAG" /tmp/release-event.json
fi
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+12 -5
View File
@@ -45,6 +45,11 @@ on:
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
required: false
default: stable
type: string
permissions:
contents: write # create the release and upload archives
@@ -124,7 +129,7 @@ jobs:
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.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 }}
@@ -132,6 +137,7 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
@@ -189,13 +195,13 @@ jobs:
if: ${{ inputs.orchestrated }}
uses: actions/download-artifact@v8
with:
name: stable-reviewed-release-notes
path: /tmp/stable-reviewed-release-notes
name: orchestrator-reviewed-release-notes
path: /tmp/orchestrator-reviewed-release-notes
- name: Use orchestrator-reviewed release notes
if: ${{ inputs.orchestrated }}
run: |
test -s /tmp/stable-reviewed-release-notes/release-notes.md
cp /tmp/stable-reviewed-release-notes/release-notes.md /tmp/release-notes.md
test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md
cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated }}
env:
@@ -270,6 +276,7 @@ jobs:
tag_name: $release.tag_name,
prerelease: $release.prerelease,
html_url: $release.html_url,
release_notes_url: ("https://reasonix.io/changelog/" + $tag + "/"),
assets: [
$required[] as $name |
$assets[$name] |
+1
View File
@@ -72,3 +72,4 @@ temp/
scripts/official-theme-art/out/
scripts/official-theme-art/__pycache__/
agent-complexity-report.txt
site/.generated/
+3
View File
@@ -6,10 +6,13 @@ allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/release-stable.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-cli-trigger.yml
- .github/workflows/release-desktop.yml
- .github/workflows/release-desktop-trigger.yml
- .signpath/artifact-configurations/windows-payload.xml
+6 -5
View File
@@ -211,11 +211,12 @@ func genManifest(dir, version, tag string) error {
repo = "esengine/DeepSeek-Reasonix"
}
m := update.Manifest{
Version: version,
DownloadPage: "https://reasonix.io/?download=desktop#start",
Platforms: map[string]update.Asset{},
NativePackages: map[string]update.Asset{},
Downloads: map[string]update.Asset{},
Version: version,
DownloadPage: "https://reasonix.io/?download=desktop#start",
ReleaseNotesURL: "https://reasonix.io/changelog/" + version + "/",
Platforms: map[string]update.Asset{},
NativePackages: map[string]update.Asset{},
Downloads: map[string]update.Asset{},
}
entries, err := os.ReadDir(dir)
if err != nil {
+3
View File
@@ -89,6 +89,9 @@ func TestGenManifest(t *testing.T) {
if m.DownloadPage != "https://reasonix.io/?download=desktop#start" {
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
}
if m.ReleaseNotesURL != "https://reasonix.io/changelog/v1.2.0/" {
t.Fatalf("release_notes_url = %q, want exact version history", m.ReleaseNotesURL)
}
if len(m.Platforms) != 5 {
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
}
+8 -7
View File
@@ -12,13 +12,14 @@ import "runtime"
// Version against the running build, and looks up the running platform's artifact
// via Asset / NativePackage.
type Manifest struct {
Version string `json:"version"` // release version, e.g. "v1.1.0"
Notes string `json:"notes"` // markdown release notes
PubDate string `json:"pub_date"` // RFC3339, optional
DownloadPage string `json:"download_page"` // human-facing download page (macOS manual-update fallback)
Platforms map[string]Asset `json:"platforms"` // keyed by PlatformKey, e.g. "darwin-arm64"
NativePackages map[string]Asset `json:"native_packages,omitempty"` // optional OS package assets, e.g. linux-amd64 → .deb
Downloads map[string]Asset `json:"downloads,omitempty"` // optional signed human-download assets keyed by exact filename
Version string `json:"version"` // release version, e.g. "v1.1.0"
Notes string `json:"notes"` // markdown release notes
PubDate string `json:"pub_date"` // RFC3339, optional
DownloadPage string `json:"download_page"` // human-facing download page (macOS manual-update fallback)
ReleaseNotesURL string `json:"release_notes_url,omitempty"` // exact version history page; older manifests omit it
Platforms map[string]Asset `json:"platforms"` // keyed by PlatformKey, e.g. "darwin-arm64"
NativePackages map[string]Asset `json:"native_packages,omitempty"` // optional OS package assets, e.g. linux-amd64 → .deb
Downloads map[string]Asset `json:"downloads,omitempty"` // optional signed human-download assets keyed by exact filename
}
// Asset is one platform's downloadable artifact plus the metadata the updater
+35 -25
View File
@@ -58,15 +58,14 @@ npm retains its separate `next` and `canary` dist-tags.
| Action | Who | Mechanism |
|---|---|---|
| **Cut Native CLI Preview** | release-tag creator + configured reviewer | create and push a protected `vX.Y.Z-preview.N` tag; a minimal relay dispatches **Release** on protected `main-v2`, which classifies it as Preview, pauses on the `canary` environment, and publishes a GitHub prerelease without touching Homebrew or Latest |
| **Cut Desktop Preview** | maintainer + configured reviewer | dispatch **Release desktop** on protected `main-v2`; the `canary` GitHub environment is retained as a compatibility name and must have the same required reviewers as `release` |
| **Ship Preview** | release-tag creator + configured reviewer | prepare notes for the exact `X.Y.Z-preview.N` version, then create and push its protected CLI tag; a minimal relay dispatches **Release preview** on protected `main-v2`, which pauses once on `canary` and publishes aligned CLI, Desktop, npm, and changelog identities |
| **Ship stable** | release-tag creators + one configured reviewer | atomically push the three stable tags; a minimal tag relay dispatches **Release stable** on protected `main-v2`, which requests one GitHub `release`-environment approval before every channel publishes |
| **Ship a standalone RC** | release-tag creators + one configured reviewer | push the surface-specific prerelease tag; a minimal relay dispatches the standalone workflow on protected `main-v2`, which requests one `release` approval |
Preview remains operationally fast, but its public artifacts are not an
unreviewed or test-certificate path. Native CLI Preview tag runs and Desktop
Preview dispatches pause at the legacy `canary` environment approval. Desktop
then uses the production SignPath policy. A stable release pauses once in
unreviewed or test-certificate path. The unified Preview event pauses once at
the legacy `canary` environment approval, then uses the production SignPath
policy. A stable release pauses once in
**Release stable** until a configured reviewer approves the `release`
environment. The jobs then continue without another human approval: SignPath
verifies the trusted GitHub origin, scans and signs every installed executable,
@@ -90,7 +89,8 @@ cannot claim that it already passed the approval job.
Repository `write` access remains a privileged role because repository-level
Actions secrets are available to workflows on repository branches. Production
Windows signing has an additional provider-side boundary: SignPath accepts the
trusted `.github/workflows/release-stable.yml` and
trusted `.github/workflows/release-stable.yml`,
`.github/workflows/release-preview.yml`, and
`.github/workflows/release-desktop.yml` build definitions only when their
origin branch is exactly `main-v2`. Never broaden that policy to `**` or a
tag-shaped wildcard. Other publication credentials should move to protected
@@ -101,9 +101,11 @@ strict separation from repository writers is required.
1. **Develop** — PRs land on `main-v2` (branch auto-deletes on merge).
2. **Prepare the release notes without creating a release-only PR by default** —
Actions → **Prepare release notes**. Enter the intended version, the previous
desktop tag when needed, and the number of an existing release-bound PR when
one is available. The reusable PR must be open, target `main-v2`, come from a
Actions → **Prepare release notes**. Enter the exact intended version
(`X.Y.Z` for Stable or `X.Y.Z-preview.N` for Preview), the previous release
tag when needed, and the number of an existing release-bound PR when one is
available. Every exact Stable and Preview identity gets its own reviewed
catalog record. The reusable PR must be open, target `main-v2`, come from a
branch in this repository, and already include the latest `main-v2`. The
workflow commits the generated notes onto that branch, so product changes and
their release copy share one PR and one review surface. The added commit still
@@ -118,20 +120,25 @@ strict separation from repository writers is required.
equivalent English and Chinese product notes, validates their structure and
citations, and includes the rendered draft in the workflow summary. Review
the catalog diff like product copy. Once merged, the same entry drives
`/changelog/` and both CLI and Desktop GitHub Releases; the desktop app links
to that web history from Settings → Updates. A missing catalog entry still
blocks stable publication.
the exact `/changelog/vX.Y.Z/` or `/changelog/vX.Y.Z-preview.N/` page and the
corresponding release surfaces; the desktop app links directly to that exact
web history from Settings → Updates. A reviewed record stays hidden until the
all-surface postflight uploads its matching immutable `release-event.json`.
Missing or mismatched notes block publication.
3. **Cut Preview** during the intended release cycle (e.g. heading for `1.4.0`):
- Native CLI: create and push the next protected Preview tag:
- First merge the reviewed notes for the exact Preview identity, then create
and push its protected tag:
```sh
git tag v1.4.0-preview.1
git push origin v1.4.0-preview.1
```
- Desktop: Actions → **Release desktop** → `channel: preview`, `base_version: 1.4.0`
- CLI: Actions → **Release npm** → `base_version: 1.4.0`
- Publishes the native CLI as a GitHub prerelease and Desktop to R2
`preview/` (no Desktop GitHub release), mirroring Desktop `canary/` only
for older clients. npm still publishes its independent `@canary` channel.
- The tag relay dispatches **Release preview** on protected `main-v2`. After
one `canary` approval and a zero-publication SignPath preflight, it publishes
CLI `v1.4.0-preview.1`, Desktop `1.4.0-preview.1`, npm
`1.4.0-canary.1`, and the exact Preview changelog as one event.
- Desktop advances only R2 `preview/` (and mirrors `canary/` for older
clients); no Desktop Preview appears on the GitHub releases page. npm
advances only `@canary`; Homebrew and Stable pointers remain untouched.
4. **Test** — native CLI testers download the immutable GitHub prerelease;
desktop users opt into Preview in Settings → Updates; npm CLI testers
install `reasonix@canary`.
@@ -191,9 +198,11 @@ strict separation from repository writers is required.
## Notes
- Native CLI Preview uses the protected tag's explicit `N`; Desktop Preview and
npm Canary use their workflow `run_number`, so suffixes can differ. Only
monotonicity per surface matters.
- Native CLI Preview supplies the protected tag's explicit `N`; the unified
Preview orchestrator reuses that ordinal for Desktop `preview.N` and npm
`canary.N`. The exact versions are recorded in one reviewed changelog entry;
the immutable publication marker adds the actual candidate SHA after all
surfaces succeed.
- A stable `-rc` tag (e.g. `npm-v1.4.0-rc.1`) still ships under `next`, not `canary`.
- Recover an interrupted stable release by dispatching **Release stable** from
protected `main-v2` with the existing `vX.Y.Z` tag. Recovery requires the CLI,
@@ -216,7 +225,8 @@ strict separation from repository writers is required.
request through the SignPath API. Human SignPath approvers remain available
for emergency recovery, but normal releases do not require additional
SignPath clicks. SignPath must restrict `release-signing` to the trusted
`.github/workflows/release-stable.yml` and
`.github/workflows/release-stable.yml`,
`.github/workflows/release-preview.yml`, and
`.github/workflows/release-desktop.yml` build definitions and exact `main-v2`
branch. Stable and prerelease tag events are relayed to that protected control
plane; do not replace exact matches with wildcards. The machine-readable
@@ -224,9 +234,9 @@ strict separation from repository writers is required.
workflow call graph in CI. Standalone Desktop releases require
`SIGNPATH_RELEASE_SIGNING_ATTESTATION` to match the current contract hash;
`signing_preflight=true` refreshes it only after both architectures complete
both signing stages. Stable performs the same live preflight in its approved
run before CLI, npm, or Desktop publication, so a provider-side policy drift
fails before any public channel starts.
both signing stages. Stable and unified Preview perform the same live
preflight in their approved runs before CLI, npm, or Desktop publication, so
a provider-side policy drift fails before any public channel starts.
- Desktop in-app updates use R2 first, then the `crash.reasonix.io` desktop release
gateway. The gateway resolves the `desktop-v*` release line directly and never uses
GitHub's repository-wide `/releases/latest`, because plain `v*` tags are the CLI
+14 -8
View File
@@ -9,6 +9,7 @@ Reasonix Windows Authenticode 两阶段签名链路。
- Preview 渠道改造:[esengine/DeepSeek-Reasonix#6155](https://github.com/esengine/DeepSeek-Reasonix/pull/6155)
- 本 SOP 的验收对象:每次执行前通过 PR API 读回的当前 PR Head
- 签名工作流:`.github/workflows/release-stable.yml`、
`.github/workflows/release-preview.yml`、
`.github/workflows/release-desktop.yml`
- 机器契约:`.signpath/contracts/release-signing.yml`
- Authenticode 验证脚本:`scripts/verify-windows-authenticode.ps1`
@@ -73,6 +74,7 @@ SignPath 权限说明:
- `release-signing` 开启 `Use approval process`,Required approvals 为 `1`。
- `release-signing` 的 Allowed build definitions 精确允许:
- `.github/workflows/release-stable.yml`
- `.github/workflows/release-preview.yml`
- `.github/workflows/release-desktop.yml`
- `release-signing` 的 Allowed branches 必须精确为 `main-v2`;稳定版和 RC
标签由最小 relay workflow 转发到该受保护控制面。
@@ -250,10 +252,11 @@ GitHub `upload-artifact` 提交给 SignPath 的产物是 ZIP,因此配置根
```
- Allowed branches:**只能填写 `main-v2`**
- Allowed build definitions:**只能逐行填写以下两个精确路径**:
- Allowed build definitions:**只能逐行填写以下三个精确路径**:
```text
.github/workflows/release-stable.yml
.github/workflows/release-preview.yml
.github/workflows/release-desktop.yml
```
@@ -274,11 +277,11 @@ GitHub `upload-artifact` 提交给 SignPath 的产物是 ZIP,因此配置根
`desktop-v*` 或临时测试分支。
- Allowed build definitions 与仓库机器契约逐项相同,没有通配符。
正式版和 RC 的标签事件由 `release-stable-trigger.yml` /
`release-desktop-trigger.yml` 转发:relay 只携带候选 tag,实际
`release-desktop.yml` 固定运行在受保护的 `main-v2`,再签署该 tag 指向的
不可变候选 SHA。不能把 Allowed branches 改成标签通配符,因为普通分支也可以
取形如 `v-malicious` 的名字。
Stable、Preview 和 RC 的标签事件由 `release-stable-trigger.yml`、
`release-cli-trigger.yml` / `release-desktop-trigger.yml` 转发:relay
只携带候选 tag,实际顶层发布 workflow 固定运行在受保护的 `main-v2`,
再签署该 tag 指向的不可变候选 SHA。不能把 Allowed branches 改成标签通配符,
因为普通分支也可以取形如 `v-malicious` 的名字。
`Release certificate 2026` 的 Restrictions 明确要求所有使用该证书的策略启用
审批流程。尝试关闭时,SignPath 会拒绝保存并提示:
@@ -357,6 +360,8 @@ gh workflow run release-desktop.yml \
Stable 发布由 `.github/workflows/release-stable.yml` 在唯一的 `release`
environment 审批之后自动调用相同预检。该预检完成前,CLI、npm 和 Desktop
三个公开 publisher 均不会启动,因此 SignPath 策略漂移不会再形成半发布。
统一 Preview 发布由 `.github/workflows/release-preview.yml` 在唯一的
`canary` environment 审批之后执行同样的预检,并具有相同的失败关闭语义。
### 10.1 监控运行
@@ -419,7 +424,8 @@ SignPath Signing Requests 中应出现 4 个成功请求:
- 旧 `windows-installer` 未改变。
- `release-signing` 的证书级审批保持开启,正式审批人可用。
- `release-signing` 的 Build Definitions 精确允许
`.github/workflows/release-stable.yml` 和
`.github/workflows/release-stable.yml`、
`.github/workflows/release-preview.yml` 和
`.github/workflows/release-desktop.yml`。
- `release-signing` 的 Allowed branches 精确为 `main-v2`。
- `CI builds` 是 `release-signing` 的 Submitter 和 Approver,GitHub Secret 使用其专用
@@ -538,7 +544,7 @@ gh variable set SIGNPATH_RELEASE_SIGNING_ATTESTATION \
- [ ] `SIGNPATH_API_TOKEN` 对应专用 `CI builds`,不是个人账号
- [ ] `release-signing` 已开启 Trusted Build System
- [ ] `release-signing` 已开启 Origin Verification
- [ ] `release-signing` 的 Allowed build definitions 精确为 `.github/workflows/release-stable.yml` 和 `.github/workflows/release-desktop.yml`
- [ ] `release-signing` 的 Allowed build definitions 精确为 `.github/workflows/release-stable.yml`、`.github/workflows/release-preview.yml` 和 `.github/workflows/release-desktop.yml`
- [ ] `release-signing` 的 Allowed branches 精确为 `main-v2`
- [ ] `release-signing` 的 SignPath 审批已开启,Required approvals 为 `1`
- [ ] GitHub `release` environment 的正式发布审批人和响应流程已经明确
+42 -5
View File
@@ -157,9 +157,25 @@ async function main() {
if (!args.version) throw new Error("--version is required");
const version = normalizeVersion(args.version);
const catalog = await loadCatalog();
const previous = args.from || catalog.releases.find((release) => release.version !== version)?.version;
const channel = version.includes("-") ? "prerelease" : "stable";
const baseVersion = version.split("-")[0];
const previousRecord = channel === "stable"
? catalog.releases.find((release) => release.version !== version && release.channel === "stable")
: catalog.releases.find(
(release) =>
release.version !== version &&
release.channel === "prerelease" &&
release.baseVersion === baseVersion,
) || catalog.releases.find((release) => release.channel === "stable");
const previous = args.from || previousRecord?.version;
if (!previous) throw new Error("--from is required when no previous release exists");
const from = previous.match(/^(?:desktop-|npm-)?v/) ? previous : `desktop-v${previous}`;
const previousVersion = normalizeVersion(previous);
const previousIsPreview = previousVersion.includes("-");
const from = previous.match(/^(?:desktop-|npm-)?v/)
? previous
: previousIsPreview
? `v${previousVersion}`
: `desktop-v${previousVersion}`;
const to = args.to || "HEAD";
const repository = repositoryName();
const commits = commitRange(from, to);
@@ -168,24 +184,45 @@ async function main() {
if (!pulls.length) throw new Error(`no pull requests found in ${from}..${to}`);
const docLinks = collectDocLinks(from, repository, to);
const date = args.date || new Date().toISOString().slice(0, 10);
const tag = args.tag || `desktop-v${version}`;
const tag = args.tag || (channel === "prerelease" ? `v${version}` : `desktop-v${version}`);
const source = {
version,
date,
channel: version.includes("-") ? "prerelease" : "stable",
channel,
range: `${from}..${to}`,
pullRequests: pulls,
documentationUrls: docLinks,
};
const release = await askDeepSeek(source);
release.version = version;
release.releaseId = version;
release.baseVersion = baseVersion;
release.date = date;
release.channel = source.channel;
release.status = "reviewed";
release.previousRelease = previousVersion;
const previewOrdinal = version.match(/-preview\.([1-9][0-9]*)$/)?.[1];
if (channel === "prerelease") {
if (!previewOrdinal) throw new Error("Preview release version must use MAJOR.MINOR.PATCH-preview.N");
release.builds = {
cli: `v${version}`,
desktop: `v${baseVersion}-preview.${previewOrdinal}`,
npm: `${baseVersion}-canary.${previewOrdinal}`,
};
} else {
release.builds = {
cli: `v${version}`,
desktop: `v${version}`,
npm: version,
};
}
release.contributors = [...new Set(pulls.map((pull) => pull.author).filter(Boolean))];
release.links = {
github: `https://github.com/${repository}/releases/tag/${tag}`,
compare: `https://github.com/${repository}/compare/${from}...${tag}`,
download: "https://reasonix.io/?download=desktop#start",
download: channel === "prerelease"
? "https://reasonix.io/?download=desktop&channel=preview#start"
: "https://reasonix.io/?download=desktop&channel=stable#start",
};
release.guides = (release.guides || []).filter((guide) => docLinks.includes(guide.href));
assertGroundedRefs(release, new Set(pulls.map((pull) => pull.number)));
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadCatalog, releaseForVersion } from "./release-notes.mjs";
function invariant(condition, message) {
if (!condition) throw new Error(message);
}
function parseArgs(argv) {
const [command = "validate", ...rest] = argv;
const values = { command };
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
values[arg.slice(2)] = rest[++index];
}
return values;
}
export function validateReleaseEvent(event, release) {
invariant(event && typeof event === "object" && !Array.isArray(event), "release event must be an object");
invariant(event.schemaVersion === 1, "release event schemaVersion must be 1");
invariant(event.releaseId === release.version, "release event releaseId does not match reviewed notes");
invariant(
event.channel === (release.channel === "prerelease" ? "preview" : "stable"),
"release event channel does not match reviewed notes",
);
invariant(/^[0-9a-f]{40}$/.test(event.candidateSha), "release event candidateSha must be a full commit SHA");
if (release.candidateSha) {
invariant(event.candidateSha === release.candidateSha, "release event candidateSha does not match reviewed notes");
}
invariant(/^\d{4}-\d{2}-\d{2}T/.test(event.publishedAt), "release event publishedAt must be an ISO timestamp");
invariant(event.releaseNotesUrl === `https://reasonix.io/changelog/v${release.version}/`, "release event URL is invalid");
invariant(event.builds && typeof event.builds === "object", "release event builds are required");
for (const surface of ["cli", "desktop", "npm"]) {
invariant(typeof event.builds[surface] === "string" && event.builds[surface], `release event builds.${surface} is required`);
if (release.builds?.[surface]) {
invariant(event.builds[surface] === release.builds[surface], `release event builds.${surface} does not match reviewed notes`);
}
}
return event;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
invariant(args.version, "--version is required");
const catalog = await loadCatalog(args.catalog ? resolve(args.catalog) : undefined);
const release = releaseForVersion(catalog, args.version);
if (args.command === "generate") {
invariant(args.sha, "generate requires --sha");
invariant(args.output, "generate requires --output");
const event = validateReleaseEvent({
schemaVersion: 1,
releaseId: release.version,
channel: release.channel === "prerelease" ? "preview" : "stable",
candidateSha: args.sha,
publishedAt: args["published-at"] || new Date().toISOString(),
releaseNotesUrl: `https://reasonix.io/changelog/v${release.version}/`,
builds: release.builds,
}, release);
await writeFile(resolve(args.output), `${JSON.stringify(event, null, 2)}\n`);
return;
}
invariant(args.command === "validate", `unknown command ${args.command}`);
invariant(args.input, "validate requires --input");
const event = JSON.parse(await readFile(resolve(args.input), "utf8"));
validateReleaseEvent(event, release);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+59 -2
View File
@@ -10,6 +10,8 @@ export const defaultCatalogPath = resolve(repoRoot, "release-notes/releases.json
const localizedFields = ["title", "body"];
const changeKinds = ["new", "improved", "fixed"];
const itemKinds = new Set(["new", "improved", "fixed", "security"]);
const releaseChannels = new Set(["stable", "prerelease"]);
const releaseStatuses = new Set(["reviewed", "published"]);
function invariant(condition, message) {
if (!condition) throw new Error(message);
@@ -47,7 +49,7 @@ function semverParts(version) {
return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] || ""];
}
function compareVersionsDesc(a, b) {
export function compareVersionsDesc(a, b) {
const aa = semverParts(a);
const bb = semverParts(b);
for (let i = 0; i < 3; i += 1) {
@@ -72,7 +74,58 @@ export function validateCatalog(catalog) {
invariant(!versions.has(release.version), `duplicate version ${release.version}`);
versions.add(release.version);
invariant(/^\d{4}-\d{2}-\d{2}$/.test(release.date), `${path}.date must use YYYY-MM-DD`);
invariant(release.channel === "stable" || release.channel === "prerelease", `${path}.channel is invalid`);
invariant(releaseChannels.has(release.channel), `${path}.channel is invalid`);
if (release.releaseId !== undefined) {
invariant(release.releaseId === release.version, `${path}.releaseId must equal version`);
}
if (release.baseVersion !== undefined) {
semverParts(release.baseVersion);
invariant(!release.baseVersion.includes("-"), `${path}.baseVersion must be stable semver`);
invariant(
release.version === release.baseVersion || release.version.startsWith(`${release.baseVersion}-`),
`${path}.baseVersion does not match version`,
);
}
if (release.status !== undefined) {
invariant(releaseStatuses.has(release.status), `${path}.status is invalid`);
}
if (release.candidateSha !== undefined) {
invariant(/^[0-9a-f]{40}$/.test(release.candidateSha), `${path}.candidateSha must be a full commit SHA`);
}
if (release.previousRelease !== undefined) {
semverParts(release.previousRelease);
invariant(release.previousRelease !== release.version, `${path}.previousRelease must differ from version`);
}
if (release.builds !== undefined) {
invariant(isObject(release.builds), `${path}.builds must be an object`);
for (const surface of ["cli", "desktop", "npm"]) {
invariant(
typeof release.builds[surface] === "string" && release.builds[surface].trim(),
`${path}.builds.${surface} must be a non-empty string`,
);
}
}
if (release.status !== undefined) {
for (const field of ["releaseId", "baseVersion", "previousRelease", "builds"]) {
invariant(release[field] !== undefined, `${path}.${field} is required for managed release records`);
}
if (release.channel === "stable") {
invariant(release.version === release.baseVersion, `${path}.stable version must equal baseVersion`);
invariant(release.builds.cli === `v${release.version}`, `${path}.builds.cli does not match Stable version`);
invariant(release.builds.desktop === `v${release.version}`, `${path}.builds.desktop does not match Stable version`);
invariant(release.builds.npm === release.version, `${path}.builds.npm does not match Stable version`);
} else {
const preview = release.version.match(/^(\d+\.\d+\.\d+)-preview\.([1-9][0-9]*)$/);
invariant(preview, `${path}.managed prerelease must use MAJOR.MINOR.PATCH-preview.N`);
invariant(preview[1] === release.baseVersion, `${path}.Preview baseVersion does not match version`);
invariant(release.builds.cli === `v${release.version}`, `${path}.builds.cli does not match Preview version`);
invariant(release.builds.desktop === `v${release.version}`, `${path}.builds.desktop does not match Preview version`);
invariant(
release.builds.npm === `${release.baseVersion}-canary.${preview[2]}`,
`${path}.builds.npm does not match Preview ordinal`,
);
}
}
validateLocalized(release.title, `${path}.title`);
validateLocalized(release.summary, `${path}.summary`);
invariant(Array.isArray(release.surfaces) && release.surfaces.length > 0, `${path}.surfaces must not be empty`);
@@ -136,9 +189,13 @@ function renderItems(items, lang) {
export function renderGitHubRelease(release, lang = "zh") {
const isZh = lang === "zh";
const isPreview = release.channel === "prerelease";
const channelLabel = isPreview ? (isZh ? "预览版" : "Preview") : (isZh ? "稳定版" : "Stable");
const lines = [
`> ${localized(release.summary, lang)}`,
"",
`**${isZh ? "发布渠道" : "Release channel"}:${channelLabel} · v${release.version}**`,
"",
isZh
? `[English →](https://reasonix.io/changelog/v${release.version}/?lang=en) · [网页版完整更新日志 →](https://reasonix.io/changelog/v${release.version}/)`
: `[中文 →](https://reasonix.io/changelog/v${release.version}/?lang=zh) · [Full release notes →](https://reasonix.io/changelog/v${release.version}/?lang=en)`,
+74
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { loadCatalog, releaseForVersion, renderGitHubRelease, validateCatalog } from "./release-notes.mjs";
import { validateReleaseEvent } from "./release-event.mjs";
test("the committed release catalog is valid and newest first", async () => {
const catalog = await loadCatalog();
@@ -45,3 +46,76 @@ test("validation rejects bilingual drift", () => {
/title\.zh/,
);
});
test("managed Preview records bind every surface to one exact ordinal", () => {
const release = {
version: "1.19.0-preview.3",
releaseId: "1.19.0-preview.3",
baseVersion: "1.19.0",
date: "2026-07-31",
channel: "prerelease",
status: "reviewed",
previousRelease: "1.19.0-preview.2",
builds: {
cli: "v1.19.0-preview.3",
desktop: "v1.19.0-preview.3",
npm: "1.19.0-canary.3",
},
title: { en: "Preview", zh: "预览版" },
summary: { en: "Preview summary", zh: "预览版摘要" },
surfaces: ["cli"],
guides: [],
highlights: [{
kind: "fixed",
title: { en: "Fix", zh: "修复" },
body: { en: "A fix.", zh: "一项修复。" },
refs: [1],
}],
changes: { new: [], improved: [], fixed: [] },
upgrade: [],
risks: [],
contributors: [],
links: {
github: "https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.19.0-preview.3",
compare: "https://github.com/esengine/DeepSeek-Reasonix/compare/v1.19.0-preview.2...v1.19.0-preview.3",
download: "https://reasonix.io/?download=desktop&channel=preview#start",
},
};
assert.doesNotThrow(() => validateCatalog({ schemaVersion: 1, releases: [release] }));
assert.throws(
() => validateCatalog({
schemaVersion: 1,
releases: [{ ...release, builds: { ...release.builds, npm: "1.19.0-canary.4" } }],
}),
/builds\.npm/,
);
});
test("publication marker is bound to reviewed version, channel, SHA, and builds", () => {
const release = {
version: "1.19.0-preview.3",
channel: "prerelease",
candidateSha: "a".repeat(40),
builds: {
cli: "v1.19.0-preview.3",
desktop: "v1.19.0-preview.3",
npm: "1.19.0-canary.3",
},
};
const event = {
schemaVersion: 1,
releaseId: release.version,
channel: "preview",
candidateSha: release.candidateSha,
publishedAt: "2026-07-31T00:00:00.000Z",
releaseNotesUrl: "https://reasonix.io/changelog/v1.19.0-preview.3/",
builds: release.builds,
};
assert.doesNotThrow(() => validateReleaseEvent(event, release));
assert.throws(
() => validateReleaseEvent({ ...event, candidateSha: "b".repeat(40) }, release),
/candidateSha/,
);
});
+30 -6
View File
@@ -31,9 +31,17 @@ grep -Eq "workflow_id: 'release-desktop\.yml'" \
"$repo_root/.github/workflows/release-desktop-trigger.yml"
grep -Fq 'Desktop Preview must be dispatched without a Git tag' \
"$repo_root/.github/workflows/release-desktop-trigger.yml"
grep -Eq "workflow_id: 'release\.yml'" \
grep -Fq "workflow_id: preview ? 'release-preview.yml' : 'release.yml'" \
"$repo_root/.github/workflows/release-cli-trigger.yml"
grep -Eq "preview\\\\\." "$repo_root/.github/workflows/release-cli-trigger.yml"
grep -Eq '^name: Release preview$' "$repo_root/.github/workflows/release-preview.yml"
grep -Eq '^ environment: canary$' "$repo_root/.github/workflows/release-preview.yml"
grep -Eq '^ signpath-preflight:$' "$repo_root/.github/workflows/release-preview.yml"
grep -Eq 'signing_preflight: true' "$repo_root/.github/workflows/release-preview.yml"
grep -Eq 'signing_preflight_verified: true' "$repo_root/.github/workflows/release-preview.yml"
[ "$(grep -Ec 'needs: \[authorize, signpath-preflight\]' "$repo_root/.github/workflows/release-preview.yml")" = "3" ]
grep -Eq 'release-event\.mjs generate' "$repo_root/.github/workflows/release-preview.yml"
grep -Eq 'gh workflow run pages\.yml' "$repo_root/.github/workflows/release-preview.yml"
grep -Eq 'release-cli-trigger\.yml' "$repo_root/.github/workflows/ci.yml"
if grep -Eq '^ push:$' "$repo_root/.github/workflows/release-stable.yml" ||
grep -Eq '^ push:$' "$repo_root/.github/workflows/release.yml" ||
@@ -46,7 +54,7 @@ for workflow in release.yml release-npm.yml release-desktop.yml; do
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-stable\.yml' "$repo_root/.github/workflows/$workflow"
grep -Eq 'release-\{1\}\.yml' "$repo_root/.github/workflows/$workflow"
grep -Eq "needs\.cache-guard\.result == 'success'" "$repo_root/.github/workflows/$workflow"
done
grep -Eq 'options: \[stable, preview\]' "$repo_root/.github/workflows/release.yml"
@@ -163,7 +171,9 @@ desktop_r2_upload_line="$(
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: stable-reviewed-release-notes' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'name: orchestrator-reviewed-release-notes' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'name: Upload reviewed release notes' "$repo_root/.github/workflows/release-preview.yml"
grep -Eq 'name: orchestrator-reviewed-release-notes' "$repo_root/.github/workflows/release-preview.yml"
for channel in cli npm 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"
@@ -177,7 +187,7 @@ grep -Eq '^v1:[0-9a-f]{64}$' <<<"$contract_fingerprint"
grep -Eq 'public postflight will still verify it' "$repo_root/.github/workflows/release-stable.yml"
for workflow in release.yml release-desktop.yml; do
grep -Eq 'name: Download orchestrator-reviewed release notes' "$repo_root/.github/workflows/$workflow"
grep -Eq 'name: stable-reviewed-release-notes' "$repo_root/.github/workflows/$workflow"
grep -Eq 'name: orchestrator-reviewed-release-notes' "$repo_root/.github/workflows/$workflow"
grep -Eq 'if: \$\{\{ !inputs\.orchestrated' "$repo_root/.github/workflows/$workflow"
done
@@ -277,6 +287,7 @@ jq -n \
tag_name: $tag,
prerelease: false,
html_url: ("https://github.com/" + $repo + "/releases/tag/" + $tag),
release_notes_url: ("https://reasonix.io/changelog/" + $tag + "/"),
assets: [
$names[] as $name |
{
@@ -634,6 +645,7 @@ write_desktop_manifest() {
{
version: $version,
download_page: "https://reasonix.io/?download=desktop#start",
release_notes_url: ("https://reasonix.io/changelog/" + $version + "/"),
platforms: {
"darwin-arm64": asset("Reasonix-darwin-arm64.zip"),
"darwin-amd64": asset("Reasonix-darwin-amd64.zip"),
@@ -782,10 +794,16 @@ git clone -q "$test_root/remote.git" "$test_root/repo"
git tag v1.2.3
git tag npm-v1.2.3
git tag -a desktop-v1.2.3 -m "desktop release"
git push -q origin v1.2.3 npm-v1.2.3 desktop-v1.2.3
git tag v1.3.0-preview.42
git push -q origin v1.2.3 npm-v1.2.3 desktop-v1.2.3 v1.3.0-preview.42
GITHUB_OUTPUT="$test_root/stable.out" RELEASE_TAG=v1.2.3 "$repo_root/scripts/resolve-stable-release.sh"
grep -Eq '^version=1\.2\.3$' "$test_root/stable.out"
grep -Eq '^desktop_tag=desktop-v1\.2\.3$' "$test_root/stable.out"
GITHUB_OUTPUT="$test_root/preview.out" RELEASE_TAG=v1.3.0-preview.42 \
"$repo_root/scripts/resolve-preview-release.sh"
grep -Eq '^version=1\.3\.0-preview\.42$' "$test_root/preview.out"
grep -Eq '^desktop_tag=desktop-v1\.3\.0-preview\.42$' "$test_root/preview.out"
grep -Eq '^npm_version=1\.3\.0-canary\.42$' "$test_root/preview.out"
approved_sha="$(git rev-parse HEAD)"
GITHUB_OUTPUT="$test_root/desktop-stable-candidate.out" \
RELEASE_CHANNEL=stable RELEASE_TAG=desktop-v1.2.3 \
@@ -832,6 +850,12 @@ git clone -q "$test_root/remote.git" "$test_root/repo"
CALLER_WORKFLOW_SHA="$approved_sha" CALLER_SHA="$approved_sha" \
APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \
"$repo_root/scripts/verify-release-authorization.sh"
ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-preview.yml@refs/heads/main-v2' \
EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-preview.yml@refs/heads/main-v2' \
CALLER_EVENT_NAME=workflow_dispatch CALLER_REF=refs/heads/main-v2 CALLER_REF_PROTECTED=true \
CALLER_WORKFLOW_SHA="$approved_sha" CALLER_SHA="$approved_sha" \
APPROVED_CHANNEL=preview APPROVED_CLI_TAG=v1.3.0-preview.42 APPROVED_SHA="$approved_sha" \
"$repo_root/scripts/verify-release-authorization.sh"
RELEASE_TAG=desktop-v1.2.3 APPROVED_SHA="$approved_sha" \
"$repo_root/scripts/verify-release-tag.sh"
@@ -1004,7 +1028,7 @@ EVENT_NAME=push IN_CHANNEL='' IN_TAG='' REF_NAME=v1.3.0-preview.42 \
grep -Eq '^tag=v1\.3\.0-preview\.42$' "$test_root/cli-preview-push.out"
grep -Eq '^version=1\.3\.0-preview\.42$' "$test_root/cli-preview-push.out"
grep -Eq '^base_version=1\.3\.0$' "$test_root/cli-preview-push.out"
grep -Eq '^notes_version=v1\.3\.0$' "$test_root/cli-preview-push.out"
grep -Eq '^notes_version=v1\.3\.0-preview\.42$' "$test_root/cli-preview-push.out"
grep -Eq '^channel=preview$' "$test_root/cli-preview-push.out"
grep -Eq '^prerelease=true$' "$test_root/cli-preview-push.out"
+1 -1
View File
@@ -36,7 +36,7 @@ esac
if [[ "$tag" =~ $preview_semver_re ]]; then
channel="preview"
notes_version="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
notes_version="$tag"
else
case "$tag" in
v*-preview*)
+6 -1
View File
@@ -27,7 +27,12 @@ if [ "${IN_CHANNEL:-stable}" = "preview" ] || [ "${IN_CHANNEL:-stable}" = "canar
echo "::error::preview base version must be MAJOR.MINOR.PATCH, got: $base" >&2
exit 1
fi
version="v${base}-preview.${RUN_NUMBER}"
preview_number="${IN_PREVIEW_NUMBER:-${RUN_NUMBER:-}}"
if [[ ! "$preview_number" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "::error::preview number must be a non-negative integer, got: $preview_number" >&2
exit 1
fi
version="v${base}-preview.${preview_number}"
tag="desktop-${version}"
channel="preview"
prerelease="true"
+6 -1
View File
@@ -15,7 +15,12 @@ if [ "${EVENT_NAME:-}" = "workflow_dispatch" ] || [ "${IN_ORCHESTRATED:-false}"
exit 1
fi
if [ "${IN_CHANNEL:-stable}" = "canary" ]; then
arg="v${base}-canary.${RUN_NUMBER:?canary requires run number}"
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
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Validate one canonical Preview release tag and emit the shared release event
# identity used by CLI, Desktop, npm, and the exact changelog entry.
set -euo pipefail
release_tag="${RELEASE_TAG:?RELEASE_TAG is required}"
release_remote="${RELEASE_REMOTE:-origin}"
if [[ ! "$release_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.([1-9][0-9]*)$ ]]; then
echo "::error::Preview release tag must be vMAJOR.MINOR.PATCH-preview.N, got: $release_tag" >&2
exit 1
fi
base_version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
preview_number="${BASH_REMATCH[4]}"
checkout_sha="$(git rev-parse HEAD^{commit})"
main_sha="$(git ls-remote "$release_remote" refs/heads/main-v2 | awk 'NR == 1 { print $1 }')"
tag_sha="$(
git ls-remote --tags "$release_remote" "refs/tags/$release_tag" "refs/tags/$release_tag^{}" |
awk '/\^\{\}$/ { print $1; found = 1; exit } NR == 1 { first = $1 } END { if (!found) print first }'
)"
if [ -z "$main_sha" ] || [ "$checkout_sha" != "$main_sha" ]; then
echo "::error::Preview control checkout must equal current $release_remote/main-v2 ($main_sha), got $checkout_sha" >&2
exit 1
fi
if [ -z "$tag_sha" ] || [ "$tag_sha" != "$main_sha" ]; then
echo "::error::$release_tag must point to current $release_remote/main-v2 ($main_sha), got ${tag_sha:-missing}" >&2
exit 1
fi
{
echo "version=${release_tag#v}"
echo "base_version=$base_version"
echo "preview_number=$preview_number"
echo "cli_tag=$release_tag"
echo "desktop_tag=desktop-v${base_version}-preview.${preview_number}"
echo "npm_version=${base_version}-canary.${preview_number}"
echo "sha=$main_sha"
} >>"${GITHUB_OUTPUT:-/dev/stdout}"
echo "Preview release resolved: ${release_tag#v} at $main_sha"
+1
View File
@@ -65,6 +65,7 @@ jq -e \
else (.prerelease == ($expected_prerelease == "true"))
end) and
(.html_url == ("https://github.com/" + $repository + "/releases/tag/" + $tag)) and
(.release_notes_url == ("https://reasonix.io/changelog/" + $tag + "/")) and
(.assets | type == "array") and
(.assets | length == ($required | length)) and
((.assets | map(.name) | sort) == ($required | sort)) and
@@ -80,6 +80,10 @@ jq -e \
(type == "object") and
(.version == $version) and
(.download_page == "https://reasonix.io/?download=desktop#start") and
(if $legacy
then (.release_notes_url == null or .release_notes_url == ("https://reasonix.io/changelog/" + $version + "/"))
else (.release_notes_url == ("https://reasonix.io/changelog/" + $version + "/"))
end) and
(.platforms | exact_keys([
"darwin-arm64",
"darwin-amd64",
+18 -3
View File
@@ -13,6 +13,7 @@ caller_workflow_sha="${CALLER_WORKFLOW_SHA:?CALLER_WORKFLOW_SHA is required}"
caller_sha="${CALLER_SHA:?CALLER_SHA is required}"
approved_cli_tag="${APPROVED_CLI_TAG:?APPROVED_CLI_TAG is required}"
approved_sha="${APPROVED_SHA:?APPROVED_SHA is required}"
approved_channel="${APPROVED_CHANNEL:-stable}"
if [ "$actual_caller" != "$expected_caller" ]; then
echo "::error::orchestrated release caller is $actual_caller, expected $expected_caller" >&2
@@ -22,8 +23,22 @@ if [ "$caller_ref_protected" != "true" ]; then
echo "::error::orchestrated release caller ref is not protected: $caller_ref" >&2
exit 1
fi
if [[ ! "$approved_cli_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::approved CLI tag must be vMAJOR.MINOR.PATCH, got: $approved_cli_tag" >&2
case "$approved_channel" in
stable)
approved_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
approved_tag_description='vMAJOR.MINOR.PATCH'
;;
preview)
approved_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.([1-9][0-9]*)$'
approved_tag_description='vMAJOR.MINOR.PATCH-preview.N'
;;
*)
echo "::error::approved release channel must be stable or preview, got: $approved_channel" >&2
exit 1
;;
esac
if [[ ! "$approved_cli_tag" =~ $approved_tag_pattern ]]; then
echo "::error::approved CLI tag must be $approved_tag_description, got: $approved_cli_tag" >&2
exit 1
fi
@@ -43,7 +58,7 @@ workflow_dispatch)
fi
;;
*)
echo "::error::unsupported stable release caller event: $caller_event" >&2
echo "::error::unsupported orchestrated release caller event: $caller_event" >&2
exit 1
;;
esac
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "astro dev",
"prebuild": "node scripts/fetch-community.mjs",
"prebuild": "node scripts/fetch-community.mjs && node scripts/fetch-publications.mjs",
"build": "astro build",
"preview": "astro preview",
"test": "node --test src/scripts/*.test.mjs"
+50
View File
@@ -0,0 +1,50 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { validateReleaseEvent } from "../../scripts/release-event.mjs";
const siteRoot = resolve(import.meta.dirname, "..");
const repoRoot = resolve(siteRoot, "..");
const catalog = JSON.parse(await readFile(resolve(repoRoot, "release-notes/releases.json"), "utf8"));
const output = resolve(siteRoot, ".generated/publications.json");
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "reasonix-site-publications",
};
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const publications = [];
for (const release of catalog.releases) {
// Legacy entries predate publication markers and remain public. Every new
// reviewed entry fails closed until its exact CLI release carries the marker
// uploaded by the all-surface postflight.
if (!release.status || release.status === "published") {
publications.push(release.version);
continue;
}
const tag = `v${release.version}`;
try {
const response = await fetch(
`https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/tags/${encodeURIComponent(tag)}`,
{ headers, signal: AbortSignal.timeout(20_000) },
);
if (!response.ok) continue;
const githubRelease = await response.json();
const marker = githubRelease.assets?.find((asset) => asset.name === "release-event.json" && asset.size > 0);
if (!marker?.browser_download_url) continue;
const markerResponse = await fetch(marker.browser_download_url, {
headers,
signal: AbortSignal.timeout(20_000),
});
if (!markerResponse.ok) continue;
const event = await markerResponse.json();
validateReleaseEvent(event, release);
publications.push(release.version);
} catch {
// A reviewed release must never become public because an upstream lookup
// failed. The next Pages build retries after the release postflight.
}
}
await mkdir(resolve(siteRoot, ".generated"), { recursive: true });
await writeFile(output, `${JSON.stringify({ schemaVersion: 1, publications }, null, 2)}\n`);
console.log(`Resolved ${publications.length} public release note(s).`);
+21 -5
View File
@@ -19,6 +19,9 @@ const prHref = (ref: number) => `${repo}/pull/${ref}`;
patch release — it gets the compact hero; anything else (including
"Reasonix 1.18.0 — Better agents") keeps the full hero. */
const isThematic = isThematicTitle(release.title.en, release.version);
const isPreview = release.channel === 'prerelease';
const channelLabel = isPreview ? { en: 'Preview', zh: '预览版' } : { en: 'Stable', zh: '稳定版' };
const latestForChannel = releases.find((item) => item.channel === release.channel);
const kindLabel = (kind: string) => ({
new: { en: 'New', zh: '新增' },
@@ -82,16 +85,22 @@ const description = release.summary.en;
<div class="release-grid">
<aside class="release-rail" aria-label="Release versions">
<div class="release-rail__eyebrow"><span class="release-rail__mark"></span><span class="l-en">Release ledger</span><span class="l-zh">版本档案</span></div>
<div class="release-channel-tabs">
<a class:list={{ active: !isPreview }} aria-current={!isPreview ? 'page' : undefined} href={`${base}/changelog/stable`}><span class="l-en">Stable</span><span class="l-zh">稳定版</span></a>
<a class:list={{ active: isPreview }} aria-current={isPreview ? 'page' : undefined} href={`${base}/changelog/preview`}><span class="l-en">Preview</span><span class="l-zh">预览版</span></a>
</div>
<nav>
{railGroups.map((group) => {
const visible = group.items.slice(0, RAIL_VISIBLE);
const overflow = group.items.slice(RAIL_VISIBLE);
const channelItems = group.items.filter((item) => item.channel === release.channel);
if (!channelItems.length) return null;
const visible = channelItems.slice(0, RAIL_VISIBLE);
const overflow = channelItems.slice(RAIL_VISIBLE);
return (
<div class="release-rail__group">
<p class="release-rail__minor">{group.key}.x</p>
{visible.map((item) => (
<a class:list={["release-version-link", { active: item.version === release.version }]} href={`${base}${releasePath(item.version)}`}>
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === releases[0] && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === latestForChannel && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
<small>{item.date}</small>
</a>
))}
@@ -103,7 +112,7 @@ const description = release.summary.en;
</summary>
{overflow.map((item) => (
<a class:list={["release-version-link", { active: item.version === release.version }]} href={`${base}${releasePath(item.version)}`}>
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === releases[0] && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
<span class="release-version-link__line"><strong>v{item.version}</strong>{item === latestForChannel && <em><span class="l-en">Latest</span><span class="l-zh">最新</span></em>}</span>
<small>{item.date}</small>
</a>
))}
@@ -119,7 +128,7 @@ const description = release.summary.en;
<article class="release-paper">
<header class:list={["release-hero", { "release-hero--compact": !isThematic }]}>
<div class="release-hero__meta">
<span class="release-channel"><span class="release-channel__dot"></span>{release.channel}</span>
<span class:list={["release-channel", { "release-channel--preview": isPreview }]}><span class="release-channel__dot"></span><span class="l-en">{channelLabel.en}</span><span class="l-zh">{channelLabel.zh}</span></span>
<time datetime={release.date}>{release.date}</time>
</div>
{isThematic && <p class="release-hero__version">Reasonix / v{release.version}</p>}
@@ -130,6 +139,13 @@ const description = release.summary.en;
return <span><span class="l-en">{label.en}</span><span class="l-zh">{label.zh}</span></span>;
})}
</div>
{release.builds && (
<div class="release-builds" aria-label="Published builds">
<span><strong>CLI</strong> {release.builds.cli}</span>
<span><strong>Desktop</strong> {release.builds.desktop}</span>
<span><strong>npm</strong> {release.builds.npm}</span>
</div>
)}
{isThematic ? (
<blockquote><span class="l-en">{release.summary.en}</span><span class="l-zh">{release.summary.zh}</span></blockquote>
) : (
+29 -2
View File
@@ -1,4 +1,6 @@
import source from '../../../release-notes/releases.json';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
export type LocalizedText = { en: string; zh: string };
@@ -17,8 +19,14 @@ export type ReleaseUpgrade = ReleaseItem & { level: 'info' | 'warning' };
export type ReleaseRecord = {
version: string;
releaseId?: string;
baseVersion?: string;
date: string;
channel: 'stable' | 'prerelease';
status?: 'reviewed' | 'published';
candidateSha?: string;
previousRelease?: string;
builds?: { cli: string; desktop: string; npm: string };
title: LocalizedText;
summary: LocalizedText;
surfaces: string[];
@@ -32,8 +40,27 @@ export type ReleaseRecord = {
};
export const releaseCatalog = source as { schemaVersion: number; releases: ReleaseRecord[] };
export const releases = releaseCatalog.releases;
export const latestRelease = releases[0];
export const allReleases = releaseCatalog.releases;
let publishedIds = new Set<string>();
try {
const generated = JSON.parse(
readFileSync(resolve(process.cwd(), '.generated/publications.json'), 'utf8'),
) as { publications?: string[] };
publishedIds = new Set(generated.publications ?? []);
} catch {
// Before prebuild, preserve legacy entries and fail closed for reviewed
// releases that require a successful all-surface publication marker.
}
export const releases = allReleases.filter(
(release) => !release.status || release.status === 'published' || publishedIds.has(release.version),
);
export const stableReleases = releases.filter((release) => release.channel === 'stable');
export const previewReleases = releases.filter((release) => release.channel === 'prerelease');
export const latestStableRelease = stableReleases[0];
export const latestPreviewRelease = previewReleases[0];
export const latestRelease = latestStableRelease ?? latestPreviewRelease;
export function releasePath(version: string): string {
return `/changelog/v${version}/`;
+3 -3
View File
@@ -1,10 +1,10 @@
---
import ChangelogPage from '../../components/ChangelogPage.astro';
import { latestRelease, releases, releasePath } from '../../data/releases';
import { latestStableRelease, releases, releasePath } from '../../data/releases';
/* /changelog/ renders the latest release but canonicalizes to its version URL
to avoid duplicate content with /changelog/vX.Y.Z/. */
const canonical = new URL(releasePath(latestRelease.version), Astro.site).href;
const canonical = new URL(releasePath(latestStableRelease.version), Astro.site).href;
---
<ChangelogPage release={latestRelease} releases={releases} canonical={canonical} />
<ChangelogPage release={latestStableRelease} releases={releases} canonical={canonical} />
+11
View File
@@ -0,0 +1,11 @@
---
import ChangelogPage from '../../components/ChangelogPage.astro';
import { latestPreviewRelease, releases, releasePath } from '../../data/releases';
if (!latestPreviewRelease) {
return Astro.redirect('/changelog/');
}
const canonical = new URL(releasePath(latestPreviewRelease.version), Astro.site).href;
---
<ChangelogPage release={latestPreviewRelease} releases={releases} canonical={canonical} />
+8
View File
@@ -0,0 +1,8 @@
---
import ChangelogPage from '../../components/ChangelogPage.astro';
import { latestStableRelease, releases, releasePath } from '../../data/releases';
const canonical = new URL(releasePath(latestStableRelease.version), Astro.site).href;
---
<ChangelogPage release={latestStableRelease} releases={releases} canonical={canonical} />
+2 -2
View File
@@ -315,7 +315,7 @@ const ld = {
</div>
</div>
</div>
<div class="dl-note"><span class="l-en">Switch channels later in Settings → Updates · <a href={`${repo}/releases`}>all releases →</a></span><span class="l-zh">安装后可在“设置 → 更新”中切换渠道 · <a href={`${repo}/releases`}>全部版本 →</a></span></div>
<div class="dl-note"><span class="l-en">Switch channels later in Settings → Updates · <a href={`${base}/changelog/stable/`} data-release-notes="desktop">release notes →</a> · <a href={`${repo}/releases`}>all releases →</a></span><span class="l-zh">安装后可在“设置 → 更新”中切换渠道 · <a href={`${base}/changelog/stable/`} data-release-notes="desktop">更新日志 →</a> · <a href={`${repo}/releases`}>全部版本 →</a></span></div>
<div class="dl-help">
<strong><span class="l-en">macOS opens with a quarantine warning?</span><span class="l-zh">macOS 提示无法打开或已损坏?</span></strong>
<span><span class="l-en">If the app was installed from the official download and placed in <code>/Applications</code>, quit Reasonix, then run:</span><span class="l-zh">如果来自官网下载并已放入 <code>/Applications</code>,先退出 Reasonix,再运行:</span></span>
@@ -355,7 +355,7 @@ const ld = {
<div class="os-options"><a href={repoReleases} data-cli-asset="reasonix-linux-arm64.tar.gz">ARM64 tar.gz</a></div>
</div>
</div>
<div class="dl-note"><span class="l-en"><a href={repoReleases} data-cli-asset="SHA256SUMS">SHA256SUMS →</a> · npm and Homebrew continue to track Stable</span><span class="l-zh"><a href={repoReleases} data-cli-asset="SHA256SUMS">SHA256SUMS →</a> · npm 与 Homebrew 继续跟随正式版</span></div>
<div class="dl-note"><span class="l-en"><a href={repoReleases} data-cli-asset="SHA256SUMS">SHA256SUMS →</a> · <a href={`${base}/changelog/stable/`} data-release-notes="cli">release notes →</a> · npm and Homebrew continue to track Stable</span><span class="l-zh"><a href={repoReleases} data-cli-asset="SHA256SUMS">SHA256SUMS →</a> · <a href={`${base}/changelog/stable/`} data-release-notes="cli">更新日志 →</a> · npm 与 Homebrew 继续跟随正式版</span></div>
</div>
<div class="dl-pane" id="download-pane-vscode" data-pane="vscode" role="tabpanel" aria-labelledby="download-tab-vscode" hidden>
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
const css = await readFile(new URL("../styles/changelog.css", import.meta.url), "utf8");
const component = await readFile(new URL("../components/ChangelogPage.astro", import.meta.url), "utf8");
test("changelog channel tabs use the shared accent hierarchy in both color schemes", () => {
assert.doesNotMatch(css, /var\(--(?:muted|paper)\)/);
assert.match(
css,
/\.release-channel-tabs a\.active\s*\{[^}]*background:\s*var\(--accent-soft\);[^}]*border-color:[^}]*var\(--accent\)[^}]*color:\s*var\(--accent-deep\);/s,
);
});
test("changelog channel navigation exposes current-page and keyboard-focus states", () => {
assert.equal((component.match(/aria-current=/g) ?? []).length, 2);
assert.match(component, /aria-current=\{!isPreview \? 'page' : undefined\}/);
assert.match(component, /aria-current=\{isPreview \? 'page' : undefined\}/);
assert.match(
css,
/\.release-channel-tabs a:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\);[^}]*outline-offset:\s*2px;/s,
);
});
+9
View File
@@ -154,12 +154,17 @@ export function cliReleaseModel(releases, requestedChannel) {
const assets = releaseAssetMap(release);
if (!assets) return null;
const releaseURL = `https://github.com/esengine/DeepSeek-Reasonix/releases/tag/${parsed.tag}`;
const exactChangelogURL = `https://reasonix.io/changelog/${parsed.tag}/`;
const changelogURL = release.release_notes_url === exactChangelogURL
? exactChangelogURL
: `https://reasonix.io/changelog/${channel}/`;
return {
channel,
version: parsed.tag,
displayVersion: parsed.tag.slice(1),
assets,
releaseURL,
changelogURL,
};
}
@@ -230,6 +235,9 @@ export function desktopReleaseModel(manifest, requestedChannel) {
version: parsed.tag,
displayVersion: parsed.tag.slice(1),
assets,
changelogURL: manifest.release_notes_url === `https://reasonix.io/changelog/${parsed.tag}/`
? manifest.release_notes_url
: `https://reasonix.io/changelog/${parsed.channel}/`,
};
}
@@ -272,6 +280,7 @@ export function desktopGitHubReleaseModel(release) {
version: match[1],
displayVersion: match[1].slice(1),
assets: Object.fromEntries(DESKTOP_ASSETS.map(([, , name]) => [name, found[name]])),
changelogURL: "https://reasonix.io/changelog/stable/",
};
}
+10 -1
View File
@@ -161,7 +161,7 @@ test("CLI selection compares arbitrarily large numeric version components exactl
assert.equal(selectCLIRelease(releases, "stable")?.tag_name, "v100000000000000000000.0.0");
});
test("CLI release notes always use the canonical repository tag URL", () => {
test("CLI release links are derived from the validated canonical tag", () => {
const release = {
tag_name: "v1.18.0",
prerelease: false,
@@ -172,6 +172,12 @@ test("CLI release notes always use the canonical repository tag URL", () => {
cliReleaseModel([release], "stable")?.releaseURL,
"https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.18.0",
);
assert.equal(
cliReleaseModel([release], "stable")?.changelogURL,
"https://reasonix.io/changelog/stable/",
);
release.release_notes_url = "https://reasonix.io/changelog/v1.18.0/";
assert.equal(cliReleaseModel([release], "stable")?.changelogURL, release.release_notes_url);
});
test("CLI assets reject spoofed hosts and cross-tag URLs", () => {
@@ -226,6 +232,9 @@ test("Desktop manifests accept only the exact official Stable and Preview bases"
assert.equal(desktopReleaseModel(preview, "stable"), null);
const model = desktopReleaseModel(preview, "preview");
assert.equal(model?.displayVersion, "1.18.0-preview.62");
assert.equal(model?.changelogURL, "https://reasonix.io/changelog/preview/");
preview.release_notes_url = "https://reasonix.io/changelog/v1.18.0-preview.62/";
assert.equal(desktopReleaseModel(preview, "preview")?.changelogURL, preview.release_notes_url);
assert.equal(model?.assets["Reasonix-darwin-universal.dmg"],
"https://dl.reasonix.io/desktop-v1.18.0-preview.62/Reasonix-darwin-universal.dmg");
assert.equal(model?.assets["Reasonix-windows-amd64.zip"],
+4
View File
@@ -270,6 +270,10 @@ import { initTheme } from "./theme.js";
document.querySelectorAll('[data-release-summary="' + surface + '"] [data-channel-copy]').forEach((copy) => {
copy.hidden = copy.dataset.channelCopy !== channel;
});
document.querySelectorAll('[data-release-notes="' + surface + '"]').forEach((link) => {
const path = model?.changelogURL ? new URL(model.changelogURL).pathname : "changelog/" + channel + "/";
link.href = new URL(path, window.location.origin + "/").href;
});
document.querySelectorAll('[data-release-switch="' + surface + '"] [data-release-channel]').forEach((button) => {
const active = button.dataset.releaseChannel === channel;
button.classList.toggle("active", active);
+50 -1
View File
@@ -409,6 +409,55 @@ body {
.release-footer > span { order: 3; width: 100%; margin: 0; }
}
.release-channel-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin: 14px 0 18px;
}
.release-channel-tabs a {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-2);
padding: 7px 10px;
text-align: center;
text-decoration: none;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.release-channel-tabs a:hover {
border-color: color-mix(in oklch, var(--accent) 40%, var(--line));
color: var(--accent-deep);
}
.release-channel-tabs a.active {
background: var(--accent-soft);
border-color: color-mix(in oklch, var(--accent) 46%, var(--line));
color: var(--accent-deep);
}
.release-channel-tabs a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.release-builds {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.release-builds span {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-2);
font-family: var(--mono);
font-size: 0.78rem;
padding: 6px 10px;
}
@media (prefers-reduced-motion: reduce) {
.release-version-link, .release-guide { transition: none; }
.release-version-link, .release-guide, .release-channel-tabs a { transition: none; }
}