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 @@