mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
e498c4a796
commit
b6da01dbdc
41 files changed
+954
-105
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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] |
|
||||
|
||||
@@ -72,3 +72,4 @@ temp/
|
||||
scripts/official-theme-art/out/
|
||||
scripts/official-theme-art/__pycache__/
|
||||
agent-complexity-report.txt
|
||||
site/.generated/
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 的正式发布审批人和响应流程已经明确
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Executable
+80
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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*)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+42
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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).`);
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}/`;
|
||||
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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/",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user