diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 880535b2a9..e4e4d04b6c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e56475b0f..93d949b777 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,6 +398,7 @@ jobs: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 \ -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 \ diff --git a/.github/workflows/prepare-release-notes.yml b/.github/workflows/prepare-release-notes.yml index cf67ec2aa2..39da9da953 100644 --- a/.github/workflows/prepare-release-notes.yml +++ b/.github/workflows/prepare-release-notes.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: diff --git a/.github/workflows/release-cli-trigger.yml b/.github/workflows/release-cli-trigger.yml index d91dc73926..6a6d72bfbb 100644 --- a/.github/workflows/release-cli-trigger.yml +++ b/.github/workflows/release-cli-trigger.yml @@ -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 }, }); diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index dc18eb373f..0d0853548f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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 diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 29e7d819f9..428cc14a7b 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -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: diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 0000000000..961c854a60 --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -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 diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index b14893a940..bab8cff63f 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 566720537a..cc5b310964 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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] | diff --git a/.gitignore b/.gitignore index 0286b67ad5..e9afe86d15 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ temp/ scripts/official-theme-art/out/ scripts/official-theme-art/__pycache__/ agent-complexity-report.txt +site/.generated/ diff --git a/.signpath/contracts/release-signing.yml b/.signpath/contracts/release-signing.yml index bfbff6b85c..da64566b3f 100644 --- a/.signpath/contracts/release-signing.yml +++ b/.signpath/contracts/release-signing.yml @@ -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 diff --git a/desktop/cmd/sign/main.go b/desktop/cmd/sign/main.go index 5dfb8e7d52..d5eb1c0918 100644 --- a/desktop/cmd/sign/main.go +++ b/desktop/cmd/sign/main.go @@ -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 { diff --git a/desktop/cmd/sign/main_test.go b/desktop/cmd/sign/main_test.go index 0bda6a57b9..3012f2d912 100644 --- a/desktop/cmd/sign/main_test.go +++ b/desktop/cmd/sign/main_test.go @@ -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) } diff --git a/desktop/internal/update/manifest.go b/desktop/internal/update/manifest.go index d2462c99b7..099af42219 100644 --- a/desktop/internal/update/manifest.go +++ b/desktop/internal/update/manifest.go @@ -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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9dbe3d10e3..c56772202d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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 diff --git a/docs/SIGNPATH_WINDOWS_ADMIN_SOP.md b/docs/SIGNPATH_WINDOWS_ADMIN_SOP.md index 738b9d0884..838b414e38 100644 --- a/docs/SIGNPATH_WINDOWS_ADMIN_SOP.md +++ b/docs/SIGNPATH_WINDOWS_ADMIN_SOP.md @@ -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 的正式发布审批人和响应流程已经明确 diff --git a/scripts/generate-release-notes.mjs b/scripts/generate-release-notes.mjs index 88e6b845e1..18413e3ea8 100644 --- a/scripts/generate-release-notes.mjs +++ b/scripts/generate-release-notes.mjs @@ -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))); diff --git a/scripts/release-event.mjs b/scripts/release-event.mjs new file mode 100755 index 0000000000..7306069115 --- /dev/null +++ b/scripts/release-event.mjs @@ -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; + }); +} diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs index 891c8935f6..aaae08699c 100644 --- a/scripts/release-notes.mjs +++ b/scripts/release-notes.mjs @@ -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)`, diff --git a/scripts/release-notes.test.mjs b/scripts/release-notes.test.mjs index 0f16fafe20..81d7d45eff 100644 --- a/scripts/release-notes.test.mjs +++ b/scripts/release-notes.test.mjs @@ -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/, + ); +}); diff --git a/scripts/release-workflows.test.sh b/scripts/release-workflows.test.sh index 317b55c1f7..1f7418dd04 100755 --- a/scripts/release-workflows.test.sh +++ b/scripts/release-workflows.test.sh @@ -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" diff --git a/scripts/resolve-cli-release.sh b/scripts/resolve-cli-release.sh index 5c07a36139..0761e60310 100644 --- a/scripts/resolve-cli-release.sh +++ b/scripts/resolve-cli-release.sh @@ -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*) diff --git a/scripts/resolve-desktop-release.sh b/scripts/resolve-desktop-release.sh index b5847fdafe..b8d223e4fc 100644 --- a/scripts/resolve-desktop-release.sh +++ b/scripts/resolve-desktop-release.sh @@ -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" diff --git a/scripts/resolve-npm-release.sh b/scripts/resolve-npm-release.sh index 12c9edd5d2..c2d27d7004 100755 --- a/scripts/resolve-npm-release.sh +++ b/scripts/resolve-npm-release.sh @@ -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 diff --git a/scripts/resolve-preview-release.sh b/scripts/resolve-preview-release.sh new file mode 100755 index 0000000000..fdc1efa646 --- /dev/null +++ b/scripts/resolve-preview-release.sh @@ -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" diff --git a/scripts/validate-cli-release-manifest.sh b/scripts/validate-cli-release-manifest.sh index 8d579a1fc3..3ca5ec37e2 100755 --- a/scripts/validate-cli-release-manifest.sh +++ b/scripts/validate-cli-release-manifest.sh @@ -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 diff --git a/scripts/validate-desktop-release-manifest.sh b/scripts/validate-desktop-release-manifest.sh index b2e76f4531..ed43aa7439 100755 --- a/scripts/validate-desktop-release-manifest.sh +++ b/scripts/validate-desktop-release-manifest.sh @@ -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", diff --git a/scripts/verify-release-authorization.sh b/scripts/verify-release-authorization.sh index 01340bf5da..61490558cf 100755 --- a/scripts/verify-release-authorization.sh +++ b/scripts/verify-release-authorization.sh @@ -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 diff --git a/site/package.json b/site/package.json index 531fc67342..7d73919e56 100644 --- a/site/package.json +++ b/site/package.json @@ -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" diff --git a/site/scripts/fetch-publications.mjs b/site/scripts/fetch-publications.mjs new file mode 100644 index 0000000000..e9c2bd8c9d --- /dev/null +++ b/site/scripts/fetch-publications.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).`); diff --git a/site/src/components/ChangelogPage.astro b/site/src/components/ChangelogPage.astro index 9406658d60..a0239ff992 100644 --- a/site/src/components/ChangelogPage.astro +++ b/site/src/components/ChangelogPage.astro @@ -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;
-
Switch channels later in Settings → Updates · all releases →安装后可在“设置 → 更新”中切换渠道 · 全部版本 →
+
Switch channels later in Settings → Updates · release notes → · all releases →安装后可在“设置 → 更新”中切换渠道 · 更新日志 → · 全部版本 →
macOS opens with a quarantine warning?macOS 提示无法打开或已损坏? If the app was installed from the official download and placed in /Applications, quit Reasonix, then run:如果来自官网下载并已放入 /Applications,先退出 Reasonix,再运行: @@ -355,7 +355,7 @@ const ld = {
ARM64 tar.gz
-
SHA256SUMS → · npm and Homebrew continue to track StableSHA256SUMS → · npm 与 Homebrew 继续跟随正式版
+
SHA256SUMS → · release notes → · npm and Homebrew continue to track StableSHA256SUMS → · 更新日志 → · npm 与 Homebrew 继续跟随正式版