diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index da040fe..63e3db7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,8 @@ name: CI
# Fast pull-request gate: type-check, unit tests and a cross-platform Rust
# clippy/test pass. The heavy signed/notarized bundle build stays in
-# release.yml (tags only) — this never builds or signs the .app.
+# the default-branch release.yml (signalled by v* tags) — this never builds or
+# signs the .app.
on:
pull_request:
push:
@@ -65,6 +66,70 @@ jobs:
run: cargo clippy --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings
- name: Test (src-tauri)
run: cargo test --manifest-path src-tauri/Cargo.toml --workspace
+ - name: Assert vendored tauri updater is active
+ shell: bash
+ run: |
+ set -euo pipefail
+ metadata_file="$(mktemp)"
+ trap 'rm -f "$metadata_file"' EXIT
+ cargo metadata --locked --format-version 1 \
+ --manifest-path src-tauri/Cargo.toml > "$metadata_file"
+ node --input-type=module - "$metadata_file" "$GITHUB_WORKSPACE" <<'NODE'
+ import { readFileSync, realpathSync } from "node:fs";
+ import { resolve } from "node:path";
+
+ const metadata = JSON.parse(readFileSync(process.argv[2], "utf8"));
+ const workspace = realpathSync(process.argv[3]);
+ const expectedManifest = realpathSync(
+ resolve(workspace, "vendor/tauri-plugin-updater-2.10.1/Cargo.toml"),
+ );
+ const updaterPackages = metadata.packages.filter(
+ ({ name }) => name === "tauri-plugin-updater",
+ );
+ if (updaterPackages.length !== 1) {
+ throw new Error(
+ `expected exactly one tauri-plugin-updater package, found ${updaterPackages.length}`,
+ );
+ }
+ const [updater] = updaterPackages;
+ if (updater.version !== "2.10.1") {
+ throw new Error(`expected tauri-plugin-updater 2.10.1, found ${updater.version}`);
+ }
+ if (updater.source !== null) {
+ throw new Error(`tauri-plugin-updater resolved from registry source ${updater.source}`);
+ }
+ if (realpathSync(updater.manifest_path) !== expectedManifest) {
+ throw new Error(
+ `tauri-plugin-updater resolved from ${updater.manifest_path}, expected ${expectedManifest}`,
+ );
+ }
+
+ const app = metadata.packages.find(
+ ({ manifest_path: manifestPath, name }) =>
+ name === "codex-app-manager" &&
+ realpathSync(manifestPath) ===
+ realpathSync(resolve(workspace, "src-tauri/Cargo.toml")),
+ );
+ const updaterDeclarations =
+ app?.dependencies.filter(({ name }) => name === "tauri-plugin-updater") ?? [];
+ if (
+ updaterDeclarations.length !== 1 ||
+ updaterDeclarations[0].req !== "=2.10.1"
+ ) {
+ throw new Error("codex-app-manager must pin tauri-plugin-updater to =2.10.1");
+ }
+ const appNode = metadata.resolve?.nodes.find(({ id }) => id === app?.id);
+ const updaterEdges =
+ appNode?.deps.filter(({ name }) => name === "tauri_plugin_updater") ?? [];
+ if (updaterEdges.length !== 1 || updaterEdges[0].pkg !== updater.id) {
+ throw new Error("codex-app-manager does not resolve the expected vendored updater");
+ }
+ console.log(`verified vendored tauri updater: ${updater.manifest_path}`);
+ NODE
+ # The updater is a path-patched dependency, so its response-limit tests
+ # are not included in the application workspace test command above.
+ - name: Test vendored tauri updater bounds
+ run: cargo test --locked --manifest-path vendor/tauri-plugin-updater-2.10.1/Cargo.toml --lib
# Path deps are not workspace members, so their unit/integration tests are
# not covered by the step above. Run each engine crate explicitly so
# mac/win pure-logic regressions fail required CI on every PR.
diff --git a/.github/workflows/release-source.yml b/.github/workflows/release-source.yml
new file mode 100644
index 0000000..70c893f
--- /dev/null
+++ b/.github/workflows/release-source.yml
@@ -0,0 +1,33 @@
+name: Release source
+
+# This workflow is only an unprivileged signal. Tag workflows are loaded from
+# the tagged commit, so this file must never receive an environment, secrets,
+# write permissions, checkout, or artifacts. The credentialed Release workflow
+# is triggered with workflow_run and is therefore loaded from the default branch.
+on:
+ push:
+ tags: ["v*"]
+
+permissions: {}
+
+jobs:
+ signal:
+ runs-on: ubuntu-latest
+ permissions: {}
+ steps:
+ - name: Validate release signal shape
+ shell: bash
+ env:
+ RELEASE_TAG: ${{ github.ref_name }}
+ RELEASE_SOURCE_SHA: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
+ echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG"
+ exit 1
+ fi
+ if [[ ! "$RELEASE_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "::error::release source must be a full commit SHA"
+ exit 1
+ fi
+ echo "Unprivileged release signal accepted for $RELEASE_TAG at $RELEASE_SOURCE_SHA"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 64e88d4..aa4e962 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,11 +1,14 @@
name: Release
-# Tag-driven cross-platform release:
+# Default-branch, tag-signalled cross-platform release:
# build (unsigned) → macOS finalize (adaptive icon + inside-out Developer ID
# sign + notarize + staple + repackage updater/dmg) → collect → publish a
# GitHub Release with the Tauri updater manifest (latest.json).
+# Windows jobs currently fail closed at the SignPath readiness gate, so the
+# dependent publish job cannot release a partial macOS-only set or unsigned
+# Windows artifacts until the approved trusted-build integration replaces it.
#
-# Required repo secrets (Settings ▸ Secrets and variables ▸ Actions / release env):
+# Required `release` environment secrets (never repository/org secrets):
# APPLE_CERTIFICATE base64 of your "Developer ID Application" .p12
# APPLE_CERTIFICATE_PASSWORD password for that .p12
# APPLE_SIGNING_IDENTITY "Developer ID Application: NAME (TEAMID)"
@@ -15,29 +18,304 @@ name: Release
# AC_API_KEY_BASE64 base64 of the AuthKey_XXXX.p8
# TAURI_SIGNING_PRIVATE_KEY updater private key (string)
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password (empty if none)
+# IMMUTABLE_RELEASES_READ_TOKEN fine-grained token with repository
+# Administration: read-only
+# MANAGER_R2_S3_ENDPOINT / MANAGER_R2_PROMOTION_ACCESS_KEY_ID /
+# MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY
+# R2 S3-compatible read/write access
+# vars.MANAGER_IHEP_S3_ENDPOINT / vars.MANAGER_IHEP_S3_BUCKET /
+# vars.MANAGER_IHEP_S3_REGION / vars.MANAGER_IHEP_S3_PREFIX
+# MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID /
+# MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY
+# IHEP S3-compatible read/write access
+# Both mirror backends are required for stable publication and must already
+# contain a valid latest.json baseline. R2 is the sole CAS authority and must
+# enforce conditional PutObject plus HeadObject metadata round trips. IHEP is an
+# unconditional single-writer follower and only needs PutObject + metadata
+# round trips; promotion never relies on its ignored conditional headers.
+# GitHub Immutable Releases must be enabled before any release starts; the
+# workflow verifies the repository setting with IMMUTABLE_RELEASES_READ_TOKEN.
+# Separate active tag rulesets must restrict refs/tags/v* creation to the named
+# release publisher and forbid every update/deletion; the same read-only token
+# verifies those policies and the live peeled SHA.
+# Every reused asset is also pinned to the API-provided sha256 digest.
+# Configure the `release` environment for the protected default branch only;
+# never allow v* tags. A tagged revision may change release-source.yml, but it
+# cannot claim this environment and the signal itself has no credentials.
+# The old MANAGER_*_ACCESS_KEY_ID / SECRET_ACCESS_KEY names must stay deleted;
+# otherwise a historical workflow revision could still perform unconditional writes.
#
-# Optional Windows Authenticode (non-blocking until configured; see docs/windows-signing.md):
-# WINDOWS_CERTIFICATE base64 of OV/EV code-signing .pfx
-# WINDOWS_CERTIFICATE_PASSWORD password for that .pfx
-# vars.AUTHENTICODE_REQUIRED set to "true" to fail release when PE is unsigned
-# vars.WINDOWS_TIMESTAMP_URL optional RFC3161 timestamp URL override
+# Windows Authenticode status and migration contract:
+# docs/code-signing-policy.md + docs/windows-signing.md
+# Do not add PFX secrets. SignPath Foundation approval and trusted-build
+# integration are pending; scripts/assert-signpath-foundation-ready.ps1 blocks
+# Windows tag jobs until a separately reviewed implementation replaces it.
on:
- push:
- tags: ["v*"]
+ # `workflow_run` always loads this credentialed workflow from the repository
+ # default branch. Never put credentials in release-source.yml: tag-triggered
+ # workflow definitions are loaded from the tagged commit.
+ workflow_run:
+ workflows: ["Release source"]
+ types: [completed]
workflow_dispatch:
+ inputs:
+ target_tag:
+ description: "Existing vX.Y.Z release to republish through the current protected workflow"
+ required: true
+ type: string
+ allow_mirror_downgrade:
+ description: "Emergency only: allow this tag to replace a newer mirror latest.json"
+ required: false
+ default: false
+ type: boolean
+ mirror_downgrade_reason:
+ description: "Required audit reason when emergency mirror downgrade is enabled"
+ required: false
+ default: ""
+ type: string
permissions:
contents: read
+env:
+ AUTHORIZED_RELEASE_ACTOR_LOGIN: Wangnov
+ AUTHORIZED_RELEASE_ACTOR_ID: "48670012"
+ RELEASE_TAG: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || inputs.target_tag }}
+ RELEASE_SOURCE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || '' }}
+ TRUSTED_RELEASE_INVOCATION: ${{ github.event_name == 'workflow_run' && 'tag-signal' || 'manual-recovery' }}
+
+# Serialize every credentialed mirror writer through one release lane. This is a
+# correctness boundary for the unconditional IHEP follower, not an optimization.
+# R2 CAS plus pre/post ownership fencing handles a losing or accidentally
+# overlapping writer, but there is no cross-provider atomic transaction.
+concurrency:
+ group: release-latest-${{ github.repository }}
+ cancel-in-progress: false
+ queue: max
+
jobs:
+ # This is the only path from the unprivileged tag signal to credentialed jobs.
+ # It deliberately has no checkout, environment, write permission, or secrets.
+ # Every field is copied through the environment so event data is never
+ # interpolated into shell source.
+ preflight:
+ runs-on: ubuntu-latest
+ permissions: {}
+ outputs:
+ release_tag: ${{ steps.validate.outputs.release_tag }}
+ release_source_sha: ${{ steps.validate.outputs.release_source_sha }}
+ trusted_invocation: ${{ steps.validate.outputs.trusted_invocation }}
+ steps:
+ - name: Validate release invocation without secrets
+ id: validate
+ shell: bash
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
+ DISPATCH_REF: ${{ github.ref }}
+ DISPATCH_ACTOR_LOGIN: ${{ github.actor }}
+ DISPATCH_ACTOR_ID: ${{ github.actor_id }}
+ DISPATCH_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
+ EVENT_NAME: ${{ github.event_name }}
+ REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }}
+ UPSTREAM_ACTOR_LOGIN: ${{ github.event.workflow_run.actor.login || '' }}
+ UPSTREAM_ACTOR_ID: ${{ github.event.workflow_run.actor.id || '' }}
+ UPSTREAM_CONCLUSION: ${{ github.event.workflow_run.conclusion || '' }}
+ UPSTREAM_EVENT: ${{ github.event.workflow_run.event || '' }}
+ UPSTREAM_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch || '' }}
+ UPSTREAM_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name || '' }}
+ UPSTREAM_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }}
+ UPSTREAM_PATH: ${{ github.event.workflow_run.path || '' }}
+ UPSTREAM_REPOSITORY: ${{ github.event.workflow_run.repository.full_name || '' }}
+ UPSTREAM_TRIGGERING_ACTOR_LOGIN: ${{ github.event.workflow_run.triggering_actor.login || '' }}
+ UPSTREAM_TRIGGERING_ACTOR_ID: ${{ github.event.workflow_run.triggering_actor.id || '' }}
+ UPSTREAM_WORKFLOW_NAME: ${{ github.event.workflow_run.name || '' }}
+ run: |
+ set -euo pipefail
+ [[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
+ echo "::error::release workflow rerun actor is not the authorized publisher"
+ exit 1
+ }
+ if [[ "$EVENT_NAME" == "workflow_run" ]]; then
+ [[ "$UPSTREAM_WORKFLOW_NAME" == "Release source" ]] || {
+ echo "::error::unexpected upstream workflow: $UPSTREAM_WORKFLOW_NAME"
+ exit 1
+ }
+ [[ "$UPSTREAM_PATH" == ".github/workflows/release-source.yml" ]] || {
+ echo "::error::unexpected upstream workflow path: $UPSTREAM_PATH"
+ exit 1
+ }
+ [[ "$UPSTREAM_EVENT" == "push" && "$UPSTREAM_CONCLUSION" == "success" ]] || {
+ echo "::error::release source must be a successful push workflow"
+ exit 1
+ }
+ [[ "$UPSTREAM_REPOSITORY" == "$GITHUB_REPOSITORY" && "$UPSTREAM_HEAD_REPOSITORY" == "$GITHUB_REPOSITORY" ]] || {
+ echo "::error::release source must originate in $GITHUB_REPOSITORY"
+ exit 1
+ }
+ [[ "$UPSTREAM_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" && "$UPSTREAM_ACTOR_ID" == "$AUTHORIZED_RELEASE_ACTOR_ID" ]] || {
+ echo "::error::release source actor is not the authorized publisher"
+ exit 1
+ }
+ [[ "$UPSTREAM_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" && "$UPSTREAM_TRIGGERING_ACTOR_ID" == "$AUTHORIZED_RELEASE_ACTOR_ID" ]] || {
+ echo "::error::release source rerun actor is not the authorized publisher"
+ exit 1
+ }
+ [[ "$UPSTREAM_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || {
+ echo "::error::release source head_sha must be a full commit SHA"
+ exit 1
+ }
+ RELEASE_TAG="$UPSTREAM_HEAD_BRANCH"
+ RELEASE_SOURCE_SHA="$UPSTREAM_HEAD_SHA"
+ TRUSTED_RELEASE_INVOCATION="tag-signal"
+ elif [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
+ [[ "$DISPATCH_REF" == "refs/heads/$DEFAULT_BRANCH" ]] || {
+ echo "::error::workflow_dispatch must run from refs/heads/$DEFAULT_BRANCH, not $DISPATCH_REF"
+ exit 1
+ }
+ [[ "$DISPATCH_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" && "$DISPATCH_ACTOR_ID" == "$AUTHORIZED_RELEASE_ACTOR_ID" && "$DISPATCH_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
+ echo "::error::workflow_dispatch actor is not the authorized publisher"
+ exit 1
+ }
+ [[ -n "$REQUESTED_TARGET_TAG" ]] || {
+ echo "::error::workflow_dispatch requires a non-empty target_tag"
+ exit 1
+ }
+ RELEASE_TAG="$REQUESTED_TARGET_TAG"
+ RELEASE_SOURCE_SHA=""
+ TRUSTED_RELEASE_INVOCATION="manual-recovery"
+ else
+ echo "::error::unsupported release event: $EVENT_NAME"
+ exit 1
+ fi
+ if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
+ echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG"
+ exit 1
+ fi
+ printf 'release_tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
+ printf 'release_source_sha=%s\n' "$RELEASE_SOURCE_SHA" >> "$GITHUB_OUTPUT"
+ printf 'trusted_invocation=%s\n' "$TRUSTED_RELEASE_INVOCATION" >> "$GITHUB_OUTPUT"
+
+ prepare:
+ needs: preflight
+ if: ${{ needs.preflight.result == 'success' }}
+ runs-on: ubuntu-latest
+ environment: release
+ permissions:
+ contents: read
+ outputs:
+ release_reusable: ${{ steps.lookup.outputs.release_reusable }}
+ release_asset_digests: ${{ steps.lookup.outputs.release_asset_digests }}
+ release_source_sha: ${{ steps.tag_source.outputs.release_source_sha }}
+ steps:
+ - name: Authorize credentialed job rerun actor
+ shell: bash
+ env:
+ CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
+ run: |
+ set -euo pipefail
+ [[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
+ echo "::error::credentialed release job rerun actor is not the authorized publisher"
+ exit 1
+ }
+
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ node-version: 20
+
+ # Resolve the live tag through the API before any tag checkout. A
+ # workflow_run must still point at the exact SHA carried by the successful
+ # signal; an authorized manual recovery may resolve the immutable tag here.
+ - name: Resolve protected immutable release tag
+ id: tag_source
+ env:
+ ALLOW_RELEASE_SHA_RESOLUTION: ${{ needs.preflight.outputs.trusted_invocation == 'manual-recovery' && '1' || '0' }}
+ GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
+ RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.release_source_sha }}
+ run: node scripts/check-release-tag-protection.mjs "$RELEASE_TAG" "$RELEASE_SOURCE_SHA"
+
+ # The root checkout contains only the trusted default-branch workflow and
+ # release scripts. Check out application source separately by the already
+ # verified commit SHA, never by a mutable tag name.
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ steps.tag_source.outputs.release_source_sha }}
+ path: release-source
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Match release tag to every application version declaration
+ shell: bash
+ env:
+ EXPECTED_RELEASE_SOURCE_SHA: ${{ steps.tag_source.outputs.release_source_sha }}
+ run: |
+ set -euo pipefail
+ node scripts/check-release-version.mjs source "$RELEASE_TAG" release-source
+ release_source_sha="$(git -C release-source rev-parse HEAD)"
+ if [[ "$release_source_sha" != "$EXPECTED_RELEASE_SOURCE_SHA" ]]; then
+ echo "::error::release checkout differs from verified commit $EXPECTED_RELEASE_SOURCE_SHA"
+ exit 1
+ fi
+ echo "RELEASE_SOURCE_SHA=$release_source_sha" >> "$GITHUB_ENV"
+
+ - name: Require release source merged into live default branch
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ VERIFIED_RELEASE_SOURCE_SHA: ${{ steps.tag_source.outputs.release_source_sha }}
+ run: bash scripts/check-release-source-ancestor.sh release-source "$VERIFIED_RELEASE_SOURCE_SHA" "$DEFAULT_BRANCH"
+
+ - name: Require GitHub Immutable Releases
+ env:
+ GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
+ run: node scripts/check-immutable-releases.mjs
+
+ - name: Check for an existing immutable release
+ id: lookup
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }}
+ run: |
+ set -euo pipefail
+ if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
+ echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG"
+ exit 1
+ fi
+ lookup_error="$RUNNER_TEMP/release-lookup-error.txt"
+ release_json="$RUNNER_TEMP/release.json"
+ if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$release_json" 2>"$lookup_error"; then
+ REQUESTED_TARGET_TAG="$REQUESTED_TARGET_TAG" \
+ node scripts/check-release-reuse.mjs "$RELEASE_TAG" "$release_json"
+ elif grep -q 'HTTP 404' "$lookup_error"; then
+ if [[ -n "$REQUESTED_TARGET_TAG" ]]; then
+ echo "::error::target_tag $REQUESTED_TARGET_TAG must already exist as a GitHub Release"
+ exit 1
+ fi
+ echo "release_reusable=false" >> "$GITHUB_OUTPUT"
+ else
+ cat "$lookup_error" >&2
+ echo "::error::could not determine whether release $RELEASE_TAG already exists"
+ exit 1
+ fi
+
build:
- if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
- # Repo admins should configure the release environment and copy signing
- # secrets there before moving them off repo-level Actions secrets.
+ needs: [preflight, prepare]
+ if: ${{ needs.prepare.result == 'success' && needs.prepare.outputs.release_reusable != 'true' && needs.preflight.outputs.trusted_invocation == 'tag-signal' }}
+ # All signing secrets live only in the default-branch-restricted release
+ # environment. Never duplicate them as repository or organization secrets.
environment: release
+ env:
+ RELEASE_SOURCE_SHA: ${{ needs.prepare.outputs.release_source_sha }}
permissions:
contents: read
+ defaults:
+ run:
+ working-directory: release-source
strategy:
fail-fast: false
matrix:
@@ -46,26 +324,84 @@ jobs:
# Intel build on GitHub's current x86_64 image — macos-13 retired
# 2025-12; macos-15-intel is the last Intel image (until ~2027-08).
- { platform: macos-15-intel, target: x86_64-apple-darwin, os: macos }
- - { platform: windows-latest, target: x86_64-pc-windows-msvc, os: windows }
- - { platform: windows-latest, target: aarch64-pc-windows-msvc, os: windows }
+ - {
+ platform: windows-latest,
+ target: x86_64-pc-windows-msvc,
+ os: windows,
+ }
+ - {
+ platform: windows-latest,
+ target: aarch64-pc-windows-msvc,
+ os: windows,
+ }
runs-on: ${{ matrix.platform }}
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Authorize credentialed job rerun actor
+ shell: bash
+ # Job-level defaults point later build commands at release-source, but
+ # that checkout does not exist until the following actions run.
+ working-directory: ${{ github.workspace }}
+ env:
+ CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
+ run: |
+ set -euo pipefail
+ [[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
+ echo "::error::credentialed release job rerun actor is not the authorized publisher"
+ exit 1
+ }
+
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ needs.prepare.outputs.release_source_sha }}
+ path: release-source
+ persist-credentials: false
+ fetch-depth: 0
+
+ # A successful prepare may belong to an earlier attempt. Re-fetch the live
+ # default branch before executing application-controlled build code or
+ # exposing signing credentials to later steps.
+ - name: Re-check release source before build
+ shell: bash
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ [[ "$(git rev-parse HEAD)" == "$RELEASE_SOURCE_SHA" ]] || {
+ echo "::error::release checkout differs from verified commit $RELEASE_SOURCE_SHA"
+ exit 1
+ }
+ node ../scripts/check-release-version.mjs source "$RELEASE_TAG" .
+ bash ../scripts/check-release-source-ancestor.sh . "$RELEASE_SOURCE_SHA" "$DEFAULT_BRANCH"
+
+ # Intentionally has no bypass or success path. The Windows matrix failure
+ # prevents the dependent publish job from releasing a partial macOS-only
+ # set or silently shipping unsigned Windows artifacts. Keep the guard in
+ # the trusted default-branch checkout, not the tag source. A future
+ # reviewed change must replace this step with the approved SignPath
+ # trusted-build request, approval, download, and verification flow.
+ - name: Assert SignPath Foundation readiness (fail closed)
+ if: matrix.os == 'windows'
+ shell: pwsh
+ run: |
+ & ..\scripts\assert-signpath-foundation-ready.ps1
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
cache: npm
+ cache-dependency-path: release-source/package-lock.json
- - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.target }}
- - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
- workspaces: src-tauri -> target
+ workspaces: release-source/src-tauri -> target
- run: npm ci
@@ -95,8 +431,11 @@ jobs:
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KC"
security list-keychains -d user -s "$KC" $(security list-keychains -d user | sed s/\"//g)
- # ── Build WITHOUT a tauri signingIdentity; we sign inside-out after ────
- - name: Build (Tauri, unsigned)
+ # Build without an Authenticode configuration. The fail-closed readiness
+ # step above makes this unreachable for Windows today; even if that guard
+ # were removed alone, the required signature gates below would reject the
+ # unsigned output. Do not infer a two-pass SignPath design from this order.
+ - name: Build (Tauri, unsigned before platform finalization)
shell: bash
# Bundling downloads toolchains on the fly (e.g. the NSIS archive from
# GitHub) which occasionally 504s and fails the whole release. Retry a
@@ -104,7 +443,7 @@ jobs:
# output is cached, so a retry mostly just re-runs the bundling step.
run: |
for attempt in 1 2 3; do
- npm run tauri build -- --target ${{ matrix.target }} && exit 0
+ npm run tauri build -- --target "${{ matrix.target }}" && exit 0
echo "::warning::tauri build attempt ${attempt} failed (often a transient NSIS/toolchain download, e.g. HTTP 504) — retrying in 20s"
sleep 20
done
@@ -168,43 +507,46 @@ jobs:
& .\scripts\windows-pe-arch.ps1 -Path @($setup | ForEach-Object { $_.FullName })
}
if ($target -like "aarch64-*") {
- Write-Host "::notice::ARM64 main PE asserted machine=0xAA64 (cross-build on x64). Full install/launch smoke remains x64-only until ARM64 runners or trusted virtualization is available."
+ Write-Host "::notice::ARM64 main PE asserted machine=0xAA64 (cross-build on x64). Install/signature/upgrade/uninstall smoke runs below; only native ARM64 app launch still needs ARM64 hardware or trusted virtualization."
}
- # ── Windows: optional Authenticode (OV/EV) on final NSIS installer ────
- # Non-blocking until WINDOWS_CERTIFICATE is configured in the release
- # environment. When present, signs the published -setup.exe. Full
- # inside-out signing of main binary + uninstaller should eventually
- # move into tauri build via certificateThumbprint (docs/windows-signing.md).
- # This is independent of the Tauri updater minisign key below.
- - name: Authenticode-sign Windows installer (optional)
+ # ── Windows: future SignPath Authenticode verification contract ──────
+ # Tauri restores target/.../release/codex-app-manager.exe to its
+ # unsigned, unpatched build input after bundling, so only the outer
+ # installer is inspectable here. Packaged smoke installs each target and
+ # verifies the signed embedded main executable and uninstall.exe. These
+ # steps are intentionally unreachable until the readiness blocker is
+ # replaced by an approved integration; unsigned output still fails.
+ - name: Verify Windows installer Authenticode (required)
if: matrix.os == 'windows'
shell: pwsh
- env:
- WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
- WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
- WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }}
run: |
- $setup = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe" -ErrorAction Stop
- & .\scripts\sign-windows-authenticode.ps1 -Path $setup.FullName -Stage "sign"
+ $target = "${{ matrix.target }}"
+ $setup = Get-ChildItem "src-tauri/target/$target/release/bundle/nsis/*-setup.exe" -ErrorAction Stop
+ & .\scripts\verify-windows-authenticode.ps1 `
+ -Path $setup.FullName `
+ -Mode required `
+ -ExpectedSubject "SignPath Foundation" `
+ -RequireTimestamp `
+ -Stage "sign-verify"
- - name: Verify Authenticode on Windows installer
+ - name: Verify signed installed app and uninstaller (all Windows targets)
if: matrix.os == 'windows'
shell: pwsh
- env:
- AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }}
run: |
+ $skipLaunch = "${{ matrix.target }}" -like "aarch64-*"
$setup = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe" -ErrorAction Stop
- & .\scripts\verify-windows-authenticode.ps1 `
- -Path $setup.FullName `
- -Mode $env:AUTHENTICODE_MODE `
- -Stage "sign-verify"
+ & .\scripts\windows-packaged-smoke.ps1 `
+ -Installer $setup.FullName `
+ -AuthenticodeMode required `
+ -ExpectedSubject "SignPath Foundation" `
+ -RequireTimestamp `
+ -SkipLaunch:$skipLaunch
# ── Windows: sign the NSIS installer for the updater ──────────────────
- # The build is intentionally unsigned for Authenticode (until OV/EV is
- # configured), so Tauri emits no updater `.sig` during build. In Tauri 2
- # the Windows updater ships the `-setup.exe` itself, so signing it here
- # with the updater key — exactly what finalize-macos.sh does for the
+ # The Windows updater ships the Authenticode-signed `-setup.exe` itself.
+ # Signing those final bytes here with the updater key — exactly what
+ # finalize-macos.sh does for the
# .app.tar.gz — is equivalent to `createUpdaterArtifacts` and is what
# gets windows-x86_64 and windows-aarch64 into latest.json. Without it
# the manifest has macOS platforms only and Windows in-app self-update
@@ -279,18 +621,95 @@ jobs:
fi
echo "::endgroup::"
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ - name: Verify final publishable Windows installer (required)
+ if: matrix.os == 'windows'
+ shell: pwsh
+ run: |
+ $setup = Get-ChildItem "$env:GITHUB_WORKSPACE/dist-artifacts/*-setup.exe" -ErrorAction Stop
+ & .\scripts\verify-windows-authenticode.ps1 `
+ -Path $setup.FullName `
+ -Mode required `
+ -ExpectedSubject "SignPath Foundation" `
+ -RequireTimestamp `
+ -Stage "publish-sign-verify"
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: bundle-${{ matrix.target }}
+ name: bundle-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }}
path: dist-artifacts/*
if-no-files-found: error
+ # Signing and notarization are intentionally non-deterministic. Keep every
+ # successful matrix artifact under an attempt-qualified name, then select the
+ # earliest successful artifact for each target. A release/stage rerun therefore
+ # reuses the exact first bytes instead of creating a second immutable payload.
+ select_artifacts:
+ needs: [prepare, build]
+ if: ${{ always() && needs.prepare.result == 'success' }}
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ outputs:
+ artifact_names: ${{ steps.select.outputs.artifact_names }}
+ steps:
+ - name: Select canonical build artifacts
+ id: select
+ if: ${{ needs.prepare.outputs.release_reusable != 'true' }}
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ targets=(
+ aarch64-apple-darwin
+ x86_64-apple-darwin
+ x86_64-pc-windows-msvc
+ aarch64-pc-windows-msvc
+ )
+ names_file="$RUNNER_TEMP/canonical-artifact-names.txt"
+ : > "$names_file"
+ for poll in 1 2 3 4 5; do
+ gh api --paginate \
+ "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \
+ --jq '.artifacts[].name' > "$RUNNER_TEMP/run-artifacts.txt"
+ : > "$names_file"
+ complete=true
+ for target in "${targets[@]}"; do
+ selected=""
+ for attempt in $(seq 1 "$GITHUB_RUN_ATTEMPT"); do
+ candidate="bundle-${target}-${GITHUB_RUN_ID}-${attempt}"
+ if grep -Fxq "$candidate" "$RUNNER_TEMP/run-artifacts.txt"; then
+ selected="$candidate"
+ break
+ fi
+ done
+ if [[ -z "$selected" ]]; then
+ complete=false
+ break
+ fi
+ echo "$selected" >> "$names_file"
+ done
+ [[ "$complete" == "true" ]] && break
+ sleep 5
+ done
+ [[ "$complete" == "true" ]] || {
+ echo "::error::no complete canonical release artifact set exists for this run"
+ exit 1
+ }
+ artifact_names="$(jq -Rsc 'split("\n") | map(select(length > 0))' "$names_file")"
+ echo "artifact_names=$artifact_names" >> "$GITHUB_OUTPUT"
+ echo "Canonical artifacts: $artifact_names"
+
release:
- if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
- needs: build
- # Repo admins should configure the release environment and copy mirror
- # secrets there before moving them off repo-level Actions secrets.
+ if: ${{ always() && needs.preflight.result == 'success' && needs.prepare.result == 'success' && needs.select_artifacts.result == 'success' }}
+ needs: [preflight, prepare, build, select_artifacts]
+ # All mirror credentials live only in the default-branch-restricted release
+ # environment. Never duplicate them as repository or organization secrets.
environment: release
+ env:
+ TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }}
+ TRUSTED_WORKFLOW_SOURCE_SHA: ${{ github.sha }}
permissions:
contents: write
actions: write # dispatch winget.yml after publishing (release events from GITHUB_TOKEN don't cascade)
@@ -298,18 +717,228 @@ jobs:
attestations: write
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Authorize credentialed job rerun actor
+ shell: bash
+ env:
+ CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
+ run: |
+ set -euo pipefail
+ [[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
+ echo "::error::credentialed release job rerun actor is not the authorized publisher"
+ exit 1
+ }
+
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ # Historical workflow revisions used the legacy credential names and wrote
+ # latest.json unconditionally. Keeping those secrets deleted is the storage
+ # authorization boundary that makes an old run's "Re-run all jobs" fail
+ # before it can mutate either mirror.
+ - name: Enforce legacy mirror credential revocation
+ shell: bash
+ env:
+ LEGACY_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_ACCESS_KEY_ID }}
+ LEGACY_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_SECRET_ACCESS_KEY }}
+ LEGACY_IHEP_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_ACCESS_KEY_ID }}
+ LEGACY_IHEP_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_SECRET_ACCESS_KEY }}
+ run: |
+ if [[ -n "$LEGACY_R2_ACCESS_KEY_ID$LEGACY_R2_SECRET_ACCESS_KEY$LEGACY_IHEP_ACCESS_KEY_ID$LEGACY_IHEP_SECRET_ACCESS_KEY" ]]; then
+ echo "::error::legacy mirror write secrets are still configured; move credentials to the *_PROMOTION_* names and delete all four legacy access-key secrets"
+ exit 1
+ fi
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
+ cache: npm
+
+ - name: Install release tooling
+ run: npm ci
+
+ # Re-resolve tag policy on every release-job attempt. For manual recovery
+ # this independently resolves the immutable tag; a signal run must still
+ # match the SHA carried by the original successful push workflow.
+ - name: Re-resolve protected immutable release tag
+ id: tag_source
+ env:
+ ALLOW_RELEASE_SHA_RESOLUTION: ${{ needs.preflight.outputs.trusted_invocation == 'manual-recovery' && '1' || '0' }}
+ GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
+ RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.release_source_sha }}
+ run: node scripts/check-release-tag-protection.mjs "$RELEASE_TAG" "$RELEASE_SOURCE_SHA"
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ # Re-run this gate inside every release-job attempt. GitHub's "Re-run
+ # failed jobs" does not re-run prepare, and no immutable publication may
+ # rely only on a previous attempt's source or ancestry decision.
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
- path: dist
- merge-multiple: true
+ ref: ${{ steps.tag_source.outputs.release_source_sha }}
+ path: release-source
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Re-check release tag against application versions
+ shell: bash
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ EXPECTED_RELEASE_SOURCE_SHA: ${{ steps.tag_source.outputs.release_source_sha }}
+ run: |
+ set -euo pipefail
+ node scripts/check-release-version.mjs source "$RELEASE_TAG" release-source
+ release_source_sha="$(git -C release-source rev-parse HEAD)"
+ if [[ "$release_source_sha" != "$EXPECTED_RELEASE_SOURCE_SHA" ]]; then
+ echo "::error::release checkout differs from verified commit $EXPECTED_RELEASE_SOURCE_SHA"
+ exit 1
+ fi
+ bash scripts/check-release-source-ancestor.sh release-source "$release_source_sha" "$DEFAULT_BRANCH"
+ echo "RELEASE_SOURCE_SHA=$release_source_sha" >> "$GITHUB_ENV"
+
+ # Re-check on every release-job attempt. `prepare` is not rerun by
+ # "Re-run failed jobs", and publishing a resumed draft while the setting is
+ # disabled would create a mutable canonical source.
+ - name: Re-check GitHub Immutable Releases
+ env:
+ GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
+ run: node scripts/check-immutable-releases.mjs
+
+ # `prepare` may belong to an earlier run attempt when the operator chooses
+ # "Re-run failed jobs". Refresh the release state inside this job so a
+ # GitHub Release published by the failed attempt becomes the canonical,
+ # immutable byte source instead of being uploaded a second time.
+ - name: Refresh immutable release state
+ id: live_release
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }}
+ run: |
+ set -euo pipefail
+ lookup_error="$RUNNER_TEMP/live-release-lookup-error.txt"
+ release_json="$RUNNER_TEMP/live-release.json"
+ if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$release_json" 2>"$lookup_error"; then
+ REQUESTED_TARGET_TAG="$REQUESTED_TARGET_TAG" \
+ node scripts/check-release-reuse.mjs "$RELEASE_TAG" "$release_json"
+ elif grep -q 'HTTP 404' "$lookup_error"; then
+ if [[ -n "$REQUESTED_TARGET_TAG" ]]; then
+ echo "::error::target_tag $REQUESTED_TARGET_TAG must already exist as a GitHub Release"
+ exit 1
+ fi
+ echo "release_reusable=false" >> "$GITHUB_OUTPUT"
+ echo "release_asset_digests={}" >> "$GITHUB_OUTPUT"
+ else
+ cat "$lookup_error" >&2
+ echo "::error::could not refresh release state for $RELEASE_TAG"
+ exit 1
+ fi
+
+ # A target_tag dispatch intentionally checks out the protected default
+ # branch so historical workflow code cannot run. The updater trust root is
+ # release data, however, and must come from the target tag: a later key
+ # rotation must not make valid immutable historical artifacts unverifiable.
+ - name: Resolve updater trust root for release tag
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ release_config="$RUNNER_TEMP/release-tauri.conf.json"
+ gh api --method GET \
+ -H "Accept: application/vnd.github.raw+json" \
+ "repos/$GITHUB_REPOSITORY/contents/src-tauri/tauri.conf.json" \
+ -f ref="$RELEASE_SOURCE_SHA" >"$release_config"
+ if ! updater_public_key="$(
+ jq -er '
+ .plugins.updater.pubkey
+ | select(type == "string" and length > 0)
+ | select((contains("\n") or contains("\r")) | not)
+ | select(test("^[A-Za-z0-9+/]+={0,2}$"))
+ ' "$release_config"
+ )"; then
+ echo "::error::release tag $RELEASE_TAG has no valid single-line updater public key"
+ exit 1
+ fi
+ printf 'RELEASE_TAURI_CONFIG=%s\n' "$release_config" >> "$GITHUB_ENV"
+ printf 'MIRROR_UPDATER_PUBLIC_KEY=%s\n' "$updater_public_key" >> "$GITHUB_ENV"
+
+ - name: Download canonical build artifacts
+ if: ${{ steps.live_release.outputs.release_reusable != 'true' }}
+ shell: bash
+ env:
+ ARTIFACT_NAMES_JSON: ${{ needs.select_artifacts.outputs.artifact_names }}
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ rm -rf dist
+ rm -f release-binding.json
+ mkdir -p dist
+ jq -er '.[]' <<<"$ARTIFACT_NAMES_JSON" | while IFS= read -r artifact; do
+ gh run download "$GITHUB_RUN_ID" \
+ --repo "$GITHUB_REPOSITORY" \
+ --name "$artifact" \
+ --dir dist
+ done
+
+ # A same-tag rerun or emergency downgrade must use the bytes already
+ # published for that tag. Rebuilding signed/notarized artifacts is
+ # intentionally non-reproducible and would conflict with immutable keys.
+ - name: Resolve immutable release artifact source
+ id: release_source
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_ASSET_DIGESTS: ${{ steps.live_release.outputs.release_asset_digests }}
+ run: |
+ set -euo pipefail
+ if [[ "${{ steps.live_release.outputs.release_reusable }}" == "true" ]]; then
+ published="$RUNNER_TEMP/published-release-artifacts"
+ rm -rf "$published"
+ mkdir -p "$published"
+ gh release download "$RELEASE_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --dir "$published" \
+ --pattern 'CodexAppManager*' \
+ --pattern 'latest.json' \
+ --pattern 'release-identity.json*' \
+ --pattern 'release-binding.json'
+ test -f "$published/latest.json" || {
+ echo "::error::published $RELEASE_TAG has no latest.json"
+ exit 1
+ }
+ jq -e 'type == "object" and length > 0' <<<"$RELEASE_ASSET_DIGESTS" >/dev/null || {
+ echo "::error::immutable release digest evidence is missing"
+ exit 1
+ }
+ while IFS=$'\t' read -r name expected_digest; do
+ file="$published/$name"
+ test -f "$file" || {
+ echo "::error::immutable release asset is missing after download: $name"
+ exit 1
+ }
+ actual_digest="sha256:$(sha256sum "$file" | cut -d' ' -f1)"
+ if [[ "$actual_digest" != "$expected_digest" ]]; then
+ echo "::error::immutable release digest mismatch for $name"
+ exit 1
+ fi
+ echo "Verified immutable release digest: $name"
+ done < <(jq -r 'to_entries[] | [.key, .value] | @tsv' <<<"$RELEASE_ASSET_DIGESTS")
+ cp "$published/latest.json" "$RUNNER_TEMP/published-latest.json"
+ rm "$published/latest.json"
+ cp "$published/release-binding.json" release-binding.json
+ rm "$published/release-binding.json"
+ rm -rf dist
+ mv "$published" dist
+ echo "existing=true" >> "$GITHUB_OUTPUT"
+ echo "source=github-release" >> "$GITHUB_OUTPUT"
+ echo "Reusing immutable artifacts from existing release $RELEASE_TAG"
+ else
+ test -d dist || {
+ echo "::error::no build artifacts are available for new release $RELEASE_TAG"
+ exit 1
+ }
+ echo "existing=false" >> "$GITHUB_OUTPUT"
+ echo "source=canonical-actions-artifacts" >> "$GITHUB_OUTPUT"
+ fi
# Validate the merged *publishable* tree (renamed final names), not the
# per-target intermediate build directories from the matrix jobs.
@@ -318,16 +947,25 @@ jobs:
run: |
echo "::group::[build] validate final publishable artifacts"
set -euo pipefail
+ node scripts/check-release-version.mjs artifacts "$RELEASE_TAG" dist
ls -la dist
missing=0
require() {
local pattern="$1"
+ local matches
# shellcheck disable=SC2086
- if ! compgen -G "dist/$pattern" > /dev/null; then
+ matches="$(compgen -G "dist/$pattern" || true)"
+ if [[ -z "$matches" ]]; then
echo "::error::[build] missing final artifact matching: $pattern"
missing=1
else
- echo "OK $pattern → $(compgen -G "dist/$pattern" | tr '\n' ' ')"
+ while IFS= read -r file; do
+ if [[ ! -s "$file" ]]; then
+ echo "::error::[build] empty final artifact: $file"
+ missing=1
+ fi
+ done <<<"$matches"
+ echo "OK $pattern → $(tr '\n' ' ' <<<"$matches")"
fi
}
require "CodexAppManager_aarch64.dmg"
@@ -350,29 +988,288 @@ jobs:
- name: Generate updater manifest (latest.json)
env:
ALLOW_PARTIAL_RELEASE: ${{ vars.ALLOW_PARTIAL_RELEASE }}
- run: node scripts/gen-updater-manifest.mjs "${{ github.ref_name }}" dist
+ run: |
+ node scripts/gen-updater-manifest.mjs "$RELEASE_TAG" dist release-source
+ cp latest.json "$RUNNER_TEMP/artifact-derived-latest.json"
+ if [[ "${{ steps.release_source.outputs.existing }}" == "true" ]]; then
+ cp "$RUNNER_TEMP/published-latest.json" latest.json
+ fi
+ node scripts/validate-release-manifest.mjs \
+ "$RELEASE_TAG" \
+ latest.json \
+ "$RUNNER_TEMP/artifact-derived-latest.json"
+
+ - name: Reuse or sign fresh canonical release identity
+ if: ${{ steps.release_source.outputs.existing != 'true' }}
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ status=0
+ node scripts/reuse-release-identity-signature.mjs \
+ release-identity.json release-identity.json.sig \
+ "$RELEASE_TAURI_CONFIG" || status=$?
+ if [ "$status" -eq 3 ]; then
+ echo "No reusable immutable identity signature; signing canonical identity."
+ npx tauri signer sign release-identity.json
+ elif [ "$status" -ne 0 ]; then
+ echo "::error::prior immutable release identity/signature is invalid or inconsistent" >&2
+ exit "$status"
+ fi
+ test -s release-identity.json.sig
+
+ # A published immutable release is the byte authority on rerun. Never use
+ # the private key to mint a new identity for historical artifacts.
+ - name: Restore signed identity from immutable release
+ if: ${{ steps.release_source.outputs.existing == 'true' }}
+ run: |
+ cp dist/release-identity.json release-identity.json
+ cp dist/release-identity.json.sig release-identity.json.sig
+
+ - name: Verify signed canonical release identity
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -s release-identity.json
+ test -s release-identity.json.sig
+ node scripts/minisign-verify.mjs \
+ release-identity.json release-identity.json.sig \
+ "$RELEASE_TAURI_CONFIG"
+ if [[ "${{ steps.release_source.outputs.existing }}" != "true" ]]; then
+ cp release-identity.json release-identity.json.sig dist/
+ fi
- # Stable releases mirror in two phases: stage immutable versioned assets
- # before GitHub Release publication, then promote latest.json only after
+ # A wrong private/public updater-key pairing cannot be repaired after an
+ # immutable GitHub Release is published. Verify the exact local payloads
+ # (including manifest/sidecar equality) before either mirror staging or
+ # draft publication. This also protects prereleases, which skip mirrors.
+ - name: Verify local updater signatures before immutable publication
+ run: |
+ node scripts/verify-release-artifacts.mjs \
+ latest.json \
+ dist \
+ "$RELEASE_TAURI_CONFIG"
+
+ # A workflow_run attestation identifies the trusted default-branch
+ # release.yml, not the tag ref. Bind the separately verified tag commit to
+ # the exact subject digests in a custom, immutable predicate.
+ - name: Resolve signed release binding
+ id: binding
+ shell: bash
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ subjects=(dist/* latest.json)
+ if [[ "${{ steps.release_source.outputs.existing }}" != "true" ]]; then
+ node scripts/release-binding.mjs create \
+ release-binding.json \
+ "$RELEASE_TAG" \
+ "$RELEASE_SOURCE_SHA" \
+ "$GITHUB_REPOSITORY" \
+ "$DEFAULT_BRANCH" \
+ "$TRUSTED_WORKFLOW_SHA" \
+ "$TRUSTED_WORKFLOW_SOURCE_SHA" \
+ "${subjects[@]}"
+ fi
+ node scripts/release-binding.mjs verify \
+ release-binding.json \
+ "$RELEASE_TAG" \
+ "$RELEASE_SOURCE_SHA" \
+ "$GITHUB_REPOSITORY" \
+ "$DEFAULT_BRANCH" \
+ "${subjects[@]}"
+
+ # Create provenance only for bytes produced by this release run, before a
+ # draft can become immutable. A later failed-job rerun must never mint a
+ # new statement that falsely attributes already-published bytes to the
+ # rerun's OIDC identity.
+ - name: Attest fresh build provenance
+ id: attest_fresh
+ if: ${{ steps.release_source.outputs.existing != 'true' }}
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: |
+ dist/*
+ latest.json
+
+ - name: Attest fresh release source binding
+ id: attest_binding
+ if: ${{ steps.release_source.outputs.existing != 'true' }}
+ uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
+ with:
+ subject-path: |
+ dist/*
+ latest.json
+ predicate-type: https://codexapp.agentsmirror.com/attestations/release-binding/v1
+ predicate-path: release-binding.json
+
+ # Verify the just-created bundle before any draft upload. The standard
+ # workflow_run identity is the trusted main workflow/source; the custom
+ # predicate and exact subject set provide the independent tag binding.
+ - name: Verify fresh release source binding
+ id: verify_fresh_binding
+ if: ${{ steps.release_source.outputs.existing != 'true' }}
+ shell: bash
+ env:
+ ATTESTATION_BUNDLE: ${{ steps.attest_binding.outputs.bundle-path }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ signer_workflow="$GITHUB_REPOSITORY/.github/workflows/release.yml"
+ verification="$RUNNER_TEMP/fresh-release-binding-verification.json"
+ gh attestation verify latest.json \
+ --bundle "$ATTESTATION_BUNDLE" \
+ --repo "$GITHUB_REPOSITORY" \
+ --signer-workflow "$signer_workflow" \
+ --signer-digest "$TRUSTED_WORKFLOW_SHA" \
+ --source-ref "refs/heads/$DEFAULT_BRANCH" \
+ --source-digest "$TRUSTED_WORKFLOW_SOURCE_SHA" \
+ --predicate-type "https://codexapp.agentsmirror.com/attestations/release-binding/v1" \
+ --deny-self-hosted-runners \
+ --format json > "$verification"
+ node scripts/release-binding.mjs attestation "$verification" release-binding.json
+
+ # Existing immutable releases are accepted only when their downloaded,
+ # API-digest-pinned bytes already carry provenance from this repository's
+ # trusted default-branch release workflow revision. The custom predicate
+ # separately binds the immutable tag and peeled source SHA to every digest.
+ # This is verification, not a new OIDC statement; missing historical
+ # provenance or binding fails closed.
+ - name: Verify existing immutable release provenance
+ id: verify_existing_provenance
+ if: ${{ steps.release_source.outputs.existing == 'true' }}
+ shell: bash
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_SIGNER_SHA: ${{ steps.binding.outputs.signer_sha }}
+ RELEASE_WORKFLOW_SOURCE_SHA: ${{ steps.binding.outputs.source_sha }}
+ run: |
+ set -euo pipefail
+ signer_workflow="$GITHUB_REPOSITORY/.github/workflows/release.yml"
+ predicate_type="https://codexapp.agentsmirror.com/attestations/release-binding/v1"
+ gh release verify "$RELEASE_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --format json > "$RUNNER_TEMP/immutable-release-attestation.json"
+ for file in dist/* latest.json release-binding.json; do
+ gh release verify-asset "$RELEASE_TAG" "$file" \
+ --repo "$GITHUB_REPOSITORY" >/dev/null
+ done
+ for file in dist/* latest.json; do
+ gh attestation verify "$file" \
+ --repo "$GITHUB_REPOSITORY" \
+ --signer-workflow "$signer_workflow" \
+ --signer-digest "$RELEASE_SIGNER_SHA" \
+ --source-ref "refs/heads/$DEFAULT_BRANCH" \
+ --source-digest "$RELEASE_WORKFLOW_SOURCE_SHA" \
+ --deny-self-hosted-runners >/dev/null
+ custom_json="$RUNNER_TEMP/release-binding-$(basename "$file").json"
+ gh attestation verify "$file" \
+ --repo "$GITHUB_REPOSITORY" \
+ --signer-workflow "$signer_workflow" \
+ --signer-digest "$RELEASE_SIGNER_SHA" \
+ --source-ref "refs/heads/$DEFAULT_BRANCH" \
+ --source-digest "$RELEASE_WORKFLOW_SOURCE_SHA" \
+ --predicate-type "$predicate_type" \
+ --deny-self-hosted-runners \
+ --format json > "$custom_json"
+ node scripts/release-binding.mjs attestation "$custom_json" release-binding.json
+ echo "Verified existing provenance and release binding: $(basename "$file")"
+ done
+
+ - name: Resolve provenance gate
+ id: provenance
+ shell: bash
+ env:
+ FRESH_ATTESTATION_OUTCOME: ${{ steps.attest_fresh.outcome }}
+ FRESH_BINDING_ATTESTATION_OUTCOME: ${{ steps.attest_binding.outcome }}
+ FRESH_BINDING_VERIFICATION_OUTCOME: ${{ steps.verify_fresh_binding.outcome }}
+ EXISTING_PROVENANCE_OUTCOME: ${{ steps.verify_existing_provenance.outcome }}
+ RELEASE_REUSED: ${{ steps.release_source.outputs.existing }}
+ run: |
+ set -euo pipefail
+ if [[ "$RELEASE_REUSED" == "true" ]]; then
+ [[ "$FRESH_ATTESTATION_OUTCOME" == "skipped" ]]
+ [[ "$FRESH_BINDING_ATTESTATION_OUTCOME" == "skipped" ]]
+ [[ "$FRESH_BINDING_VERIFICATION_OUTCOME" == "skipped" ]]
+ [[ "$EXISTING_PROVENANCE_OUTCOME" == "success" ]]
+ echo "mode=verified-existing-immutable-release" >> "$GITHUB_OUTPUT"
+ else
+ [[ "$FRESH_ATTESTATION_OUTCOME" == "success" ]]
+ [[ "$FRESH_BINDING_ATTESTATION_OUTCOME" == "success" ]]
+ [[ "$FRESH_BINDING_VERIFICATION_OUTCOME" == "success" ]]
+ [[ "$EXISTING_PROVENANCE_OUTCOME" == "skipped" ]]
+ echo "mode=fresh-build-attested-before-publication" >> "$GITHUB_OUTPUT"
+ fi
+ echo "ready=true" >> "$GITHUB_OUTPUT"
+
+ # Stable releases stage immutable versioned assets before GitHub Release
+ # publication, verify them separately, then promote latest.json only after
# the GitHub Release succeeds. Pre-releases still skip the mirror entirely.
- name: Stage CDN mirror candidate (R2 + IHEP S3)
id: mirror_stage
- if: ${{ !contains(github.ref_name, '-') }}
+ if: ${{ steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }}
env:
MIRROR_PHASE: stage
+ MIRROR_REQUIRED_BACKENDS: r2,ihep
+ MIRROR_CANDIDATE_ID: ${{ github.run_id }}-${{ github.run_attempt }}
+ MIRROR_ALLOW_DOWNGRADE: ${{ inputs.allow_mirror_downgrade && '1' || '0' }}
+ MIRROR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ MIRROR_DOWNGRADE_REASON: ${{ inputs.mirror_downgrade_reason || '' }}
+ MIRROR_WORKFLOW_REF_NAME: ${{ github.ref_name }}
+ MANAGER_R2_S3_ENDPOINT: ${{ secrets.MANAGER_R2_S3_ENDPOINT }}
+ MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }}
+ MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }}
+ MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }}
+ MANAGER_IHEP_S3_ENDPOINT: ${{ vars.MANAGER_IHEP_S3_ENDPOINT }}
+ MANAGER_IHEP_S3_BUCKET: ${{ vars.MANAGER_IHEP_S3_BUCKET }}
+ MANAGER_IHEP_S3_REGION: ${{ vars.MANAGER_IHEP_S3_REGION }}
+ MANAGER_IHEP_S3_PREFIX: ${{ vars.MANAGER_IHEP_S3_PREFIX }}
+ MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }}
+ MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }}
+ run: |
+ [ -f dist/latest.json ] || cp latest.json dist/latest.json
+ bash scripts/sync-mirror.sh dist
+ # The GitHub Release uploads the root latest.json exactly once. Recreate
+ # dist/latest.json later for promotion instead of giving the upload
+ # action two paths with the same asset basename.
+ rm -f dist/latest.mirror.json dist/latest.json
+
+ # Complete a direct readback from both object stores and force separate
+ # readbacks through the Worker's R2 and mainland-China IHEP branches. This
+ # is a verify-only phase: latest.json cannot be changed here. Both public
+ # branches must pass before GitHub publication becomes immutable.
+ - name: Verify staged CDN mirror before immutable publication
+ id: mirror_verify
+ if: ${{ steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }}
+ env:
+ MIRROR_PHASE: verify
+ MIRROR_REQUIRED_BACKENDS: r2,ihep
+ MIRROR_CANDIDATE_ID: ${{ github.run_id }}-${{ github.run_attempt }}
+ MIRROR_ALLOW_DOWNGRADE: ${{ inputs.allow_mirror_downgrade && '1' || '0' }}
+ MIRROR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ MIRROR_DOWNGRADE_REASON: ${{ inputs.mirror_downgrade_reason || '' }}
+ MIRROR_WORKFLOW_REF_NAME: ${{ github.ref_name }}
MANAGER_R2_S3_ENDPOINT: ${{ secrets.MANAGER_R2_S3_ENDPOINT }}
- MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_ACCESS_KEY_ID }}
- MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_SECRET_ACCESS_KEY }}
- MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }}
- MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }}
- MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }}
- MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }}
- MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_ACCESS_KEY_ID }}
- MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_SECRET_ACCESS_KEY }}
+ MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }}
+ MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }}
+ MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }}
+ MANAGER_IHEP_S3_ENDPOINT: ${{ vars.MANAGER_IHEP_S3_ENDPOINT }}
+ MANAGER_IHEP_S3_BUCKET: ${{ vars.MANAGER_IHEP_S3_BUCKET }}
+ MANAGER_IHEP_S3_REGION: ${{ vars.MANAGER_IHEP_S3_REGION }}
+ MANAGER_IHEP_S3_PREFIX: ${{ vars.MANAGER_IHEP_S3_PREFIX }}
+ MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }}
+ MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }}
run: |
[ -f dist/latest.json ] || cp latest.json dist/latest.json
bash scripts/sync-mirror.sh dist
- rm -f dist/latest.mirror.json
+ rm -f dist/latest.mirror.json dist/latest.json
# ── release notes: the hand-written file from the version-bump PR
# (docs/releases/.md, see docs/releases/TEMPLATE.md) wins;
@@ -380,7 +1277,7 @@ jobs:
# empty. GitHub's auto "What's Changed" section is appended either way.
- name: Prepare release notes
run: |
- NOTES="docs/releases/${{ github.ref_name }}.md"
+ NOTES="release-source/docs/releases/$RELEASE_TAG.md"
if [ -f "$NOTES" ]; then
cp "$NOTES" "$RUNNER_TEMP/release-notes.md"
else
@@ -398,9 +1295,10 @@ jobs:
if [ ! -f dist/latest.json ]; then
sha256sum latest.json >> SHA256SUMS
fi
+ sha256sum release-binding.json >> SHA256SUMS
cat SHA256SUMS
- - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Generate SBOMs
id: sbom
@@ -411,7 +1309,7 @@ jobs:
status=0
: > release-assets/sbom-status.txt
- if npm sbom --package-lock-only --sbom-format cyclonedx > release-assets/sbom-npm.cdx.json; then
+ if (cd release-source && npm sbom --package-lock-only --sbom-format cyclonedx) > release-assets/sbom-npm.cdx.json; then
echo "npm SBOM: generated" >> release-assets/sbom-status.txt
else
echo "::warning::npm SBOM generation failed"
@@ -420,7 +1318,7 @@ jobs:
status=1
fi
- if cargo metadata --manifest-path src-tauri/Cargo.toml --format-version=1 > "$RUNNER_TEMP/cargo-metadata.json" \
+ if cargo metadata --manifest-path release-source/src-tauri/Cargo.toml --format-version=1 > "$RUNNER_TEMP/cargo-metadata.json" \
&& node scripts/cargo-metadata-to-cyclonedx.mjs "$RUNNER_TEMP/cargo-metadata.json" release-assets/sbom-cargo.cdx.json; then
echo "cargo SBOM: generated" >> release-assets/sbom-status.txt
else
@@ -432,54 +1330,278 @@ jobs:
exit "$status"
- - name: Publish GitHub Release
- id: publish_release
- uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
+ # The repository-level ruleset closes the long validation-to-publication
+ # window by forbidding v* tag updates/deletions. Re-read both the policy and
+ # the peeled commit immediately before creating the canonical draft.
+ - name: Re-check protected release source before draft upload
+ if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }}
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
+ run: |
+ bash scripts/check-release-source-ancestor.sh release-source "$RELEASE_SOURCE_SHA" "$DEFAULT_BRANCH"
+ node scripts/check-release-tag-protection.mjs "$RELEASE_TAG" "$RELEASE_SOURCE_SHA"
+
+ # A failed attempt may leave a mutable draft with stale or attacker-named
+ # assets. action-gh-release replaces matching names but does not remove
+ # extras, which would otherwise become part of the immutable Release.
+ - name: Reset existing mutable Release draft assets
+ if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }}
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ draft_json="$RUNNER_TEMP/existing-release-draft.json"
+ lookup_error="$RUNNER_TEMP/existing-release-draft-error.txt"
+ if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$draft_json" 2>"$lookup_error"; then
+ release_id="$(jq -er --arg tag "$RELEASE_TAG" '
+ select(.tag_name == $tag and .draft == true and .immutable != true)
+ | .id
+ ' "$draft_json")" || {
+ echo "::error::existing release is no longer the expected mutable draft"
+ exit 1
+ }
+ while IFS= read -r asset_id; do
+ [[ "$asset_id" =~ ^[0-9]+$ ]] || {
+ echo "::error::mutable draft returned an invalid asset id"
+ exit 1
+ }
+ gh api --method DELETE \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id"
+ done < <(jq -r '.assets[]?.id' "$draft_json")
+
+ cleared=false
+ for attempt in 1 2 3 4 5; do
+ gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$draft_json"
+ if jq -e \
+ --arg tag "$RELEASE_TAG" \
+ --argjson id "$release_id" \
+ '.id == $id and .tag_name == $tag and .draft == true and .immutable != true and (.assets | length) == 0' \
+ "$draft_json" >/dev/null; then
+ cleared=true
+ break
+ fi
+ sleep 2
+ done
+ [[ "$cleared" == "true" ]] || {
+ echo "::error::could not prove the mutable draft asset set was empty"
+ exit 1
+ }
+ elif grep -q 'HTTP 404' "$lookup_error"; then
+ echo "No existing Release draft to reset for $RELEASE_TAG"
+ else
+ cat "$lookup_error" >&2
+ echo "::error::could not inspect an existing Release before draft upload"
+ exit 1
+ fi
+
+ # Explicit draft-first publication keeps asset upload ahead of publication
+ # for both stable and prerelease tags. This is required when GitHub immutable
+ # releases are enabled: a published release can no longer accept assets.
+ - name: Upload GitHub Release draft
+ id: upload_release_draft
+ if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }}
+ uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
- tag_name: ${{ github.ref_name }}
- draft: false
+ tag_name: ${{ env.RELEASE_TAG }}
+ draft: true
# Tags carrying a pre-release identifier (e.g. v0.1.2-rc1) publish as a
# prerelease, so GitHub's `releases/latest` — and the updater endpoint
# pointing at it — keep resolving to the last stable build instead of
# serving a release-candidate to real users.
- prerelease: ${{ contains(github.ref_name, '-') }}
+ prerelease: ${{ contains(env.RELEASE_TAG, '-') }}
body_path: ${{ runner.temp }}/release-notes.md
generate_release_notes: true
files: |
dist/*
latest.json
+ release-binding.json
SHA256SUMS
release-assets/*
+ - name: Verify exact GitHub Release draft assets
+ if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }}
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ canonical_assets=(dist/* latest.json release-binding.json SHA256SUMS release-assets/*)
+ draft_json="$RUNNER_TEMP/uploaded-release-draft.json"
+ draft_plan="$RUNNER_TEMP/uploaded-release-draft-assets.json"
+ verification_log="$RUNNER_TEMP/uploaded-release-draft-verification.log"
+ verified=false
+ for attempt in 1 2 3 4 5; do
+ : >"$verification_log"
+ if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$draft_json" 2>>"$verification_log" \
+ && node scripts/check-release-draft-assets.mjs \
+ "$RELEASE_TAG" "$draft_json" "$draft_plan" \
+ "${canonical_assets[@]}" >>"$verification_log" 2>&1; then
+ verified=true
+ break
+ fi
+ sleep 2
+ done
+ if [[ "$verified" != "true" ]]; then
+ cat "$verification_log" >&2
+ echo "::error::uploaded Release draft does not contain the exact canonical asset set"
+ exit 1
+ fi
+ cat "$verification_log"
+
+ readback="$RUNNER_TEMP/uploaded-release-draft-readback"
+ rm -rf "$readback"
+ mkdir -p "$readback"
+ while IFS=$'\t' read -r asset_id name local_path expected_sha256; do
+ [[ "$asset_id" =~ ^[0-9]+$ && "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ && "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] || {
+ echo "::error::draft verification plan contains invalid asset metadata"
+ exit 1
+ }
+ downloaded="$readback/$name"
+ gh api \
+ -H "Accept: application/octet-stream" \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" >"$downloaded"
+ actual_sha256="$(sha256sum "$downloaded" | cut -d' ' -f1)"
+ [[ -s "$downloaded" && "$actual_sha256" == "$expected_sha256" ]] || {
+ echo "::error::Release draft readback differs from canonical local bytes: $name"
+ exit 1
+ }
+ cmp -- "$local_path" "$downloaded" >/dev/null || {
+ echo "::error::Release draft byte comparison failed: $name"
+ exit 1
+ }
+ done < <(jq -r '.assets[] | [.id, .name, .localPath, .sha256] | @tsv' "$draft_plan")
+
+ post_json="$RUNNER_TEMP/uploaded-release-draft-post-readback.json"
+ post_plan="$RUNNER_TEMP/uploaded-release-draft-post-readback-assets.json"
+ gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$post_json"
+ node scripts/check-release-draft-assets.mjs \
+ "$RELEASE_TAG" "$post_json" "$post_plan" \
+ "${canonical_assets[@]}"
+ cmp -- "$draft_plan" "$post_plan" >/dev/null || {
+ echo "::error::Release draft assets changed during canonical readback"
+ exit 1
+ }
+
+ # Upload can take long enough for a repository policy regression to matter.
+ # Fence the transition from mutable draft to immutable publication again.
+ - name: Re-check protected release source before publication
+ if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }}
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
+ run: |
+ bash scripts/check-release-source-ancestor.sh release-source "$RELEASE_SOURCE_SHA" "$DEFAULT_BRANCH"
+ node scripts/check-release-tag-protection.mjs "$RELEASE_TAG" "$RELEASE_SOURCE_SHA"
+
+ # Omitting `draft` tells action-gh-release v3 to publish the existing draft
+ # after all uploads succeeded while retaining its stable/prerelease metadata.
+ - name: Publish GitHub Release
+ id: publish_release
+ if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }}
+ uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
+ with:
+ tag_name: ${{ env.RELEASE_TAG }}
+
+ - name: Verify published immutable Release and asset digests
+ if: ${{ steps.release_source.outputs.existing != 'true' }}
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ release_json="$RUNNER_TEMP/published-release.json"
+ verification_log="$RUNNER_TEMP/published-release-verification.log"
+ verification_output="$RUNNER_TEMP/published-release-verification-output.txt"
+ verified=false
+ for attempt in 1 2 3 4 5; do
+ : > "$verification_log"
+ : > "$verification_output"
+ if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$release_json" 2>"$verification_log" \
+ && REQUESTED_TARGET_TAG="$RELEASE_TAG" GITHUB_OUTPUT="$verification_output" \
+ node scripts/check-release-reuse.mjs "$RELEASE_TAG" "$release_json" >>"$verification_log" 2>&1; then
+ verified=true
+ break
+ fi
+ sleep 3
+ done
+ if [[ "$verified" != "true" ]]; then
+ cat "$verification_log" >&2
+ echo "::error::published $RELEASE_TAG did not become immutable with canonical asset digests"
+ exit 1
+ fi
+ cat "$verification_log"
+ release_attestation="$RUNNER_TEMP/immutable-release-attestation.json"
+ release_attestation_verified=false
+ for attempt in 1 2 3 4 5; do
+ if gh release verify "$RELEASE_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --format json > "$release_attestation"; then
+ release_attestation_verified=true
+ break
+ fi
+ sleep 3
+ done
+ if [[ "$release_attestation_verified" != "true" ]]; then
+ echo "::error::published $RELEASE_TAG has no valid GitHub immutable release attestation"
+ exit 1
+ fi
+ release_asset_digests="$(sed -n 's/^release_asset_digests=//p' "$verification_output")"
+ jq -e 'type == "object" and length > 0' <<<"$release_asset_digests" >/dev/null || {
+ echo "::error::published immutable digest evidence is missing"
+ exit 1
+ }
+ for file in dist/* latest.json release-binding.json; do
+ name="$(basename "$file")"
+ gh release verify-asset "$RELEASE_TAG" "$file" \
+ --repo "$GITHUB_REPOSITORY" >/dev/null
+ expected_digest="$(jq -er --arg name "$name" '.[$name]' <<<"$release_asset_digests")"
+ actual_digest="sha256:$(sha256sum "$file" | cut -d' ' -f1)"
+ if [[ "$actual_digest" != "$expected_digest" ]]; then
+ echo "::error::published immutable digest does not match attested local bytes: $name"
+ exit 1
+ fi
+ echo "Verified published attested digest: $name"
+ done
+
+ # Existing immutable releases skip draft publication, so fence the final
+ # credentialed mirror promotion for both fresh and recovery paths.
+ - name: Re-check protected release source before final promotion
+ if: ${{ success() && steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }}
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
+ run: |
+ bash scripts/check-release-source-ancestor.sh release-source "$RELEASE_SOURCE_SHA" "$DEFAULT_BRANCH"
+ node scripts/check-release-tag-protection.mjs "$RELEASE_TAG" "$RELEASE_SOURCE_SHA"
+
- name: Promote CDN mirror latest (R2 + IHEP S3)
id: mirror_promote
- if: ${{ !contains(github.ref_name, '-') }}
+ if: ${{ success() && steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }}
env:
MIRROR_PHASE: promote
+ MIRROR_REQUIRED_BACKENDS: r2,ihep
+ MIRROR_CANDIDATE_ID: ${{ github.run_id }}-${{ github.run_attempt }}
+ MIRROR_ALLOW_DOWNGRADE: ${{ inputs.allow_mirror_downgrade && '1' || '0' }}
+ MIRROR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ MIRROR_DOWNGRADE_REASON: ${{ inputs.mirror_downgrade_reason || '' }}
+ MIRROR_WORKFLOW_REF_NAME: ${{ github.ref_name }}
MANAGER_R2_S3_ENDPOINT: ${{ secrets.MANAGER_R2_S3_ENDPOINT }}
- MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_ACCESS_KEY_ID }}
- MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_SECRET_ACCESS_KEY }}
- MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }}
- MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }}
- MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }}
- MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }}
- MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_ACCESS_KEY_ID }}
- MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_SECRET_ACCESS_KEY }}
+ MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }}
+ MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }}
+ MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }}
+ MANAGER_IHEP_S3_ENDPOINT: ${{ vars.MANAGER_IHEP_S3_ENDPOINT }}
+ MANAGER_IHEP_S3_BUCKET: ${{ vars.MANAGER_IHEP_S3_BUCKET }}
+ MANAGER_IHEP_S3_REGION: ${{ vars.MANAGER_IHEP_S3_REGION }}
+ MANAGER_IHEP_S3_PREFIX: ${{ vars.MANAGER_IHEP_S3_PREFIX }}
+ MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }}
+ MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }}
run: |
[ -f dist/latest.json ] || cp latest.json dist/latest.json
bash scripts/sync-mirror.sh dist
-
- - name: Attest build provenance
- id: attest
- if: ${{ steps.publish_release.outcome == 'success' }}
- continue-on-error: true
- uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
- with:
- subject-path: |
- dist/*
- latest.json
- SHA256SUMS
- release-assets/*
+ rm -f dist/latest.json dist/latest.mirror.json
# The release above is created with GITHUB_TOKEN, which by design does not
# trigger other workflows — so winget.yml's `on: release` never fires.
@@ -488,11 +1610,11 @@ jobs:
# so a re-run is harmless.
- name: Trigger winget submission
id: winget
- if: ${{ !contains(github.ref_name, '-') }}
+ if: ${{ success() && steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }}
env:
GH_TOKEN: ${{ github.token }}
run: |
- if gh workflow run winget.yml -f release-tag="${{ github.ref_name }}" --repo "${{ github.repository }}"; then
+ if gh workflow run winget.yml -f release-tag="$RELEASE_TAG" --repo "${{ github.repository }}"; then
echo "status=dispatched" >> "$GITHUB_OUTPUT"
else
echo "::warning::winget dispatch failed"
@@ -502,10 +1624,29 @@ jobs:
- name: Write release summary
if: always()
env:
- PUBLISH_OUTCOME: ${{ steps.publish_release.outcome }}
+ PUBLISH_OUTCOME: ${{ steps.release_source.outputs.existing == 'true' && 'reused' || steps.publish_release.outcome }}
MIRROR_STAGE_OUTCOME: ${{ steps.mirror_stage.outcome }}
+ MIRROR_VERIFY_OUTCOME: ${{ steps.mirror_verify.outcome }}
MIRROR_PROMOTE_OUTCOME: ${{ steps.mirror_promote.outcome }}
WINGET_OUTCOME: ${{ steps.winget.outputs.status || steps.winget.outcome }}
SBOM_OUTCOME: ${{ steps.sbom.outcome }}
- ATTESTATION_OUTCOME: ${{ steps.attest.outcome }}
+ ATTESTATION_OUTCOME: ${{ steps.attest_fresh.outcome }}
+ BINDING_ATTESTATION_OUTCOME: ${{ steps.attest_binding.outcome }}
+ PROVENANCE_MODE: ${{ steps.provenance.outputs.mode }}
run: node scripts/write-release-summary.mjs
+
+ - name: Upload release audit records
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: release-audit-${{ env.RELEASE_TAG }}-${{ github.run_attempt }}
+ path: |
+ manifest-summary.json
+ updater-signature-verification.json
+ mirror-stage-summary.json
+ mirror-verification-summary.json
+ mirror-promotion-summary.json
+ SHA256SUMS
+ release-binding.json
+ if-no-files-found: warn
+ retention-days: 90
diff --git a/.github/workflows/win-installer-check.yml b/.github/workflows/win-installer-check.yml
index f2bff1b..c43e139 100644
--- a/.github/workflows/win-installer-check.yml
+++ b/.github/workflows/win-installer-check.yml
@@ -6,7 +6,8 @@ name: Windows installer check
# install → first launch → upgrade → uninstall.
#
# Also probes Authenticode on the installer and installed PE files in
-# non-blocking (optional) mode until OV/EV secrets are configured.
+# non-blocking (optional) mode. Pull requests intentionally build unsigned;
+# production signing remains blocked pending the SignPath Foundation migration.
#
# ARM64 is NOT smoke-tested here: GitHub-hosted runners are x64, and a
# cross-built ARM64 PE is not runtime verification. See docs/windows-signing.md.
@@ -27,7 +28,6 @@ on:
- "package.json"
- "package-lock.json"
- "scripts/windows-*.ps1"
- - "scripts/sign-windows-authenticode.ps1"
- "scripts/verify-windows-authenticode.ps1"
- ".github/workflows/win-installer-check.yml"
workflow_dispatch:
@@ -63,9 +63,9 @@ jobs:
# NSIS only — just needs to compile the template, resolve the brand images,
# and link all languages. NO signing secrets here on purpose: this is a
# pull_request trigger running PR-authored code (config/template/hooks), so
- # exposing TAURI_SIGNING_PRIVATE_KEY / WINDOWS_CERTIFICATE would let a PR
- # exfiltrate keys. createUpdaterArtifacts is off; Authenticode remains a
- # release-time concern (release.yml) with optional post-build path.
+ # exposing TAURI_SIGNING_PRIVATE_KEY or any future SignPath credential
+ # would let a PR exfiltrate it. createUpdaterArtifacts is off;
+ # Authenticode remains a release-time concern (release.yml).
- name: Build NSIS installer
id: build
shell: bash
@@ -101,8 +101,8 @@ jobs:
- name: Authenticode probe (optional / non-blocking)
shell: pwsh
env:
- # Flip to "required" via repo variable once OV/EV is wired into release.
- AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }}
+ # Pull requests do not receive release-signing credentials.
+ AUTHENTICODE_MODE: optional
run: |
& .\scripts\verify-windows-authenticode.ps1 `
-Path "${{ steps.artifact.outputs.installer }}" `
@@ -112,7 +112,7 @@ jobs:
- name: Packaged lifecycle smoke (install / launch / upgrade / uninstall)
shell: pwsh
env:
- AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }}
+ AUTHENTICODE_MODE: optional
run: |
& .\scripts\windows-packaged-smoke.ps1 `
-Installer "${{ steps.artifact.outputs.installer }}" `
diff --git a/README.md b/README.md
index 0ac8d7d..5236abe 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
- 官网 · Official Website · 中文 · English
+ 官网 · Official Website · 代码签名政策 · Code signing policy · 隐私政策 · Privacy policy · 中文 · English
---
@@ -89,7 +89,7 @@ brew install --cask wangnov/tap/codex-app-manager
| Windows x64 | `CodexAppManager_x64-setup.exe` | [⤓ 镜像下载](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) |
| Windows ARM64 | `CodexAppManager_arm64-setup.exe` | [⤓ 镜像下载](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) |
-macOS 版本经 **Developer ID 签名 + Apple 公证**,首次打开不会被 Gatekeeper 拦截。Windows 安装器(`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`)当前**没有 Authenticode 代码签名**,首次运行可能出现 SmartScreen 提示;应用内自更新使用的 Tauri updater 签名只校验下载字节,不代表 Windows 发行者信任。详情见 [Windows signing and verification](docs/windows-signing.md)。
+macOS 版本经 **Developer ID 签名 + Apple 公证**,首次打开不会被 Gatekeeper 拦截。Windows 安装器(`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`)当前**没有 Authenticode 代码签名**,首次运行可能出现 SmartScreen 提示;应用内自更新使用的 Tauri updater 签名只校验下载字节,不代表 Windows 发行者信任。SignPath Foundation 仍在申请/迁移中,新的 Windows tag 发布在集成验证完成前保持 fail-closed。详情见 [代码签名政策](docs/code-signing-policy.md)与 [Windows signing and verification](docs/windows-signing.md)。
下载后建议用同一 GitHub Release 的 `SHA256SUMS` 核验文件。Windows 可用 PowerShell,macOS 可用 `shasum`:
@@ -114,7 +114,7 @@ Manager 内置 Tauri updater,按以下顺序检查自身新版本:
1. `https://codexapp.agentsmirror.com/manager/latest.json` —— 自有镜像,**全球走 R2、中国大陆自动分流到 IHEP S3**
2. `https://github.com/Wangnov/Codex-App-Manager/releases/latest/download/latest.json` —— GitHub 兜底
-`latest.json` 里的签名签的是**安装包字节**而非 URL,镜像只是逐字节复制并改写下载地址,所以签名始终有效。每次正式发版,CI 会自动把产物与改写后的 `latest.json` 同步到两套镜像(安装包置于 `…/manager/<版本>/…` 的版本化路径,长缓存安全;`latest.json` 在固定根路径短缓存),因此**无需依赖 GitHub 也能自更新**——这对国内网络尤其重要。
+`latest.json` 里的签名签的是**安装包字节**而非 URL;Manager 会先验证固定根路径的签名 `release-identity.json(.sig)`,只接受其授权的 stable 版本、文件名与 SHA-256,再读取可因镜像改写 URL 的 `latest.json`。每次正式发版,CI 会把产物、版本化身份文件和根身份指针同步到 R2/IHEP;任一身份或清单不一致都会安全回退 GitHub,不会安装未授权版本。
## 管理与更新 Codex 本体
@@ -135,7 +135,7 @@ Manager 通过上游镜像管理 Codex 桌面应用本体:
### 发布流水线
-打 `v*` tag 触发 `release.yml`:四平台构建(瞬时下载失败自动重试)→ macOS inside-out Developer ID 签名 + 公证 + 重打 updater 包 → Windows updater 产物签名 → 发布 GitHub Release → 自动同步到 R2 + IHEP 镜像。
+打 `v*` tag 触发 `release.yml`。当前 Windows job 会在构建前由 SignPath Foundation readiness gate 主动失败,因此整个发布不会继续,也不会悄悄发布 unsigned Windows 或仅 macOS 的不完整版本。待申请获批并以真实工件验证 trusted-build、人工审批、NSIS 边界和最终签名后,再由独立 PR 接回四平台发布链路。
### 生态:codex-app-mirror
@@ -161,6 +161,11 @@ npm run tauri:build # 本地构建(未签名)
- 不伪造或重算 Sparkle 签名(只字节级复制官方签名)
- 不替代 OpenAI、Microsoft Store 的官方分发渠道
+## 政策与隐私
+
+- [代码签名政策](docs/code-signing-policy.md):SignPath Foundation 申请状态、项目角色、逐次人工审批、可签工件边界与发布硬门。
+- [隐私政策](docs/privacy.md):Manager 自身与 Codex 的更新检查、镜像/代理网络行为、本地数据和第三方连接元数据。
+
## 致谢
- **[LINUX DO](https://linux.do/)** 社区 —— 安装体验、更新链路、问题反馈的讨论都汇聚于此。
@@ -221,7 +226,7 @@ Grab your platform's file from the [latest GitHub Release](https://github.com/Wa
| Windows x64 | `CodexAppManager_x64-setup.exe` | [⤓ mirror](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) |
| Windows ARM64 | `CodexAppManager_arm64-setup.exe` | [⤓ mirror](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) |
-The macOS builds are **Developer ID signed + Apple notarized**, so Gatekeeper won't block first launch. The Windows installers (`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`) are **not Authenticode-signed** yet, so SmartScreen may warn on first run; the Tauri updater signature used for in-app updates verifies bytes only and is not Windows publisher trust. See [Windows signing and verification](docs/windows-signing.md).
+The macOS builds are **Developer ID signed + Apple notarized**, so Gatekeeper won't block first launch. The Windows installers (`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`) are **not Authenticode-signed** yet, so SmartScreen may warn on first run; the Tauri updater signature used for in-app updates verifies bytes only and is not Windows publisher trust. The SignPath Foundation application/migration is pending, and new Windows tag publication remains fail-closed until the integration is proven. See the [code signing policy](docs/code-signing-policy.md) and [Windows signing and verification](docs/windows-signing.md).
After downloading, compare the file with `SHA256SUMS` from the same GitHub Release. Use PowerShell on Windows or `shasum` on macOS:
@@ -246,7 +251,7 @@ The Manager ships the Tauri updater and checks for new versions of itself in thi
1. `https://codexapp.agentsmirror.com/manager/latest.json` — its own mirror: **R2 globally, auto-failover to IHEP S3 for mainland China**
2. `https://github.com/Wangnov/Codex-App-Manager/releases/latest/download/latest.json` — GitHub fallback
-The signatures in `latest.json` sign the **installer bytes**, not the URL, so the mirror only re-hosts the bytes verbatim and rewrites the download URL — the signature stays valid. On every stable release, CI syncs the artifacts and a rewritten `latest.json` to both mirrors (installers under a versioned path `…/manager//…` so long caching is safe; `latest.json` at the fixed root with a short cache), so **self-update never depends on GitHub** — which matters most inside China.
+The signatures in `latest.json` sign the **installer bytes**, not the URL. Before trusting that URL-rewritten manifest, the Manager verifies a signed root `release-identity.json(.sig)` and accepts only the authorized stable version, artifact names, and SHA-256 values. Every stable release syncs the artifacts, immutable versioned identity, and root identity pointer to R2/IHEP; any mismatch safely falls back to GitHub instead of installing an unauthorized version.
## Managing & updating Codex itself
@@ -267,7 +272,7 @@ Self-update and payload downloads share the `codexapp.agentsmirror.com` link, fr
### Release pipeline
-Pushing a `v*` tag triggers `release.yml`: four-platform build (with automatic retry on transient download hiccups) → macOS inside-out Developer ID signing + notarization + updater repackage → Windows updater artifact signing → publish the GitHub Release → auto-sync to the R2 + IHEP mirror.
+Pushing a `v*` tag triggers `release.yml`. Today the Windows jobs deliberately fail at the SignPath Foundation readiness gate before building, so the full release cannot proceed and cannot silently publish unsigned Windows or a macOS-only partial set. A separate reviewed PR will restore the four-platform path only after real artifacts prove the trusted build, manual approval, NSIS boundary, and final signatures.
### Ecosystem: codex-app-mirror
@@ -293,6 +298,11 @@ npm run tauri:build # local (unsigned) build
- Does not forge or recompute Sparkle signatures (official signatures are copied verbatim)
- Is not a replacement for official OpenAI / Microsoft Store distribution
+## Policies & privacy
+
+- [Code signing policy](docs/code-signing-policy.md):SignPath Foundation application state, project roles, per-release human approval, eligible-artifact boundaries, and release gates.
+- [Privacy policy](docs/privacy.md):Manager/Codex update checks, mirror and proxy behavior, local data, and ordinary third-party connection metadata.
+
## Acknowledgements
- **[LINUX DO](https://linux.do/)** community — the home for feedback on install experience, update links, and bug reports.
diff --git a/cloudflare/manager-download-router/README.md b/cloudflare/manager-download-router/README.md
index 920e514..b5ca148 100644
--- a/cloudflare/manager-download-router/README.md
+++ b/cloudflare/manager-download-router/README.md
@@ -12,14 +12,24 @@ the manager's **own** bucket so the two never mix:
a presigned **IHEP S3** URL, once the `SECONDARY_S3_*` secrets are set. Until
then CN also falls back to R2 (still far better than GitHub).
-The updater (`src-tauri/tauri.conf.json`) already checks
-`…/manager/latest.json` first, GitHub second — no app change needed.
+The updater checks the signed root `…/manager/release-identity.json(.sig)` before
+the unsigned `latest.json`, then falls back to GitHub. A mirror manifest is
+accepted only after its version,
+platform, artifact basename, release-note hash, updater signature, and SHA-256
+root authority (also retained under `/` for immutable audit/retry). This
+keeps mainland-China discovery/download on the mirror without
+letting the URL-rewritten (and therefore unsigned) `latest.json` invent a
+candidate or replay an older signed package as a higher version.
## Why a separate latest.json on the mirror
`latest.json`'s embedded signatures sign the artifact **bytes**, not the URL, so
re-hosting the same files keeps them valid — we only rewrite the download URLs to
-`…/manager//`. `scripts/sync-mirror.sh` does this on every
-(non-pre-)release.
+`…/manager//`. `scripts/sync-mirror.sh` stages stable releases,
+then verifies both storage endpoints and separately forces this Worker's R2 and
+IHEP branches before the GitHub Release becomes immutable. Probe responses identify
+their backend with `X-Codex-Mirror-Backend`; an explicit IHEP probe fails instead
+of falling back when its Worker secrets are incomplete. The release repeats that
+readback immediately before advancing the mirror's `latest.json` pointer.
Installers are uploaded under a **per-version** key (`/`) and
`latest.json` stays at the fixed root the updater polls. macOS updater tarballs
@@ -29,9 +39,18 @@ could serve a previous version's bytes against the new signature. (The seeded
v0.1.8 predates this and lives at flat `…/manager/` keys — still
self-consistent; v0.1.9+ use the versioned layout.)
-> ⚠️ The mirror's `latest.json` MUST be refreshed on every stable release, or the
-> first endpoint serves a stale version and **blocks** updates. That's why the
-> `release.yml` "Sync to CDN mirror" step exists.
+`release-identity.json` and `release-identity.json.sig` are also immutable
+per-version objects. Release reruns reuse a previously verified signature, and
+the sync script rejects a byte-different overwrite (the minisign trusted-comment
+timestamp otherwise makes a freshly generated signature differ on every run).
+After `latest.json` promotion, the root signature is written before the root JSON
+and both are read back through R2 and IHEP. The Worker marks both root files
+`no-store`; a mixed/interrupted pair fails verification and the client falls back
+to GitHub until a retry converges it.
+
+> ⚠️ The mirror pointers must be refreshed on every stable release. If they are
+> stale or inconsistent, the mirror path is skipped and the client falls back to
+> GitHub. That's why the `release.yml` stage, verify, and promote steps exist.
## Already provisioned (done)
- R2 bucket `codex-app-manager` created.
@@ -59,17 +78,28 @@ So each release uploads to the mirror (`release.yml` → `scripts/sync-mirror.sh
| Secret | Notes |
| --- | --- |
| `MANAGER_R2_S3_ENDPOINT` | `https://d39dc6c92d1c4cfde580bf13e946b616.r2.cloudflarestorage.com` |
-| `MANAGER_R2_ACCESS_KEY_ID` / `MANAGER_R2_SECRET_ACCESS_KEY` | R2 S3 API token (can reuse the mirror's; account-scoped) |
-| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` / `_ACCESS_KEY_ID` / `_SECRET_ACCESS_KEY` | IHEP creds (optional; `_REGION`/`_PREFIX` optional) |
+| `MANAGER_R2_PROMOTION_ACCESS_KEY_ID` / `MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY` | R2 S3 API token for the current protected release workflow |
+| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` environment variables | IHEP endpoint and bucket (`_REGION`/`_PREFIX` variables optional) |
+| `MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID` / `MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY` | IHEP token for the current protected release workflow |
+
+Both backends must already contain the same valid `latest.json` baseline and
+preserve custom user metadata through `HeadObject`. R2 is the sole CAS authority
+and must enforce conditional `PutObject` (`If-Match` / `If-None-Match`). IHEP is
+the serialized unconditional follower; its ignored conditional headers are not a
+promotion prerequisite. Delete the legacy access-key secret names after migration
+so historical workflow revisions cannot perform their old unconditional write.
-R2 secrets missing → step warns and skips (mirror goes stale). IHEP missing →
-CN just falls back to R2 via the worker.
+Deploy this Worker revision before enabling the protected release workflow. The
+release gate intentionally rejects an older Worker that cannot force and identify
+both public backend branches.
## Deploy / manual sync
```bash
wrangler deploy # from this directory
# manual re-sync of a tag's assets (download release → rewrite → upload):
-# gh release download vX.Y.Z -D dist && cp -f dist/latest.json dist/ && \
+# rm -rf dist && gh release download vX.Y.Z -D dist && \
# MANAGER_R2_S3_ENDPOINT=… MANAGER_R2_ACCESS_KEY_ID=… MANAGER_R2_SECRET_ACCESS_KEY=… \
+# MANAGER_IHEP_S3_ENDPOINT=… MANAGER_IHEP_S3_BUCKET=… \
+# MANAGER_IHEP_S3_ACCESS_KEY_ID=… MANAGER_IHEP_S3_SECRET_ACCESS_KEY=… \
# bash ../../scripts/sync-mirror.sh dist
```
diff --git a/cloudflare/manager-download-router/src/index.js b/cloudflare/manager-download-router/src/index.js
index 7856a06..7f01c13 100644
--- a/cloudflare/manager-download-router/src/index.js
+++ b/cloudflare/manager-download-router/src/index.js
@@ -38,6 +38,7 @@ export default {
}
const country = request.cf?.country || request.headers.get("CF-IPCountry") || "";
+ const probeBackend = requestedProbeBackend(url);
const secondaryCountryCodes = new Set(
(env.SECONDARY_COUNTRY_CODES || DEFAULT_SECONDARY_COUNTRY_CODES)
.split(",")
@@ -46,7 +47,19 @@ export default {
);
// ── Mainland China: presign the IHEP S3 object and redirect ──────────────
- if (secondaryCountryCodes.has(country.toUpperCase()) && hasSecondaryS3Config(env)) {
+ const secondaryConfigured = hasSecondaryS3Config(env);
+ if (probeBackend === "ihep" && !secondaryConfigured) {
+ return new Response("Secondary mirror is not configured", {
+ status: 503,
+ headers: { "X-Codex-Mirror-Backend": "ihep" },
+ });
+ }
+ const useSecondary =
+ probeBackend === "ihep" ||
+ (probeBackend !== "r2" &&
+ secondaryCountryCodes.has(country.toUpperCase()) &&
+ secondaryConfigured);
+ if (useSecondary) {
const objectKey = objectKeyForKey(key, env.SECONDARY_S3_PREFIX || "");
const signedUrl = await presignS3Url({
// Sign for the actual method: the redirect preserves it, and an
@@ -62,7 +75,7 @@ export default {
expiresInSeconds: ttlSeconds(env.SECONDARY_S3_SIGNED_URL_TTL_SECONDS),
responseHeaders: {},
});
- return redirect(signedUrl);
+ return redirect(signedUrl, "ihep");
}
// ── Everyone else: stream straight from the bound R2 bucket ──────────────
@@ -74,6 +87,7 @@ export default {
object.writeHttpMetadata(headers); // Content-Type etc. from R2 metadata
headers.set("ETag", object.httpEtag);
headers.set("Cache-Control", cacheControlForKey(key));
+ headers.set("X-Codex-Mirror-Backend", "r2");
if (request.method === "HEAD") {
return new Response(null, { headers });
}
@@ -81,9 +95,15 @@ export default {
},
};
-// latest.json must refresh quickly so new releases are seen; the (immutable,
-// versioned) installers can cache hard.
+// The two root identity files are a coordinated signed pointer. Never cache
+// either half: promotion writes the signature first and JSON last, and stale
+// mixing must resolve to a quick verification failure/fallback instead of a
+// day-long mirror outage. Other JSON refreshes quickly; immutable versioned
+// installers and signatures can cache hard.
function cacheControlForKey(key) {
+ if (key === "release-identity.json" || key === "release-identity.json.sig") {
+ return "no-store";
+ }
if (key.endsWith(".json")) return "public, max-age=120, s-maxage=120";
return "public, max-age=86400, s-maxage=86400";
}
@@ -97,16 +117,32 @@ function hasSecondaryS3Config(env) {
);
}
+// Release verification must exercise both geographic branches from a single
+// GitHub runner. This is a routing selector, not an authorization mechanism:
+// every addressed object is already public through this Worker. Requiring the
+// run-specific probe token shape prevents ordinary download links from changing
+// backend accidentally while allowing CI to force and identify each branch.
+function requestedProbeBackend(url) {
+ const token = url.searchParams.get("cam_probe") || "";
+ const backend = url.searchParams.get("cam_backend") || "";
+ if (!/^[0-9a-f]{24}$/.test(token)) return null;
+ return ["r2", "ihep"].includes(backend) ? backend : null;
+}
+
function ttlSeconds(value) {
const parsed = Number.parseInt(value || DEFAULT_SIGNED_URL_TTL_SECONDS, 10);
if (!Number.isFinite(parsed)) return DEFAULT_SIGNED_URL_TTL_SECONDS;
return Math.min(Math.max(parsed, 1), 604800);
}
-function redirect(location) {
+function redirect(location, backend) {
return new Response(null, {
status: 302,
- headers: { Location: location, "Cache-Control": "private, no-store" },
+ headers: {
+ Location: location,
+ "Cache-Control": "private, no-store",
+ "X-Codex-Mirror-Backend": backend,
+ },
});
}
diff --git a/cloudflare/manager-download-router/test/index.test.js b/cloudflare/manager-download-router/test/index.test.js
index e49431a..c29cd06 100644
--- a/cloudflare/manager-download-router/test/index.test.js
+++ b/cloudflare/manager-download-router/test/index.test.js
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import worker from "../src/index.js";
+const PROBE_TOKEN = "0123456789abcdef01234567";
+
const latestManifest = JSON.stringify({
version: "0.1.18",
platforms: {
@@ -140,6 +142,7 @@ describe("manager download router", () => {
const jsonRes = await worker.fetch(request("/manager/0.1.18/latest.json"), jsonEnv);
expect(jsonRes.status).toBe(200);
expect(jsonRes.headers.get("Cache-Control")).toBe("public, max-age=120, s-maxage=120");
+ expect(jsonRes.headers.get("X-Codex-Mirror-Backend")).toBe("r2");
const installerEnv = {
BUCKET: bucket({
@@ -154,6 +157,30 @@ describe("manager download router", () => {
expect(installerRes.headers.get("Cache-Control")).toBe("public, max-age=86400, s-maxage=86400");
});
+ it("never caches mutable root identity pointers while keeping versioned signatures immutable", async () => {
+ const env = {
+ BUCKET: bucket({
+ "release-identity.json": r2Object("{}", { contentType: "application/json" }),
+ "release-identity.json.sig": r2Object("root-signature"),
+ "0.1.18/release-identity.json.sig": r2Object("versioned-signature"),
+ }),
+ };
+
+ for (const key of ["release-identity.json", "release-identity.json.sig"]) {
+ const response = await worker.fetch(request(`/manager/${key}`), env);
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ }
+
+ const versioned = await worker.fetch(
+ request("/manager/0.1.18/release-identity.json.sig"),
+ env,
+ );
+ expect(versioned.headers.get("Cache-Control")).toBe(
+ "public, max-age=86400, s-maxage=86400",
+ );
+ });
+
it("redirects CN requests to a presigned IHEP URL when secondary S3 is configured", async () => {
const env = {
BUCKET: bucket({}),
@@ -166,6 +193,7 @@ describe("manager download router", () => {
);
expect(res.status).toBe(302);
+ expect(res.headers.get("X-Codex-Mirror-Backend")).toBe("ihep");
const location = new URL(res.headers.get("Location"));
expect(location.origin).toBe("https://s3.example.test");
expect(location.pathname).toBe("/root/mirror-bucket/manager/0.1.18/CodexAppManager_0.1.18_x64-setup.exe");
@@ -195,6 +223,42 @@ describe("manager download router", () => {
expect(calls).toEqual(["0.1.18/CodexAppManager_0.1.18_x64-setup.exe"]);
});
+ it("lets release probes force and identify both geographic backends", async () => {
+ const calls = [];
+ const path = "/manager/0.1.18/CodexAppManager_0.1.18_x64-setup.exe";
+ const env = {
+ BUCKET: bucket(
+ { "0.1.18/CodexAppManager_0.1.18_x64-setup.exe": r2Object("installer") },
+ calls,
+ ),
+ ...secondaryEnv(),
+ };
+
+ const forcedR2 = await worker.fetch(
+ request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=r2`, {}, { country: "CN" }),
+ env,
+ );
+ expect(forcedR2.status).toBe(200);
+ expect(forcedR2.headers.get("X-Codex-Mirror-Backend")).toBe("r2");
+ expect(await forcedR2.text()).toBe("installer");
+
+ const forcedIhep = await worker.fetch(
+ request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=ihep`, {}, { country: "US" }),
+ env,
+ );
+ expect(forcedIhep.status).toBe(302);
+ expect(forcedIhep.headers.get("X-Codex-Mirror-Backend")).toBe("ihep");
+ expect(new URL(forcedIhep.headers.get("Location")).origin).toBe("https://s3.example.test");
+ expect(calls).toEqual(["0.1.18/CodexAppManager_0.1.18_x64-setup.exe"]);
+
+ const missingSecondary = await worker.fetch(
+ request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=ihep`),
+ { BUCKET: bucket({}), ...secondaryEnv({ SECONDARY_S3_SECRET_ACCESS_KEY: "" }) },
+ );
+ expect(missingSecondary.status).toBe(503);
+ expect(missingSecondary.headers.get("X-Codex-Mirror-Backend")).toBe("ihep");
+ });
+
it("rejects empty keys, directory keys, and traversal-looking keys", async () => {
const calls = [];
const env = { BUCKET: bucket({}, calls) };
diff --git a/crates/codex-mac-engine/Cargo.lock b/crates/codex-mac-engine/Cargo.lock
index 3f55b8c..f17dde6 100644
--- a/crates/codex-mac-engine/Cargo.lock
+++ b/crates/codex-mac-engine/Cargo.lock
@@ -58,6 +58,7 @@ dependencies = [
"roxmltree",
"serde",
"thiserror",
+ "url",
"uuid",
]
@@ -133,6 +134,17 @@ dependencies = [
"crypto-common",
]
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -175,6 +187,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -254,12 +275,115 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -301,6 +425,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
[[package]]
name = "log"
version = "0.4.32"
@@ -319,6 +449,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -335,6 +471,15 @@ dependencies = [
"spki",
]
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -474,6 +619,12 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
[[package]]
name = "spki"
version = "0.7.3"
@@ -484,6 +635,12 @@ dependencies = [
"der",
]
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -501,6 +658,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -521,6 +689,16 @@ dependencies = [
"syn",
]
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -539,6 +717,24 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
[[package]]
name = "uuid"
version = "1.23.3"
@@ -753,12 +949,95 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/crates/codex-mac-engine/Cargo.toml b/crates/codex-mac-engine/Cargo.toml
index 80d1acf..dfa917e 100644
--- a/crates/codex-mac-engine/Cargo.toml
+++ b/crates/codex-mac-engine/Cargo.toml
@@ -14,6 +14,7 @@ base64 = "0.22"
log = "0.4"
uuid = { version = "1", features = ["v4"] }
libc = "0.2"
+url = "2.5"
[[bin]]
name = "mac_plan"
diff --git a/crates/codex-mac-engine/src/download.rs b/crates/codex-mac-engine/src/download.rs
index 745a914..94c4246 100644
--- a/crates/codex-mac-engine/src/download.rs
+++ b/crates/codex-mac-engine/src/download.rs
@@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use crate::limits::MAX_PACKAGE_BYTES;
-use crate::network::NetworkConfig;
+use crate::network::{safe_url_origin, NetworkConfig};
use crate::EngineError;
const CURL: &str = "/usr/bin/curl";
@@ -113,11 +113,17 @@ fn partial_path(dest: &Path) -> PathBuf {
dest.with_file_name(format!("{file_name}.part"))
}
-fn url_host(url: &str) -> &str {
- url.split("://")
- .nth(1)
- .and_then(|rest| rest.split('/').next())
- .unwrap_or("")
+fn curl_download_failure(url: &str, exit: &str) -> EngineError {
+ let source = safe_url_origin(url);
+ EngineError::Io(format!("curl download failed exit={exit} source={source}"))
+}
+
+fn curl_download_stalled(url: &str) -> EngineError {
+ let source = safe_url_origin(url);
+ EngineError::Io(format!(
+ "curl download stalled for {} seconds source={source}",
+ STALL_TIMEOUT.as_secs()
+ ))
}
fn is_cancelled_error(err: &EngineError) -> bool {
@@ -132,7 +138,7 @@ fn run_curl(
on_progress: &dyn Fn(u64),
network: &NetworkConfig,
) -> Result<(), EngineError> {
- let source = url_host(url);
+ let source = safe_url_origin(url);
let dest_arg = dest.to_string_lossy().into_owned();
let max_bytes = max_bytes.to_string();
let mut args = vec![
@@ -189,13 +195,11 @@ fn run_curl(
// Carry the curl exit code so the app-layer classifier can
// tell a connect / timeout / write failure apart (stderr is
// not piped for the streamed download).
- return Err(EngineError::Io(format!(
- "curl download failed exit={} url={url}",
- status
- .code()
- .map(|c| c.to_string())
- .unwrap_or_else(|| "signal".to_string()),
- )));
+ let exit = status
+ .code()
+ .map(|c| c.to_string())
+ .unwrap_or_else(|| "signal".to_string());
+ return Err(curl_download_failure(url, &exit));
}
break;
}
@@ -211,10 +215,7 @@ fn run_curl(
log::warn!(
"macOS download stalled source={source} downloaded={downloaded} stall_secs={stall_secs}"
);
- return Err(EngineError::Io(format!(
- "curl download stalled for {} seconds: {url}",
- STALL_TIMEOUT.as_secs()
- )));
+ return Err(curl_download_stalled(url));
}
on_progress(downloaded);
std::thread::sleep(Duration::from_millis(300));
@@ -258,7 +259,7 @@ pub fn download_to_with_progress_bounded_with_network(
let part = partial_path(dest);
let should_resume = part.metadata().map(|m| m.len() > 0).unwrap_or(false);
- let source = url_host(url);
+ let source = safe_url_origin(url);
let dest_name = dest
.file_name()
.and_then(|name| name.to_str())
@@ -312,6 +313,35 @@ pub fn read_file(path: &Path) -> Result, EngineError> {
mod tests {
use super::*;
+ #[test]
+ fn failure_diagnostics_omit_url_credentials_path_query_and_fragment() {
+ let raw = "https://basic-user:basic-pass@downloads.example:8443/private/file.zip?X-Amz-Credential=secret#fragment-secret";
+ let failed = curl_download_failure(raw, "22").to_string();
+ let stalled = curl_download_stalled(raw).to_string();
+
+ assert_eq!(
+ failed,
+ "io error: curl download failed exit=22 source=https://downloads.example:8443"
+ );
+ assert_eq!(
+ stalled,
+ format!(
+ "io error: curl download stalled for {} seconds source=https://downloads.example:8443",
+ STALL_TIMEOUT.as_secs()
+ )
+ );
+ for secret in [
+ "basic-user",
+ "basic-pass",
+ "/private/file.zip",
+ "X-Amz-Credential",
+ "fragment-secret",
+ ] {
+ assert!(!failed.contains(secret), "failure leaked {secret}");
+ assert!(!stalled.contains(secret), "stall leaked {secret}");
+ }
+ }
+
// Mirrors the Windows engine's guard test: the guard serializes downloads,
// pause vs cancel set the discard flag correctly, and dropping the guard
// clears every flag so the next download starts clean. No network/curl is
diff --git a/crates/codex-mac-engine/src/network.rs b/crates/codex-mac-engine/src/network.rs
index f9424f9..db6df04 100644
--- a/crates/codex-mac-engine/src/network.rs
+++ b/crates/codex-mac-engine/src/network.rs
@@ -1,5 +1,22 @@
use std::process::Command;
+use url::Url;
+
+/// Return only a URL's parsed origin for diagnostics. The original URL still
+/// goes to curl, but credentials, path, query, and fragment must never cross
+/// into persisted errors or logs.
+pub(crate) fn safe_url_origin(raw: &str) -> String {
+ let Ok(url) = Url::parse(raw.trim()) else {
+ return "".to_string();
+ };
+ let origin = url.origin().ascii_serialization();
+ if origin == "null" {
+ "".to_string()
+ } else {
+ origin
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyMode {
System,
@@ -65,7 +82,22 @@ impl Default for NetworkConfig {
#[cfg(test)]
mod tests {
- use super::NetworkConfig;
+ use super::{safe_url_origin, NetworkConfig};
+
+ #[test]
+ fn diagnostic_origin_strips_all_sensitive_url_components() {
+ assert_eq!(
+ safe_url_origin(
+ "https://basic-user:basic-pass@downloads.example:8443/private/file.zip?X-Amz-Credential=secret#fragment-secret",
+ ),
+ "https://downloads.example:8443"
+ );
+ assert_eq!(
+ safe_url_origin("https://downloads.example/public/file.zip"),
+ "https://downloads.example"
+ );
+ assert_eq!(safe_url_origin("not a URL secret-token"), "");
+ }
#[test]
fn direct_proxy_mode_disables_curl_proxy_resolution() {
diff --git a/crates/codex-mac-engine/src/sys.rs b/crates/codex-mac-engine/src/sys.rs
index 533c11b..dde70aa 100644
--- a/crates/codex-mac-engine/src/sys.rs
+++ b/crates/codex-mac-engine/src/sys.rs
@@ -10,7 +10,7 @@ use std::path::Path;
use std::process::Command;
use crate::limits::MAX_TEXT_BYTES;
-use crate::network::NetworkConfig;
+use crate::network::{safe_url_origin, NetworkConfig};
use crate::EngineError;
const CURL: &str = "/usr/bin/curl";
@@ -20,15 +20,12 @@ fn text_from_curl(url: &str, output: std::process::Output) -> Result MAX_TEXT_BYTES as usize {
return Err(EngineError::Io(format!(
@@ -38,6 +35,14 @@ fn text_from_curl(url: &str, output: std::process::Output) -> Result EngineError {
+ let source = safe_url_origin(url);
+ // Do not carry curl stderr across the persisted-log boundary: curl may
+ // repeat or normalize a credentialed/presigned URL there. Exit code plus
+ // safe origin preserve the classifier and actionable source diagnostics.
+ EngineError::Io(format!("curl failed for source={source} exit={exit}"))
+}
+
/// Fetch a small text resource (the appcast) over HTTPS via system `curl`.
pub fn fetch_text(url: &str) -> Result {
fetch_text_with_network(url, &NetworkConfig::system())
@@ -296,9 +301,7 @@ fn quarantine_flags_indicate_translocation(value: &str) -> bool {
const QTN_FLAG_DO_NOT_TRANSLOCATE: u32 = 0x0100;
let flags_hex = value.split(';').next().unwrap_or("").trim();
match u32::from_str_radix(flags_hex, 16) {
- Ok(flags) => {
- flags & QTN_FLAG_TRANSLOCATE != 0 && flags & QTN_FLAG_DO_NOT_TRANSLOCATE == 0
- }
+ Ok(flags) => flags & QTN_FLAG_TRANSLOCATE != 0 && flags & QTN_FLAG_DO_NOT_TRANSLOCATE == 0,
Err(_) => true,
}
}
@@ -373,6 +376,32 @@ pub fn read_bundle_short_version(app: &str) -> Option {
}
}
+#[cfg(test)]
+mod url_diagnostic_tests {
+ use super::text_curl_failure;
+
+ #[test]
+ fn text_fetch_failure_keeps_only_safe_origin_and_exit_code() {
+ let raw = "https://basic-user:basic-pass@appcast.example/feed.xml?token=presigned#private";
+ let error = text_curl_failure(raw, "22");
+ let diagnostic = error.to_string();
+
+ assert_eq!(
+ diagnostic,
+ "io error: curl failed for source=https://appcast.example exit=22"
+ );
+ for secret in [
+ "basic-user",
+ "basic-pass",
+ "/feed.xml",
+ "presigned",
+ "private",
+ ] {
+ assert!(!diagnostic.contains(secret), "diagnostic leaked {secret}");
+ }
+ }
+}
+
#[cfg(test)]
mod quarantine_tests {
use super::quarantine_flags_indicate_translocation;
@@ -442,7 +471,10 @@ mod tests {
installed_codex_build_at_path(&renamed),
Some((renamed.clone(), 5059))
);
- assert_eq!(read_bundle_identifier(&renamed).as_deref(), Some(CODEX_BUNDLE_ID));
+ assert_eq!(
+ read_bundle_identifier(&renamed).as_deref(),
+ Some(CODEX_BUNDLE_ID)
+ );
let _ = fs::remove_dir_all(&root);
}
diff --git a/crates/codex-win-engine/Cargo.lock b/crates/codex-win-engine/Cargo.lock
index 5d3728c..96d0a0f 100644
--- a/crates/codex-win-engine/Cargo.lock
+++ b/crates/codex-win-engine/Cargo.lock
@@ -60,6 +60,7 @@ dependencies = [
"serde_json",
"sha2",
"thiserror",
+ "url",
"uuid",
"windows-sys",
"zip",
@@ -153,6 +154,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -221,12 +231,115 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -268,6 +381,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
[[package]]
name = "log"
version = "0.4.32"
@@ -296,12 +415,27 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -420,6 +554,18 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
[[package]]
name = "syn"
version = "2.0.117"
@@ -431,6 +577,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -451,6 +608,16 @@ dependencies = [
"syn",
]
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -469,6 +636,24 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
[[package]]
name = "uuid"
version = "1.23.3"
@@ -692,6 +877,89 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "zip"
version = "2.4.2"
diff --git a/crates/codex-win-engine/Cargo.toml b/crates/codex-win-engine/Cargo.toml
index a535194..1a64c2d 100644
--- a/crates/codex-win-engine/Cargo.toml
+++ b/crates/codex-win-engine/Cargo.toml
@@ -14,6 +14,7 @@ sha2 = "0.10"
thiserror = "2.0"
uuid = { version = "1", features = ["v4"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
+url = "2.5"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_SystemInformation", "Win32_System_Threading"] }
diff --git a/crates/codex-win-engine/src/download.rs b/crates/codex-win-engine/src/download.rs
index bf98031..ebfc50b 100644
--- a/crates/codex-win-engine/src/download.rs
+++ b/crates/codex-win-engine/src/download.rs
@@ -5,7 +5,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
use sha2::{Digest, Sha256};
use crate::limits::MAX_PACKAGE_BYTES;
-use crate::network::{is_schannel_revocation_offline, NetworkConfig, SchannelRevocationCheck};
+use crate::network::{
+ is_schannel_revocation_offline, safe_curl_failure_message, safe_url_host, NetworkConfig,
+ SchannelRevocationCheck,
+};
use crate::process::{
curl_exe, hidden_command, run_with_progress, RunError, RunLimits, TimeoutKind,
};
@@ -91,13 +94,6 @@ fn partial_path(dest: &Path) -> PathBuf {
dest.with_file_name(format!("{file_name}.part"))
}
-fn url_host(url: &str) -> &str {
- url.split("://")
- .nth(1)
- .and_then(|rest| rest.split('/').next())
- .unwrap_or("")
-}
-
fn proxy_env_summary() -> String {
let vars = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"];
let configured = vars
@@ -116,14 +112,7 @@ fn proxy_env_summary() -> String {
}
fn curl_failure_message(url: &str, exit_code: Option, stderr: &str) -> String {
- let base = format!(
- "curl failed for host={} exit={}: stderr='{}'",
- url_host(url),
- exit_code
- .map(|code| code.to_string())
- .unwrap_or_else(|| "signal".to_string()),
- stderr.trim(),
- );
+ let base = safe_curl_failure_message(url, exit_code, stderr);
// Append the proxy diagnostic only for connectivity failures — pasting it
// onto write / disk / HTTP errors (e.g. exit 23) only misleads.
if is_connectivity_exit(exit_code) {
@@ -230,9 +219,7 @@ fn run_curl_once(
let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
return match kind {
TimeoutKind::Stall => {
- log::warn!(
- "Windows download stalled source={source} downloaded={downloaded}"
- );
+ log::warn!("Windows download stalled source={source} downloaded={downloaded}");
Err(CurlAttemptError::Other(format!(
"curl download stalled (no progress) source={source} downloaded={downloaded}"
)))
@@ -274,8 +261,8 @@ struct CurlRun<'a> {
}
impl CurlRun<'_> {
- fn source(&self) -> &str {
- url_host(self.url)
+ fn source(&self) -> String {
+ safe_url_host(self.url)
}
}
@@ -293,7 +280,7 @@ fn run_curl_with_mode(
run.network,
revocation_check,
);
- run_curl_once(run.source(), run.dest, args, run.on_progress)
+ run_curl_once(&run.source(), run.dest, args, run.on_progress)
}
fn retry_with_schannel_no_revoke(
@@ -326,7 +313,7 @@ fn run_curl(
on_progress: &dyn Fn(u64),
network: &NetworkConfig,
) -> Result<(), String> {
- let source = url_host(url);
+ let source = safe_url_host(url);
let dest = dest.to_string_lossy().into_owned();
let max_bytes = max_bytes.to_string();
let run = CurlRun {
@@ -440,7 +427,7 @@ pub fn download_to_with_progress_bounded_with_network(
let part = partial_path(dest);
let should_resume = part.metadata().map(|m| m.len() > 0).unwrap_or(false);
- let source = url_host(url);
+ let source = safe_url_host(url);
let dest_name = dest
.file_name()
.and_then(|name| name.to_str())
@@ -532,6 +519,26 @@ mod tests {
assert!(!cancel_active_download());
}
+ #[test]
+ fn persisted_download_failure_uses_safe_host_and_reason_only() {
+ let raw = "https://basic-user:basic-pass@downloads.example/private/Codex.msix?token=presigned-secret#fragment-secret";
+ let stderr = format!("curl: (6) Could not resolve host while requesting {raw}");
+ let message = curl_failure_message(raw, Some(6), &stderr);
+
+ assert!(message.starts_with(
+ "curl failed for host=downloads.example exit=6 reason='DNS resolution failed'"
+ ));
+ for secret in [
+ "basic-user",
+ "basic-pass",
+ "/private/Codex.msix",
+ "presigned-secret",
+ "fragment-secret",
+ ] {
+ assert!(!message.contains(secret), "download error leaked {secret}");
+ }
+ }
+
#[test]
fn curl_args_keep_modern_progress_flag_by_default() {
let args = curl_args(
@@ -620,7 +627,7 @@ mod tests {
seen.lock().unwrap().push(downloaded);
});
if let Err(EngineError::Io(message)) = &result {
- if message.contains("Protocol \"file\" disabled") {
+ if message.contains("reason='protocol disabled'") {
let _ = std::fs::remove_dir_all(&root);
return;
}
diff --git a/crates/codex-win-engine/src/network.rs b/crates/codex-win-engine/src/network.rs
index f898860..a4ceeaf 100644
--- a/crates/codex-win-engine/src/network.rs
+++ b/crates/codex-win-engine/src/network.rs
@@ -1,5 +1,66 @@
use std::process::Command;
+use url::Url;
+
+/// Parsed host (plus explicit port) for diagnostics. The original URL remains
+/// available to curl, but credentials, path, query, and fragment never cross
+/// into progress events, errors, or persisted logs.
+pub(crate) fn safe_url_host(raw: &str) -> String {
+ let Ok(url) = Url::parse(raw.trim()) else {
+ return "".to_string();
+ };
+ let origin = url.origin().ascii_serialization();
+ if origin == "null" {
+ return "".to_string();
+ }
+ origin
+ .split_once("://")
+ .map(|(_, host)| host.to_string())
+ .unwrap_or_else(|| "".to_string())
+}
+
+/// Convert curl stderr into a fixed diagnostic category. Raw stderr is used
+/// transiently for retry decisions, but must not be persisted because curl can
+/// repeat or normalize a credentialed/presigned URL in its message.
+pub(crate) fn safe_curl_failure_message(url: &str, exit_code: Option, stderr: &str) -> String {
+ let host = safe_url_host(url);
+ let exit = exit_code
+ .map(|code| code.to_string())
+ .unwrap_or_else(|| "signal".to_string());
+ let lower = stderr.to_ascii_lowercase();
+ let reason = if lower.contains("no space left")
+ || lower.contains("not enough space")
+ || lower.contains("disk is full")
+ {
+ Some("disk is full")
+ } else if lower.contains("access is denied") || lower.contains("permission denied") {
+ Some("permission denied")
+ } else if lower.contains("failure writing output") || lower.contains("write error") {
+ Some("write error")
+ } else if lower.contains("protocol") && lower.contains("disabled") {
+ Some("protocol disabled")
+ } else if lower.contains("could not resolve") {
+ Some("DNS resolution failed")
+ } else if lower.contains("failed to connect") || lower.contains("connection refused") {
+ Some("connection failed")
+ } else if lower.contains("timed out") || lower.contains("timeout") {
+ Some("timeout")
+ } else if lower.contains("schannel")
+ || lower.contains("ssl")
+ || lower.contains("tls")
+ || lower.contains("certificate")
+ {
+ Some("TLS failure")
+ } else {
+ None
+ };
+
+ match reason {
+ Some(reason) => format!("curl failed for host={host} exit={exit} reason='{reason}'"),
+ None => format!("curl failed for host={host} exit={exit}"),
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SchannelRevocationCheck {
Strict,
@@ -101,7 +162,35 @@ fn push_schannel_no_revoke(_args: &mut Vec) {}
#[cfg(test)]
mod tests {
- use super::{is_schannel_revocation_offline, NetworkConfig, SchannelRevocationCheck};
+ #[cfg(windows)]
+ use super::SchannelRevocationCheck;
+ use super::{
+ is_schannel_revocation_offline, safe_curl_failure_message, safe_url_host, NetworkConfig,
+ };
+
+ #[test]
+ fn diagnostic_host_and_curl_error_strip_sensitive_url_components() {
+ let raw = "https://basic-user:basic-pass@downloads.example:8443/private/Codex.msix?X-Amz-Signature=presigned-secret#fragment-secret";
+ assert_eq!(safe_url_host(raw), "downloads.example:8443");
+
+ let stderr = format!("curl: (23) No space left while requesting {raw}");
+ let message = safe_curl_failure_message(raw, Some(23), &stderr);
+ assert_eq!(
+ message,
+ "curl failed for host=downloads.example:8443 exit=23 reason='disk is full'"
+ );
+ for secret in [
+ "basic-user",
+ "basic-pass",
+ "/private/Codex.msix",
+ "X-Amz-Signature",
+ "presigned-secret",
+ "fragment-secret",
+ ] {
+ assert!(!message.contains(secret), "diagnostic leaked {secret}");
+ }
+ assert_eq!(safe_url_host("not a URL with-secret"), "");
+ }
#[test]
fn direct_proxy_mode_disables_curl_proxy_resolution() {
diff --git a/crates/codex-win-engine/src/sys.rs b/crates/codex-win-engine/src/sys.rs
index b09f043..7b36482 100644
--- a/crates/codex-win-engine/src/sys.rs
+++ b/crates/codex-win-engine/src/sys.rs
@@ -10,10 +10,13 @@ use crate::capability::WinCapabilityReport;
use crate::capability::{CapabilityCheck, CapabilityState};
use crate::limits::MAX_TEXT_BYTES;
use crate::msix::parse_appx_manifest_xml;
-use crate::network::{is_schannel_revocation_offline, NetworkConfig, SchannelRevocationCheck};
+use crate::network::{
+ is_schannel_revocation_offline, safe_curl_failure_message, safe_url_host, NetworkConfig,
+ SchannelRevocationCheck,
+};
use crate::process::{
- curl_exe, hidden_command, run_capturing, spawn_and_require_liveness, LivenessResult,
- RunError, RunLimits, TimeoutKind, MSIX_ACTIVATION_WINDOW_SECS, MSIX_LIVENESS_WINDOW_SECS,
+ curl_exe, hidden_command, run_capturing, spawn_and_require_liveness, LivenessResult, RunError,
+ RunLimits, TimeoutKind, MSIX_ACTIVATION_WINDOW_SECS, MSIX_LIVENESS_WINDOW_SECS,
PORTABLE_LIVENESS_WINDOW,
};
use crate::EngineError;
@@ -190,12 +193,16 @@ fn fetch_text_output(
]);
// curl's own --max-time is the primary budget; the outer deadline is a
// backstop that also kills a hung curl that ignored max-time.
- run_capturing(command, RunLimits::total(std::time::Duration::from_secs(75)), None)
- .map_err(|e| EngineError::Io(format!("curl: {}", e.message())))
+ run_capturing(
+ command,
+ RunLimits::total(std::time::Duration::from_secs(75)),
+ None,
+ )
+ .map_err(|e| EngineError::Io(format!("curl: {}", e.message())))
}
pub fn fetch_text_with_network(url: &str, network: &NetworkConfig) -> Result {
- let source = url_host(url);
+ let source = safe_url_host(url);
log::debug!("fetch Windows text source={source}");
let mut output = fetch_text_output(url, network, SchannelRevocationCheck::Strict)?;
let should_retry_without_revocation = {
@@ -233,14 +240,7 @@ pub fn fetch_text_with_network(url: &str, network: &NetworkConfig) -> Result, stderr: &str) -> String {
- let base = format!(
- "curl failed for host={} exit={}: stderr='{}'",
- url_host(url),
- exit_code
- .map(|code| code.to_string())
- .unwrap_or_else(|| "signal".to_string()),
- stderr.trim(),
- );
+ let base = safe_curl_failure_message(url, exit_code, stderr);
// Append the proxy diagnostic only for connectivity failures — pasting it
// onto write / disk / HTTP errors (e.g. exit 23) only misleads.
if is_connectivity_exit(exit_code) {
@@ -276,13 +276,6 @@ fn is_connectivity_exit(exit_code: Option) -> bool {
)
}
-fn url_host(url: &str) -> &str {
- url.split("://")
- .nth(1)
- .and_then(|rest| rest.split('/').next())
- .unwrap_or("")
-}
-
fn proxy_env_summary() -> String {
let vars = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"];
let configured = vars
@@ -300,6 +293,30 @@ fn proxy_env_summary() -> String {
}
}
+#[cfg(test)]
+mod curl_diagnostic_tests {
+ use super::curl_failure_message;
+
+ #[test]
+ fn text_fetch_failure_never_persists_url_or_raw_stderr_secrets() {
+ let raw = "https://basic-user:basic-pass@manifest.example/latest/manifest?token=presigned-secret#fragment-secret";
+ let stderr = format!("curl: (35) TLS failed while requesting {raw}");
+ let message = curl_failure_message(raw, Some(35), &stderr);
+
+ assert!(message
+ .starts_with("curl failed for host=manifest.example exit=35 reason='TLS failure'"));
+ for secret in [
+ "basic-user",
+ "basic-pass",
+ "/latest/manifest",
+ "presigned-secret",
+ "fragment-secret",
+ ] {
+ assert!(!message.contains(secret), "text error leaked {secret}");
+ }
+ }
+}
+
pub fn detect_installed_codex(portable_root: &Path) -> Option {
detect_msix_install().or_else(|| detect_portable_install(portable_root))
}
@@ -1016,9 +1033,7 @@ if ($statusOk -and $aumidResolved -and $missing.Count -eq 0) {{
let parsed = match &run_result {
Ok(json) => serde_json::from_str::(json).ok(),
Err(PowerShellRunError::Timeout(kind)) => {
- log::info!(
- "MSIX health check result healthy=false status=timeout kind={kind:?}"
- );
+ log::info!("MSIX health check result healthy=false status=timeout kind={kind:?}");
// Start-Process detaches; kill any process the timed-out probe left running.
best_effort_close_msix_after_probe();
return MsixHealthReport {
@@ -1252,7 +1267,8 @@ pub fn detect_portable_install(portable_root: &Path) -> Option {
- let asar_name = crate::app_version::read_asar_package_name_from_install_root(portable_root);
+ let asar_name =
+ crate::app_version::read_asar_package_name_from_install_root(portable_root);
if asar_name.as_deref() != Some(crate::app_version::CODEX_ASAR_PACKAGE_NAME) {
log::debug!(
"portable root at {} has no manifest and its app payload name is {:?} (expected {}); not a Codex install",
@@ -1297,13 +1313,12 @@ pub fn launch_codex_with_options(
) -> Result<(), EngineError> {
if installed.source == "portable" {
let root = Path::new(&installed.path);
- let exe = crate::portable::installed_app_exe(root)
- .ok_or_else(|| {
- EngineError::Io(format!(
- "no app entry executable (ChatGPT.exe / Codex.exe) in {}",
- root.display()
- ))
- })?;
+ let exe = crate::portable::installed_app_exe(root).ok_or_else(|| {
+ EngineError::Io(format!(
+ "no app entry executable (ChatGPT.exe / Codex.exe) in {}",
+ root.display()
+ ))
+ })?;
// CREATE_NO_WINDOW only suppresses a console flash; the GUI still shows.
// Require a short liveness window so an immediate crash is reported as a
// launch failure instead of a silent no-op.
@@ -1321,10 +1336,7 @@ pub fn launch_codex_with_options(
code.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string())
))),
- Err(err) => Err(EngineError::Io(format!(
- "launch Codex: {}",
- err.message()
- ))),
+ Err(err) => Err(EngineError::Io(format!("launch Codex: {}", err.message()))),
}
} else {
if options.disable_codex_self_updates {
diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md
new file mode 100644
index 0000000..670ef6b
--- /dev/null
+++ b/docs/code-signing-policy.md
@@ -0,0 +1,182 @@
+# Code signing policy · 代码签名政策
+
+Last updated / 最后更新:2026-07-11
+
+## 当前状态 · Current status
+
+Codex App Manager 正在申请 SignPath Foundation,并评估从 GitHub Actions trusted build
+接入 Windows Authenticode 的可行方案。**申请尚未获批,证书尚未签发,SignPath 签名流水线
+尚未启用;当前 Windows 安装器仍未带 Authenticode 发行者签名。** 在下列构建、审批和验证
+条件全部满足前,tag 驱动的 Windows 正式发布会 fail closed,不会悄悄回退为 unsigned 发布。
+
+Codex App Manager is applying to SignPath Foundation and evaluating a GitHub
+Actions trusted-build integration for Windows Authenticode. **The application
+has not yet been approved, no certificate has been issued, and no SignPath
+signing pipeline is active. Current Windows installers remain unsigned.** The
+tag-driven Windows release path fails closed until all build, approval, and
+verification requirements below are met; it must never silently fall back to
+publishing unsigned artifacts.
+
+If the application is approved and the integration is activated, the required
+provider attribution will be:
+
+> Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)
+
+This sentence records the intended attribution and does not claim that the
+current downloads are signed by SignPath.
+
+## 适用范围 · Scope
+
+- 本政策只适用于本仓库以 MIT 许可证发布、由本项目构建的 Codex App Manager 工件。
+- Foundation 证书只会签署项目自身拥有和维护的可执行文件或安装器,不会用于其他项目、
+ 私有软件、商业软件或个人文件。
+- OpenAI Codex 桌面应用不会被嵌入或重新打包进 Manager 安装器;Manager 运行后按用户选择
+ 下载官方 Codex 工件。
+- 第三方依赖(包括 NSIS/Tauri 随附插件)可以作为开源依赖被打包,但不得被当作本项目
+ 自有二进制直接使用 Foundation 证书签名。接入方案必须证明这一边界。
+
+- This policy covers only Codex App Manager artifacts built from this MIT-
+ licensed public repository.
+- A Foundation certificate will be used only for executables or installers
+ owned and maintained by this project. It will not be used for other projects,
+ private or commercial software, or personal files.
+- The OpenAI Codex desktop app is not embedded or repackaged in the Manager
+ installer. The Manager downloads official Codex artifacts after launch when
+ the user requests an operation.
+- Third-party dependencies, including plugins supplied with NSIS or Tauri, may
+ be bundled as open-source dependencies but must not be directly signed as
+ project-owned binaries with the Foundation certificate. The final integration
+ must prove this boundary.
+
+## 角色与人工审批 · Roles and manual approval
+
+签名发布采用职责明确、逐次人工批准的流程:
+
+1. **Author**:常规源码、依赖或发布配置变更必须通过 pull request 和 required checks
+ 进入 `main`。仓库管理员技术上拥有紧急 bypass;若确需使用,必须保留审计记录,并在
+ 发起任何签名请求前由 Reviewer 重新核对 bypass diff、commit 与 CI。
+2. **Reviewer**:在合并前审查变更和必需 CI;不得用自动化结果替代代码审查。
+3. **Release approver**:核对 tag、公开源码、构建来源、目标版本和待签工件后,在
+ SignPath 中对**每一次**签名请求进行人工批准。不得自动批准、批量预批准或复用旧请求。
+4. 角色成员及权限会在 SignPath 项目获批后按最小权限配置;GitHub 与 SignPath 账户必须
+ 启用双因素认证。角色或权限变化必须可审计。
+
+当前团队成员(公开 GitHub 身份):
+
+| Role | Member | 说明 |
+|---|---|---|
+| Committer / Author | [@Wangnov](https://github.com/Wangnov) | 维护仓库、准备 maintainer PR 与 release tag;外部贡献者只通过 PR 提交,不自动取得此角色。 |
+| Reviewer | [@Wangnov](https://github.com/Wangnov) | 审查外部 PR;对维护者自己的变更逐项复核 diff 与必需 CI。 |
+| SignPath Approver | [@Wangnov](https://github.com/Wangnov) | 申请获批后,对每次签名请求执行单独的人工审批。 |
+
+本项目目前是单维护者项目,因此同一人会兼任多个角色;这不会把签名批准自动化,也不会
+省略每次请求的人工作业。如果 SignPath 的最终配置要求不同人员之间的职责分离,项目会在
+启用生产签名前公开增补成员并更新本表。
+
+The signing release process uses explicit responsibilities and per-request
+human approval:
+
+1. **Author** — normal source, dependency, and release-configuration changes
+ must enter `main` through a pull request and required checks. Repository
+ administrators technically retain an emergency bypass; if it is ever used,
+ the action must remain auditable and the Reviewer must re-check the bypassed
+ diff, commit, and CI before any signing request is submitted.
+2. **Reviewer** — reviews the change and required CI before merge; automated
+ checks do not replace code review.
+3. **Release approver** — checks the tag, public source, build origin, target
+ version, and candidate artifacts, then manually approves **each** SignPath
+ signing request. Automatic approval, advance bulk approval, and reuse of an
+ old request are prohibited.
+4. Named role assignments and least-privilege access will be configured after
+ the SignPath project is approved. GitHub and SignPath accounts must use
+ two-factor authentication, and role changes must remain auditable.
+
+Current team members (public GitHub identities):
+
+| Role | Member | Responsibility |
+|---|---|---|
+| Committer / Author | [@Wangnov](https://github.com/Wangnov) | Maintains the repository and prepares maintainer PRs and release tags. External contributors submit PRs and do not automatically receive this role. |
+| Reviewer | [@Wangnov](https://github.com/Wangnov) | Reviews external PRs and explicitly re-checks the diff and required CI for maintainer-authored changes. |
+| SignPath Approver | [@Wangnov](https://github.com/Wangnov) | After approval, manually approves each individual signing request. |
+
+This is currently a single-maintainer project, so one person holds multiple
+roles. That does not automate signing approval or remove the manual action for
+each request. If the final SignPath configuration requires separation between
+different people, the project will add members publicly and update this table
+before production signing is enabled.
+
+## Trusted build 与发布门 · Trusted build and release gates
+
+未来的 SignPath 接入必须使用 SignPath 验证过的 GitHub Actions trusted build/origin,且只
+接受来自本公开仓库受保护 tag 的工件。正式启用前必须通过一次可复现的试签与审查,证明:
+
+- 签名请求绑定准确的仓库、commit、tag、版本和 GitHub Actions run;
+- 待签文件的产品名和版本资源与 tag 一致;
+- x64 与 ARM64 的最终安装器、安装后的主程序和卸载器均满足设计的 Authenticode 边界,
+ 且没有把 Foundation 证书直接用于第三方插件;
+- 每个要求签名的 PE 都显示预期的 SignPath Foundation 发行者并带有效时间戳;
+- Tauri updater `.sig` 在 Authenticode 之后覆盖最终发布字节;它与 Authenticode 是两套
+ 独立的信任机制;
+- 最终上传到 GitHub Release 和镜像的文件与已验证文件逐字节一致。
+
+Until that review is complete, [`release.yml`](../.github/workflows/release.yml)
+intentionally blocks Windows tag jobs before building. Removing the blocker
+alone cannot make a release valid: unsigned artifacts must still fail the
+required post-build verification gates. See
+[`Windows signing and verification`](./windows-signing.md) for the operational
+status and unresolved integration boundary.
+
+## 隐私与网络行为 · Privacy and network behavior
+
+完整、可稳定链接的隐私政策见 [`docs/privacy.md`](./privacy.md)。以下为与代码签名申请
+直接相关的网络行为摘要。
+
+See the stable, standalone [`privacy policy`](./privacy.md) for the complete
+disclosure. The following is the network-behavior summary relevant to the code
+signing application.
+
+Codex App Manager 不提供账户系统,也不包含遥测、广告或行为分析 SDK,不会主动上传用户
+身份、文件内容或使用画像。应用仍会为完成其公开功能发起网络请求:
+
+- Manager 启动后以及保持打开期间会检查自身更新;发现更新后由用户决定是否安装。
+- Codex 的更新检查可在设置中关闭启动检查和定时检查;安装或更新操作会按用户选择访问
+ 镜像、GitHub、OpenAI 官方源或用户配置的自定义源。
+- 下载可使用系统代理、直连或用户配置的代理。所选服务会像任何 HTTPS 服务一样看到
+ 正常的连接元数据,例如来源 IP、时间、请求路径和 User-Agent;其日志受各服务自己的
+ 隐私政策和保留规则约束。
+- 中国大陆的 `codexapp.agentsmirror.com` 请求可能由 Cloudflare 按地区路由到 IHEP S3;
+ 其他地区通常由 Cloudflare R2 提供。GitHub 是 Manager 自更新的后备源。
+- 本地诊断日志可能记录版本、平台、更新源 host 和错误信息,但不应记录私钥、密码、
+ SignPath token 或下载 URL 中的临时签名参数。用户只有在主动提交 Issue/诊断信息时才会
+ 把这些本地内容发送给项目维护者。
+
+Codex App Manager has no account system and includes no telemetry, advertising,
+or behavioral analytics SDK. It does not intentionally upload user identity,
+file contents, or usage profiles. It still makes network requests to provide
+its documented functionality:
+
+- The Manager checks for its own updates after startup and periodically while
+ open; the user chooses whether to install an available update.
+- Startup and periodic update checks for Codex can be disabled in Settings.
+ Install or update operations contact the selected mirror, GitHub, official
+ OpenAI source, or a user-configured custom source.
+- Downloads can use the system proxy, direct mode, or a custom proxy. Like any
+ HTTPS service, the selected endpoint can observe ordinary connection metadata
+ such as source IP, time, request path, and User-Agent; its own privacy and
+ retention policies apply.
+- Mainland-China requests to `codexapp.agentsmirror.com` may be routed by
+ Cloudflare to IHEP S3; other regions are normally served from Cloudflare R2.
+ GitHub is a fallback source for Manager self-updates.
+- Local diagnostic logs may contain versions, platform, update-source host, and
+ errors, but must not contain private keys, passwords, SignPath tokens, or
+ temporary signed query strings. Local diagnostics reach maintainers only when
+ a user deliberately includes them in an issue or support request.
+
+## 参考 · References
+
+- [SignPath Foundation terms](https://signpath.org/terms.html)
+- [SignPath GitHub trusted build-system integration](https://docs.signpath.io/trusted-build-systems/github)
+- [SignPath origin verification](https://docs.signpath.io/origin-verification/)
+- [Privacy policy](./privacy.md)
+- [Windows signing and verification](./windows-signing.md)
+- [Release process](./release.md)
diff --git a/docs/privacy.md b/docs/privacy.md
new file mode 100644
index 0000000..8e15523
--- /dev/null
+++ b/docs/privacy.md
@@ -0,0 +1,140 @@
+# Privacy policy · 隐私政策
+
+Effective date / 生效日期:2026-07-11
+
+This policy applies to Codex App Manager, its public website, and the download
+router maintained in this repository. It does not replace the privacy policies
+of GitHub, Cloudflare, IHEP, OpenAI, Microsoft, or a custom endpoint/proxy chosen
+by the user.
+
+本政策适用于 Codex App Manager、本项目官网以及本仓库维护的下载路由。GitHub、
+Cloudflare、IHEP、OpenAI、Microsoft,以及用户自行选择的自定义源或代理,分别适用其
+自己的隐私政策,本政策不能替代这些第三方政策。
+
+## 我们不收集什么 · What we do not collect
+
+- 应用没有账户系统,不要求姓名、邮箱、电话或付款信息。
+- 应用不包含遥测、广告、崩溃上报或行为分析 SDK,也不会为画像目的上传使用行为。
+- 项目不会主动读取或上传 `~/.codex` 中的对话、凭据、工作区文件或其他用户内容。
+- 本地日志和设置不会自动发送给维护者。只有用户主动附在 Issue 或支持请求中时,维护者
+ 才会收到用户选择提交的内容。
+
+- The app has no account system and does not request a name, email address,
+ phone number, or payment information.
+- The app includes no telemetry, advertising, crash-reporting, or behavioral
+ analytics SDK and does not upload usage activity for profiling.
+- The project does not intentionally read or upload conversations, credentials,
+ workspace files, or other user content from `~/.codex`.
+- Local logs and settings are not automatically sent to maintainers. Maintainers
+ receive only the material a user deliberately attaches to an issue or support
+ request.
+
+## 应用会发起的网络请求 · Network requests made by the app
+
+网络请求用于更新检查、下载和校验,不用于遥测:
+
+- **Manager 自身更新**:应用启动约 1.5 秒后检查一次;保持打开时约每 6 小时检查一次。
+ 当前版本没有关闭 Manager 自身检查的设置。发现新版本后由用户决定是否安装。
+- **Codex 更新**:默认在 Manager 主界面启动时检查,并在应用保持打开时按设置周期检查。
+ 用户可分别关闭启动检查和定时检查,也可调整定时间隔。
+- **安装、更新和校验**:用户发起操作时,应用会访问所选的镜像、GitHub、OpenAI 官方
+ 源或用户配置的自定义 HTTPS 源,以取得 manifest、appcast、checksum 和安装包。
+- **代理**:请求可使用系统代理、直连或用户配置的代理。自定义代理能看到经其转发的
+ 目标 host 和正常连接元数据。
+
+These requests provide update, download, and verification functionality, not
+telemetry:
+
+- **Manager self-update** — one check runs about 1.5 seconds after startup and
+ another runs about every six hours while the app remains open. The current
+ version has no setting to disable Manager self-update checks. The user decides
+ whether to install an available update.
+- **Codex updates** — by default, the Manager checks when its main screen starts
+ and periodically while open. Users can independently disable startup and
+ periodic checks and can change the periodic interval.
+- **Install, update, and verification** — when the user starts an operation, the
+ app contacts the selected mirror, GitHub, official OpenAI source, or a custom
+ HTTPS source to obtain manifests, appcasts, checksums, and installers.
+- **Proxy behavior** — requests can use the system proxy, direct mode, or a
+ user-configured proxy. A custom proxy can observe destination hosts and
+ ordinary connection metadata for traffic it handles.
+
+## 服务与连接元数据 · Services and connection metadata
+
+默认网络路径可能包括:
+
+- `codexapp.agentsmirror.com`:由 Cloudflare Worker 提供;全球通常使用 Cloudflare R2,
+ 中国大陆请求可能按 `CF-IPCountry` 路由到 IHEP S3 的临时预签名地址。
+- `github.com`:Manager 自更新后备源及公开 release 下载。
+- `persistent.oaistatic.com` 等 OpenAI 官方 host:用户选择官方源时获取 Codex appcast 或
+ 工件。
+- 用户配置的自定义 HTTPS 源或代理。
+
+相关第三方隐私政策:
+
+- [GitHub General Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement)
+- [Cloudflare Privacy Policy](https://www.cloudflare.com/privacypolicy/)
+- [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy/)
+- [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement)
+- IHEP S3 为中国大陆镜像节点。截至本政策日期,项目未找到该镜像端点单独公开的隐私政策;
+ 相关处理受 IHEP/CAS 的适用服务条款与法律义务约束。可从
+ [IHEP 官网](https://english.ihep.cas.cn/) 查询或联系其机构。若发现适用的官方政策,
+ 本页会补充直接链接。
+
+这些服务与普通 HTTPS 服务一样,可能处理来源 IP、请求时间、请求路径、User-Agent、
+响应状态以及防滥用/安全日志。项目维护者无法代表第三方承诺其保留期限;请查阅所选服务
+的政策。官网自身不嵌入广告或分析脚本,但托管和路由服务仍可能生成正常的访问日志。
+
+Default paths may include `codexapp.agentsmirror.com` (Cloudflare Worker, R2,
+and possible IHEP S3 routing for mainland China), `github.com` (fallback update
+and public release downloads), official OpenAI hosts such as
+`persistent.oaistatic.com` when selected, and a custom HTTPS source or proxy.
+Like ordinary HTTPS services, these providers may process source IP, request
+time and path, User-Agent, response status, and security/abuse logs. Project
+maintainers cannot promise third-party retention periods. The project website
+embeds no advertising or analytics script, but hosting and routing providers
+may still produce ordinary access logs.
+
+Third-party privacy policies:
+
+- [GitHub General Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement)
+- [Cloudflare Privacy Policy](https://www.cloudflare.com/privacypolicy/)
+- [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy/)
+- [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement)
+- IHEP S3 hosts the mainland-China mirror node. As of this policy date, the
+ project has not identified a standalone public privacy policy for that mirror
+ endpoint. Applicable IHEP/CAS service terms and legal obligations govern its
+ processing; consult the [IHEP website](https://english.ihep.cas.cn/) or the
+ institution directly. This page will add a direct link if an applicable
+ official policy is identified.
+
+## 本地数据 · Data stored locally
+
+应用在本机保存运行所需的设置、安装来源信息、更新状态、操作状态和诊断日志。诊断信息可
+包含应用版本、操作系统、架构、更新源 host、安装状态和错误文本。它不应包含代码签名
+私钥、密码、SignPath token,或下载 URL 的临时签名 query;发现这类泄漏应按安全问题
+处理。卸载 Manager 不等同于删除 Codex 自己的数据;卸载界面会明确说明保留范围。
+
+The app stores settings, installation provenance, update state, operation state,
+and diagnostic logs locally. Diagnostics can include app version, operating
+system, architecture, update-source host, install state, and error text. They
+must not contain code-signing private keys, passwords, SignPath tokens, or
+temporary signed URL queries; any such leak is a security issue. Uninstalling
+the Manager is not the same as deleting Codex data, and the uninstall UI states
+what is retained.
+
+## 用户选择与反馈 · User choices and contact
+
+用户可以关闭 Codex 的启动/定时检查、选择更新源和代理,并在提交 Issue 前查看和删改诊断
+内容。隐私或安全问题请通过
+[GitHub Issues](https://github.com/Wangnov/Codex-App-Manager/issues) 报告;请勿公开粘贴
+token、密码、私钥、完整预签名 URL 或其他秘密。
+
+Users can disable Codex startup/periodic checks, select an update source and
+proxy, and review or redact diagnostics before filing an issue. Report privacy
+or security concerns through
+[GitHub Issues](https://github.com/Wangnov/Codex-App-Manager/issues), and never
+post tokens, passwords, private keys, complete presigned URLs, or other secrets
+publicly.
+
+See also the [code signing policy](./code-signing-policy.md).
diff --git a/docs/release.md b/docs/release.md
index 5e6a7e3..ffbe10b 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -3,13 +3,22 @@
## Release notes
每个版本的 release note 写在 `docs/releases/v.md`,随版本号 bump 一起进发版 PR;
-tag 推送后 `release.yml` 按 tag 名取用该文件作为 GitHub Release 正文,并自动追加
+tag 推送后无权限的 `release-source.yml` 发出信号,默认分支的 `release.yml` 按 tag
+名取用该文件作为 GitHub Release 正文,并自动追加
"What's Changed" 与 Full Changelog。文件缺失时回退到 `docs/releases/FALLBACK.md`
(安装表 + 升级说明),正文永远不会为空。写法与双语风格见 `docs/releases/TEMPLATE.md`。
-Cross-platform release is tag-driven via [`.github/workflows/release.yml`](../.github/workflows/release.yml).
-Push a `v*` tag and CI builds, signs, notarizes, and publishes a GitHub Release
-with the Tauri updater manifest.
+Cross-platform release starts with the unprivileged
+[`release-source.yml`](../.github/workflows/release-source.yml) tag signal. Its
+successful `workflow_run` invokes the current default-branch
+[`release.yml`](../.github/workflows/release.yml), which builds, signs,
+notarizes, and publishes the GitHub Release with the Tauri updater manifest.
+The signal has no checkout, secrets, environment, write permission, or artifact
+handoff; all credentialed jobs remain in the trusted default-branch workflow.
+The publish job normally consumes the full four-platform build matrix. Windows
+tag jobs currently fail closed while the SignPath Foundation application and
+trusted-build migration are pending, so a new tag cannot publish a partial or
+unsigned release. See the [code signing policy](./code-signing-policy.md).
## macOS signing pipeline
@@ -41,25 +50,76 @@ bash scripts/finalize-macos.sh aarch64-apple-darwin
Omit the `AC_API_*` vars to skip notarization for a quick local dev finalize.
+## Manager updater trust and size limits
+
+`latest.json` contains provider-specific download URLs, so the R2/IHEP copy is
+intentionally not byte-identical to GitHub's copy and is not itself a trust
+anchor. `gen-updater-manifest.mjs` also emits a deterministic, URL-free
+`release-identity.json` containing the channel, version, release-note hash, exact
+target artifact names, and SHA-256 values. The release workflow signs that
+identity with the existing Tauri updater key, publishes it as a GitHub Release
+asset, stages immutable `/` copies on R2/IHEP, and promotes a root
+`release-identity.json(.sig)` pair only after the immutable GitHub Release and
+mirror `latest.json` pointer are committed.
+
+Every `latest.json` platform entry must include a lowercase 64-character
+`sha256`. Fresh publication, immutable historical-release reuse, and mirror
+verification all fail closed when that field is missing or malformed, or when
+it differs from either the signed release identity or the local artifact bytes.
+Mirror same-version identity comparisons include the channel, release-note hash,
+and artifact digests, so a rerun cannot treat drifted notes or a byte-different
+artifact claim as idempotent.
+The one compatibility exception is a strictly older mirror baseline published
+before this field existed: a fully bound newer candidate may migrate it once.
+Legacy baselines are never accepted for same-version reuse or downgrade.
+
+The client still checks the mainland-friendly mirror first. It bounded-fetches
+and verifies that source's root identity **before** reading unsigned `latest.json`,
+accepts only a signed `stable` channel, then requires the manifest version,
+channel, notes, platform set, artifact basenames, and SHA-256 values to match.
+This prevents an unsigned manifest from selecting an old/prerelease signed
+identity. Artifact bytes then pass both the normal Tauri minisign check and the
+signed SHA-256 before `Update::install`. Any missing, oversized, invalid, or
+mismatched mirror response falls through to GitHub; installation still occurs at
+most once.
+
+Hard in-memory limits are 256 KiB for manifests/identity, 16 KiB for the identity
+signature, and 64 MiB for updater artifacts. For comparison, v0.3.1's largest
+updater artifact is about 10.9 MiB. The exact vendored
+`tauri-plugin-updater` 2.10.1 patch and its upgrade checklist live in
+[`vendor/tauri-plugin-updater-2.10.1/VENDORED.md`](../vendor/tauri-plugin-updater-2.10.1/VENDORED.md).
+Manifest/identity checks have a 30-second request deadline; artifact downloads
+also have 15-second connect, 30-second read-stall, and 15-minute total bounds.
+
+Because Tauri's minisign trusted comment contains a timestamp, a rerun would
+normally create a byte-different identity `.sig`. The workflow first reuses and
+verifies an existing GitHub Release asset (then the mirror copy), and the mirror
+sync refuses to overwrite byte-different versioned identity objects.
+
## Windows
Windows has no light/dark adaptive app icon (`.ico` is static) — the single
Default icon is used. NSIS installer + updater bundle are produced by `tauri
build`; no Apple-style finalize step.
-Post-build on the Windows matrix (see [`release.yml`](../.github/workflows/release.yml)):
+Windows Authenticode is in an explicit **application/migration pending** state:
-1. **PE arch diagnostic** — `scripts/windows-pe-arch.ps1` records x64 vs ARM64
- machine types. ARM64 is cross-built on x64 runners; this is **not** runtime
- verification ([`docs/windows-signing.md`](./windows-signing.md)).
-2. **Optional Authenticode** — `scripts/sign-windows-authenticode.ps1` signs the
- final `-setup.exe` when `WINDOWS_CERTIFICATE` is present; otherwise skips.
-3. **Authenticode verify** — `scripts/verify-windows-authenticode.ps1` in
- `optional` mode by default; set `AUTHENTICODE_REQUIRED=true` to gate.
-4. **Tauri updater `.sig`** — `npx tauri signer sign` (always required for
- Windows in-app update entries in `latest.json`).
-5. **Collect final artifacts** — space-stripped names under `dist-artifacts/`,
- with an explicit check that both `-setup.exe` and `-setup.exe.sig` exist.
+1. The project is applying to SignPath Foundation; approval, certificate
+ issuance, and trusted-build project identifiers do not exist yet.
+2. `release.yml` runs `assert-signpath-foundation-ready.ps1` before a Windows
+ tag build. The script intentionally always fails and has no variable/secret
+ bypass. Because publish depends on the complete matrix, this blocks the
+ whole release rather than shipping an unsigned or macOS-only set.
+3. No `WINDOWS_CERTIFICATE` PFX, password, thumbprint config, or local
+ `signCommand` is supported. SignPath Foundation signs submitted trusted-build
+ artifacts and requires per-request human approval; it is not a PFX provider.
+4. A future PR must prove the NSIS packaging/signing order with real SignPath
+ pilot artifacts, exclude direct signing of third-party plugins, and verify
+ the final setup, installed app, uninstaller, timestamp, updater `.sig`, and
+ mirror hashes before replacing the blocker.
+5. The existing `required` Authenticode and packaged-smoke steps are a
+ post-integration verification contract, not evidence that signing is active.
+ Removing the blocker alone still leaves unsigned output failing closed.
PR-time x64 packaged smoke lives in
[`win-installer-check.yml`](../.github/workflows/win-installer-check.yml)
@@ -67,6 +127,151 @@ PR-time x64 packaged smoke lives in
Required CI (`ci.yml`) also runs standalone engine crate tests for
`codex-mac-engine` and `codex-win-engine`.
+## Mirror promotion safety
+
+Stable releases use a stage/verify/promote, dual-backend protocol implemented by
+[`scripts/mirror-release.mjs`](../scripts/mirror-release.mjs):
+
+1. Before uploading anything immutable, cryptographically verify every local
+ updater payload against its manifest signature and the updater public key in
+ `tauri.conf.json`. The manifest signature must also equal the local `.sig`
+ sidecar. This gate runs for prereleases as well as stable releases.
+2. Upload immutable, versioned artifacts and a run-specific candidate manifest
+ to both R2 and IHEP. A versioned object is never overwritten.
+3. Before publishing the GitHub Release, download the candidate and **every staged
+ artifact** directly from each S3 endpoint. Verify every file's byte size and
+ SHA-256, plus each updater bundle's embedded Tauri/minisign signature with the
+ public key in `tauri.conf.json`. This includes both DMG installers even though
+ they are not referenced by the updater manifest. Stable promotion requires all
+ four updater platforms and requires every manifest signature to match its
+ downloaded `.sig` sidecar; `ALLOW_PARTIAL_RELEASE` cannot weaken the
+ mirror gate. The same verify-only phase forces separate downloads of the
+ run-specific candidate and all four updater payloads through the public
+ Worker's R2 and mainland-China IHEP branches. Each response must identify the
+ requested backend. The IHEP `Location` must also be a complete SigV4 URL whose
+ origin, bucket, prefix, and exact object path match the trusted release
+ configuration; the verifier refuses any follow-on redirect. This catches bad
+ routes, bucket bindings, Worker secondary credentials, and cross-store
+ redirects before immutable publication.
+4. Publish the draft GitHub Release and require GitHub to report that it is
+ immutable with canonical asset digests.
+5. Repeat the complete direct-backend and public-route readback immediately before
+ promotion, then read each backend's current `latest.json` and compare semantic
+ versions. Newer candidates advance, a fully converged same-version rerun is a
+ no-write success, and older tags fail closed. If a hard-killed run left R2 on
+ the candidate while IHEP still lags, the rerun first reclaims R2 with CAS.
+6. Treat R2 as the only linearization authority. The workflow conditionally
+ writes R2, verifies the committed ETag and promotion token, then writes IHEP
+ unconditionally as a follower. A CAS loser never writes IHEP. The winner
+ checks R2 ownership immediately before and after the follower write. If it is
+ superseded during that window, it either preserves the newer follower or
+ repairs only its own IHEP value from the newer stable R2 snapshot. If another
+ repair already moved IHEP to the exact R2 candidate after the CAS, that
+ follower is accepted. A higher version or any identity that cannot be proven
+ canonical is preserved but fails closed; it cannot force the owned R2 CAS to
+ roll back and it is never overwritten by the older run.
+7. If IHEP fails, roll R2 back only while its committed ETag and token are still
+ owned by this transaction. IHEP is restored only when it still contains this
+ transaction's token and bytes; an unchanged baseline or a concurrent value is
+ preserved rather than overwritten.
+
+The release workflow has one repository-wide `release-latest-*` concurrency lane
+with `queue: max`, so every pending tag remains queued instead of a third tag
+replacing the second. This single-writer lane and the promotion-only credential
+names are a correctness boundary because IHEP does not enforce conditional
+writes. Do not run another credentialed promotion workflow outside this lane.
+R2 CAS and the before/after ownership checks are defense in depth for accidental
+overlap; they are not a claim of an atomic transaction across providers.
+`mirror-stage-summary.json`,
+`mirror-verification-summary.json`, and `mirror-promotion-summary.json` are shown
+in the job summary and retained as workflow artifacts for 90 days. If a runner is
+hard-killed after the R2 CAS but before IHEP follows, rerun that release or a newer
+one: the new run reclaims R2 with CAS and converges IHEP. A hard kill during an
+out-of-policy concurrent follower race can still leave IHEP temporarily stale;
+rerunning the release currently authoritative in R2 is the recovery procedure.
+
+Before `prepare` permits any build, and again at the start of every release-job
+attempt, the workflow queries the repository Immutable Releases setting with a
+dedicated fine-grained, read-only token. A missing token, failed query, or
+`enabled: false` response fails closed before draft upload or mirror publication.
+Two separate active tag rulesets restrict `refs/tags/v*` creation to the named
+release publisher and forbid update/deletion for everyone (including that
+publisher). The workflow also re-peels the live tag before publication. The
+peeled commit must be an ancestor of the freshly fetched live default branch;
+tagging an unmerged release-bump branch fails before build/signing and is checked
+again in each build, release-job attempt, publication boundary, and final mirror
+promotion. Every checkout after resolution uses that exact commit SHA, not the
+tag name.
+
+The `release` environment must allow the protected default branch only. Do not
+allow `v*` deployment refs and do not duplicate release credentials as repository
+or organization secrets: tag-triggered workflow definitions are read from the
+tagged commit, whereas the credentialed `workflow_run` workflow is read from the
+default branch. `release-source.yml` must stay an unprivileged signal and must
+never download or pass artifacts.
+
+Same-tag reruns reuse the artifacts, signed release identity, and `latest.json`
+attached to a complete,
+published GitHub Release only when GitHub reports `immutable: true` and a canonical
+`sha256:` digest for every required asset. The workflow re-hashes every downloaded
+asset against those API digests, and rejects a mutable release instead of calling
+its current bytes canonical. Only a draft is repairable; a published mutable or
+incomplete Release fails closed before any asset upload. The release job performs
+this lookup live rather than
+trusting `prepare` outputs from an earlier attempt, so **Re-run failed jobs** after
+a mirror failure reuses the already-published immutable bytes. While a new or
+partial release is still being repaired, the workflow selects the earliest
+successful, attempt-qualified Actions artifact for each platform so every rerun
+stages the same signed/notarized bytes. It never creates a second byte sequence for
+an immutable version key. New releases are uploaded as drafts first and published
+only after all assets succeed, including prereleases. Immediately after publish,
+the workflow requires the Release itself to report `immutable: true` and canonical
+digests for every required asset before any mirror pointer can advance.
+Before reusing a failed attempt's mutable draft, the workflow deletes every
+stale draft asset and proves the set is empty. After upload it requires an exact
+name/size match against the local canonical files, downloads every draft asset
+for byte-for-byte SHA-256 verification, and rechecks that the draft did not
+change before publication. Immutable reuse rejects every asset outside the
+fixed release/SBOM allowlist.
+Each fresh release also publishes `release-binding.json` and a custom GitHub
+attestation. `workflow_run` OIDC identifies `refs/heads/` rather than the
+tag, so verification separately pins the trusted signer workflow digest and the
+default-branch source digest, then requires the signed predicate and attestation
+subject set to match the target tag, peeled source SHA, and every canonical
+subject digest exactly. Fresh bundles are self-verified before draft upload.
+Immutable reuse downloads the API-digest-pinned binding and verifies both SLSA
+provenance and this exact custom predicate. Both fresh publication and reuse also
+require `gh release verify` to accept GitHub's immutable release attestation; an
+old immutable release without the binding is deliberately not reusable.
+
+Historical Actions runs execute their historical workflow revision, so the
+release environment intentionally uses new `*_PROMOTION_*` credential names. The
+four legacy access-key secrets listed below must be deleted; the current workflow
+has a hard gate that rejects them if they are reintroduced.
+
+Stable mirror promotion commits `latest.json` first, then writes the root
+identity signature followed by its JSON. Any interrupted or mixed generation
+fails verification and makes the client fall back to GitHub; a rerun repairs the
+root pair and verifies both direct storage backends and both public Worker routes.
+
+### Emergency mirror downgrade
+
+A normal old-tag rerun can never downgrade the mirror. For an incident rollback:
+
+1. Open the current `Release` workflow and dispatch it from the repository's
+ **default branch** (never select the historical tag's workflow revision).
+2. Set `target_tag` to the already-published `vX.Y.Z` release to restore.
+3. Enable `allow_mirror_downgrade`.
+4. Enter a concrete `mirror_downgrade_reason` (at least 10 characters).
+
+The workflow downloads the target release's original assets instead of rebuilding
+them. The target GitHub Release must be immutable and expose canonical SHA-256
+digests; older mutable releases must first be migrated through an explicitly
+reviewed process and cannot be used directly. The script rejects the override on
+tag-push events or without GitHub actor/run metadata. The triggering actor (plus
+the original workflow actor on a rerun), reason, whether the override was actually
+used, and the run URL are written to the promotion audit and job summary.
+
## Required GitHub Actions secrets
| Secret | What |
@@ -80,21 +285,52 @@ Required CI (`ci.yml`) also runs standalone engine crate tests for
| `AC_API_KEY_BASE64` | base64 of the `AuthKey_XXXX.p8` |
| `TAURI_SIGNING_PRIVATE_KEY` | updater private key |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | its password (empty if none) |
+| `IMMUTABLE_RELEASES_READ_TOKEN` | fine-grained token used only to read this repository's Immutable Releases setting; grant **Administration: read-only** and store it in the `release` environment |
+| `MANAGER_R2_S3_ENDPOINT` | R2 S3-compatible endpoint |
+| `MANAGER_R2_PROMOTION_ACCESS_KEY_ID` | R2 write/read credential used only by the protected workflow |
+| `MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY` | R2 write/read secret used only by the protected workflow |
+| `MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID` | IHEP write/read credential used only by the protected workflow |
+| `MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY` | IHEP write/read secret used only by the protected workflow |
+
+There are currently **no Windows signing secrets or variables to configure**.
+After Foundation approval, use only the exact organization/project/signing
+policy/artifact-configuration values provisioned by SignPath, in a separately
+reviewed trusted-build integration. Do not guess names and do not add a PFX
+fallback.
-### Optional Windows Authenticode secrets / vars
+After creating the promotion credentials, delete (and preferably revoke/rotate)
+`MANAGER_R2_ACCESS_KEY_ID`, `MANAGER_R2_SECRET_ACCESS_KEY`,
+`MANAGER_IHEP_S3_ACCESS_KEY_ID`, and `MANAGER_IHEP_S3_SECRET_ACCESS_KEY` from the
+repository and `release` environment. Historical workflow revisions reference
+those exact names; leaving any of them available defeats old-run isolation.
+
+### Release variables
| Name | What |
|---|---|
-| `WINDOWS_CERTIFICATE` | base64 of OV/EV code-signing **.pfx** (release env) |
-| `WINDOWS_CERTIFICATE_PASSWORD` | password for that .pfx |
-| `AUTHENTICODE_REQUIRED` (repo **variable**) | `true` → fail release when PE is not `Valid` |
-| `WINDOWS_TIMESTAMP_URL` (repo **variable**) | optional RFC3161 timestamp URL |
+| `MANAGER_R2_BUCKET` (repo **variable**) | R2 bucket; defaults to `codex-app-manager` |
+| `MANAGER_IHEP_S3_ENDPOINT` (`release` environment **variable**) | IHEP S3-compatible endpoint |
+| `MANAGER_IHEP_S3_BUCKET` (`release` environment **variable**) | IHEP bucket |
+| `MANAGER_IHEP_S3_REGION` (`release` environment **variable**) | IHEP region; defaults to `auto` when empty |
+| `MANAGER_IHEP_S3_PREFIX` (`release` environment **variable**) | optional object-key prefix for IHEP |
-Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`.
+Export Apple `.p12` / `.p8` files to base64 with `base64 -i file -o -`.
> Artifact globs in the workflow's *Collect* step and the matcher regexes in
> `gen-updater-manifest.mjs` assume the default Tauri bundler output names —
> adjust them if your `productName`/bundler config changes the filenames.
>
> Keep **updater signature**, **Authenticode**, and **SmartScreen reputation**
-> conceptually separate — see [`docs/windows-signing.md`](./windows-signing.md).
+> conceptually separate. Current Windows installers remain unsigned and new tag
+> publication is blocked until the SignPath migration is proven. See
+> [`docs/windows-signing.md`](./windows-signing.md), the
+> [code signing policy](./code-signing-policy.md), and the
+> [privacy policy](./privacy.md).
+>
+> Before enabling promotion, seed both S3-compatible endpoints with a valid
+> `latest.json` baseline. R2 must enforce conditional `PutObject` requests
+> (`If-Match` / `If-None-Match`) and preserve custom user metadata through
+> `HeadObject`. IHEP must preserve metadata and support ordinary read/write, but
+> it is explicitly allowed to ignore conditional headers because the workflow
+> uses it only as the serialized unconditional follower. Promotion fails closed
+> if either baseline is absent.
diff --git a/docs/releases/FALLBACK.md b/docs/releases/FALLBACK.md
index 9d86bd6..b130534 100644
--- a/docs/releases/FALLBACK.md
+++ b/docs/releases/FALLBACK.md
@@ -16,8 +16,10 @@
| Windows · x64 | [CodexAppManager_x64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) |
| Windows · ARM64 | [CodexAppManager_arm64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) |
-**Windows 签名状态:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` 当前没有 Authenticode 代码签名,首次运行可能出现 SmartScreen 提示;`.sig` / `latest.json` 里的 Tauri updater 签名只用于应用内自更新的字节校验,不代表 Windows 发行者信任。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。
-**Windows signing status:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` are not Authenticode-signed yet, so SmartScreen may warn on first run; the Tauri updater signature in `.sig` / `latest.json` verifies in-app update bytes only and is not Windows publisher trust. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md).
+**Windows 信任状态:** 当前 SignPath Foundation 申请与 trusted-build 迁移尚未完成,Windows tag 发布门保持 fail-closed;`.sig` / `latest.json` 的 Tauri updater 签名不是 Authenticode 发行者身份。本 fallback 不得发布 Windows 工件;参见 [代码签名政策](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md)与 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。
+**Windows trust:** The SignPath Foundation application and trusted-build migration are pending, so the Windows tag gate remains fail-closed. A Tauri updater signature in `.sig` / `latest.json` is not Authenticode publisher identity. This fallback must not accompany published Windows artifacts; see the [code signing policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md) and [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md).
+
+**隐私 · Privacy:** [隐私政策 · Privacy policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/privacy.md)
**核验下载:** 本页 Assets 带有 `SHA256SUMS`;Windows 用 `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` 或替换为 ARM64 文件名,macOS 用 `shasum -a 256 CodexAppManager_aarch64.dmg`,再与 `SHA256SUMS` 比对。
**Verify downloads:** This release includes `SHA256SUMS` in Assets; on Windows run `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` or swap in the ARM64 filename, and on macOS run `shasum -a 256 CodexAppManager_aarch64.dmg`, then compare with `SHA256SUMS`.
diff --git a/docs/releases/TEMPLATE.md b/docs/releases/TEMPLATE.md
index 8c81d84..ac45466 100644
--- a/docs/releases/TEMPLATE.md
+++ b/docs/releases/TEMPLATE.md
@@ -1,6 +1,7 @@