diff --git a/.github/workflows/pwragent-ci.yml b/.github/workflows/pwragent-ci.yml new file mode 100644 index 000000000000..f38968a2a0d1 --- /dev/null +++ b/.github/workflows/pwragent-ci.yml @@ -0,0 +1,139 @@ +# Minimal PR gate for the PwrDrvr fork. +# +# Upstream's `blocking-ci` fans out to Bazel, nextest, and the SDK suites across +# self-hosted runner groups that do not exist here, so it is disabled on this +# fork. That left `codex-rs` changes with no verification at all. This is the +# useful sliver: one Linux x64 runner proving the workspace still compiles, the +# unit tests still pass, and clippy's denied lints are still clean. +# +# Standard GitHub-hosted runners are free on public repositories, so this costs +# nothing to run on every pull request. + +name: PwrAgent CI + +on: + pull_request: + paths: + - "codex-rs/**" + - ".github/workflows/pwragent-ci.yml" + - ".github/actions/setup-rusty-v8/**" + - ".github/scripts/rusty_v8_bazel.py" + push: + branches: + - pwragent + paths: + - "codex-rs/**" + - ".github/workflows/pwragent-ci.yml" + - ".github/actions/setup-rusty-v8/**" + - ".github/scripts/rusty_v8_bazel.py" + workflow_dispatch: + +permissions: + contents: read + id-token: none + +concurrency: + group: pwragent-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + # `--locked` matters here: it fails if Cargo.lock does not already + # satisfy the manifests, which is the check a hand-edited lockfile + # needs and that a plain build would silently paper over. + - name: test + command: cargo nextest run --locked -p codex-core --lib --no-fail-fast + - name: clippy + command: cargo clippy --locked -p codex-core --all-targets + # The two jobs above only ever compile codex-core as a library. None + # of the shipped executables live there — `codex` is in codex-rs/cli — + # so without this a change that breaks a caller would pass the gate + # and only surface at release time. Debug profile: this is a link + # check, not an artifact. + - name: build + command: >- + cargo build --locked + --bin codex + --bin codex-app-server + --bin codex-code-mode-host + env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + CARGO_TERM_COLOR: always + # Required by this workspace, not a tuning knob. `cargo test` runs each + # test on a spawned thread with Rust's 2 MiB default, and several + # codex-core tests overflow that. Upstream sets the same 8 MiB in + # rust-ci.yml, rust-ci-full.yml, the nextest platform workflow, and + # .bazelrc; the justfile sets it for local runs. + RUST_MIN_STACK: "8388608" # 8 MiB + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # codex-rs plus its dependency tree does not comfortably fit alongside the + # runner's preinstalled SDKs. Dropping the ones no Rust build touches buys + # roughly 20 GB. + - name: Reclaim runner disk + shell: bash + run: | + set -euo pipefail + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /usr/local/share/boost /usr/local/share/powershell + df -h / + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: x86_64-unknown-linux-gnu + components: clippy + + - name: Cache Cargo registry + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: cargo-registry-pwragent-ci-${{ hashFiles('codex-rs/Cargo.lock') }} + restore-keys: | + cargo-registry-pwragent-ci- + + - name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 + with: + target: x86_64-unknown-linux-gnu + + # codex-rs/.config/nextest.toml is the workspace's real test contract: + # retries, slow-timeout, and the max-threads groups for tests that cannot + # run concurrently. `cargo test` ignores all of it and shares one process + # across tests, which is how global tracing-subscriber state leaked + # between them. The justfile's own guidance is to install it this way. + - name: Cache cargo-nextest + if: matrix.name == 'test' + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.cargo/bin/cargo-nextest + key: cargo-nextest-${{ runner.os }}-${{ runner.arch }} + + - name: Install cargo-nextest + if: matrix.name == 'test' + shell: bash + run: | + set -euo pipefail + if ! command -v cargo-nextest >/dev/null 2>&1; then + cargo install --locked cargo-nextest + fi + cargo nextest --version + + - name: ${{ matrix.name }} + working-directory: codex-rs + shell: bash + run: ${{ matrix.command }} diff --git a/.github/workflows/pwragent-macos-unsigned.yml b/.github/workflows/pwragent-macos-unsigned.yml new file mode 100644 index 000000000000..268a8378f6c2 --- /dev/null +++ b/.github/workflows/pwragent-macos-unsigned.yml @@ -0,0 +1,205 @@ +# Unsigned macOS arm64 build for hands-on testing. +# +# This is deliberately separate from `pwragent-release.yml`. That workflow is +# fail-closed: every artifact it produces on a PR goes through Developer ID +# signing, and `scripts/pwragent-release/check-release-signing.py` pins that +# property. Threading an unsigned path through it would mean gating the signing +# jobs on a second label inside the very workflow whose contract is "no unsigned +# output" — so the unsigned build lives here instead, where it touches no +# secrets and enters no environment. +# +# Apply the `ci:macos-unsigned` label to a pull request and the run attaches an +# `unsigned-macos-aarch64` artifact. It is for smoke-testing a build on an Apple +# Silicon Mac; it carries no signature or notarization and must never be +# shipped. To exercise the real signed path, use `ci:release-signing`. + +name: Build unsigned macOS arm64 + +on: + pull_request: + types: + - labeled + - reopened + - synchronize + workflow_dispatch: + +permissions: + contents: read + id-token: none + +# A `labeled` event for some unrelated label still starts a run of this +# workflow, which then skips at the job level. Without the run-scoped group +# below it would share a group with — and so cancel — an unsigned build already +# in flight on the same PR. +concurrency: + group: >- + pwragent-macos-unsigned-${{ github.ref }}-${{ + github.event_name == 'pull_request' + && github.event.action == 'labeled' + && github.event.label.name != 'ci:macos-unsigned' + && github.run_id + || 'build' + }} + cancel-in-progress: true + +jobs: + build: + name: Build macos-aarch64 (unsigned) + # `synchronize` and `reopened` keep the artifact current on an + # already-labeled PR; the `labeled` arm restricts new runs to this label so + # that adding an unrelated one does not trigger a build. + if: >- + github.event_name == 'workflow_dispatch' + || (contains(github.event.pull_request.labels.*.name, 'ci:macos-unsigned') + && (github.event.action == 'synchronize' + || github.event.action == 'reopened' + || github.event.label.name == 'ci:macos-unsigned')) + runs-on: macos-15 + # Measured in the first end-to-end release run: macos-aarch64 took 114 + # minutes cold and macos-x86_64 was killed at the old 120 minute cap. + # Matches the release build's 240. + timeout-minutes: 240 + permissions: + contents: read + id-token: none + env: + # Matches the release build: PwrDrvr products do not ship or upload dSYMs. + CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: "off" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + CARGO_TERM_COLOR: always + TARGET: aarch64-apple-darwin + PLATFORM: macos-aarch64 + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Resolve build version + id: version + shell: bash + run: | + set -euo pipefail + # Same derivation as the release pipeline's untagged runs. Upstream + # leaves the workspace version at 0.0.0 outside release branches, so + # this normally reads 0.0.0-pwragent.dev.N. + upstream_version="$(sed -n \ + '/^\[workspace.package\]$/{n;s/^version = "\(.*\)"$/\1/p;q;}' \ + codex-rs/Cargo.toml)" + test -n "$upstream_version" + echo "version=${upstream_version}-pwragent.dev.${GITHUB_RUN_NUMBER}" \ + >> "$GITHUB_OUTPUT" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: aarch64-apple-darwin + + # Deliberately the same key as the release workflow's macos-aarch64 leg: + # same target, same release profile, same three binaries, so the two can + # read each other's `codex-rs/target`. A cold build here is around two + # hours, which is the difference between a usable label and an ignored one. + # + # Restore and save are split for the same reason they are there — the + # combined `actions/cache` skips its save when the job fails, so a build + # killed at the timeout would discard everything it compiled. + - name: Restore Cargo cache + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + codex-rs/target + key: cargo-macos-aarch64-${{ hashFiles('codex-rs/Cargo.lock') }} + restore-keys: | + cargo-macos-aarch64- + + - name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 + with: + target: aarch64-apple-darwin + + - name: Build release binaries + working-directory: codex-rs + shell: bash + run: | + set -euo pipefail + cargo build --target "$TARGET" --release \ + --bin codex \ + --bin codex-app-server \ + --bin codex-code-mode-host + + # Cache entries are immutable once written, hence run_id/run_attempt in + # the save key. + - name: Save Cargo cache + if: always() + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + codex-rs/target + key: cargo-macos-aarch64-${{ hashFiles('codex-rs/Cargo.lock') }}-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Stage distribution + shell: bash + env: + CODEX_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + release_dir="codex-rs/target/${TARGET}/release" + dest="stage/${PLATFORM}" + mkdir -p "$dest" + for binary in codex codex-app-server codex-code-mode-host; do + install -m 0755 "${release_dir}/${binary}" "${dest}/${binary}" + done + cp LICENSE NOTICE "$dest/" + # `signed=no` is the field that distinguishes this tree from the + # release one, which is otherwise laid out identically. + cat > "${dest}/PWRAGENT-BUILD.txt" <> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pwragent-release-check.yml b/.github/workflows/pwragent-release-check.yml new file mode 100644 index 000000000000..8a610025396c --- /dev/null +++ b/.github/workflows/pwragent-release-check.yml @@ -0,0 +1,81 @@ +# Guards the downstream release pipeline itself. +# +# Runs on every change to the release workflow or its scripts, and takes no +# secrets and enters no environment, so it can run freely on any PR. The +# expensive signed build only runs behind the `ci:release-signing` label. + +name: Check PwrAgent release signing + +on: + pull_request: + paths: + - ".github/workflows/pwragent-release*.yml" + - ".github/workflows/pwragent-macos-unsigned.yml" + - "docs/pwragent-distribution.md" + - "scripts/pwragent-release/**" + push: + branches: + - pwragent + paths: + - ".github/workflows/pwragent-release*.yml" + - ".github/workflows/pwragent-macos-unsigned.yml" + - "docs/pwragent-distribution.md" + - "scripts/pwragent-release/**" + +permissions: + contents: read + id-token: none + +jobs: + release-signing-contract: + name: Release signing contract + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: none + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate fail-closed signing workflow + run: python3 scripts/pwragent-release/check-release-signing.py + + trusted-signing-preparation: + name: Prepare TrustedSigning client + runs-on: windows-2022 + timeout-minutes: 10 + permissions: + contents: read + id-token: none + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download and validate pinned signing client + shell: pwsh -NoProfile -NonInteractive -File {0} + run: >- + ./scripts/pwragent-release/prepare-trusted-signing.ps1 + -OutputRoot $env:RUNNER_TEMP/signing-tools + + - name: Verify signing client after archive round-trip + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $archive = Join-Path $env:RUNNER_TEMP "signing-tools.tgz" + $expandedRoot = Join-Path $env:RUNNER_TEMP "signing-tools-roundtrip" + & tar.exe -czf $archive -C $env:RUNNER_TEMP signing-tools + if ($LASTEXITCODE -ne 0) { + throw "Failed to archive prepared signing tools (exit code $LASTEXITCODE)." + } + New-Item -ItemType Directory -Force $expandedRoot | Out-Null + & tar.exe -xzf $archive -C $expandedRoot + if ($LASTEXITCODE -ne 0) { + throw "Failed to expand prepared signing tools (exit code $LASTEXITCODE)." + } + & ./scripts/pwragent-release/verify-trusted-signing-tools.ps1 ` + -SigningToolsRoot (Join-Path $expandedRoot "signing-tools") | + Out-Null diff --git a/.github/workflows/pwragent-release.yml b/.github/workflows/pwragent-release.yml new file mode 100644 index 000000000000..7a370df68ceb --- /dev/null +++ b/.github/workflows/pwragent-release.yml @@ -0,0 +1,683 @@ +# Downstream Codex distribution for PwrDrvr products (PwrAgent, PwrSnap, PwrGit). +# +# This is deliberately NOT upstream's `rust-release.yml`. That workflow targets +# self-hosted runner groups (`-runners`) that do not exist on this fork, +# and signs through OpenAI's own `codesigning` environment. This one builds on +# GitHub-hosted runners only and signs with PwrDrvr credentials held in the +# `apple-signing` and `windows-signing` environments. +# +# Signing is fail-closed: the signing jobs have no fallback to an unsigned +# asset, and `release-candidate` requires both of them. +# +# To exercise the signing path on a pull request, apply the `ci:release-signing` +# label. To publish, push a `pwragent-v*` tag. +# +# To just get a build onto an Apple Silicon Mac, do not use this workflow: apply +# `ci:macos-unsigned` and let `pwragent-macos-unsigned.yml` produce an unsigned +# arm64 artifact. Keeping that out of here is what lets this one stay +# fail-closed. + +name: Build PwrAgent Codex distribution + +on: + workflow_dispatch: + pull_request: + types: + - labeled + - reopened + - synchronize + - unlabeled + push: + tags: + - "pwragent-v*" + +permissions: + contents: read + id-token: none + +concurrency: + group: >- + pwragent-codex-${{ github.ref }}-${{ + github.event_name == 'pull_request' + && (github.event.action == 'labeled' || github.event.action == 'unlabeled') + && github.event.label.name != 'ci:release-signing' + && github.run_id + || 'release-signing' + }} + cancel-in-progress: >- + ${{ github.event_name == 'pull_request' + && (github.event.action == 'synchronize' + || github.event.action == 'reopened' + || github.event.label.name == 'ci:release-signing') }} + +jobs: + metadata: + name: Resolve release metadata + if: >- + github.event_name != 'pull_request' + || (contains(github.event.pull_request.labels.*.name, 'ci:release-signing') + && (github.event.action == 'synchronize' + || github.event.action == 'reopened' + || github.event.label.name == 'ci:release-signing')) + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: none + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate release signing contract + run: python3 scripts/pwragent-release/check-release-signing.py + + - name: Resolve downstream version + id: version + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/pwragent-v* ]]; then + version="${GITHUB_REF_NAME#pwragent-v}" + else + # Upstream leaves the workspace version at 0.0.0 on main and only + # bumps it on release branches, so a dev build simply carries + # whatever is checked in plus a run-scoped prerelease suffix. + upstream_version="$(sed -n \ + '/^\[workspace.package\]$/{n;s/^version = "\(.*\)"$/\1/p;q;}' \ + codex-rs/Cargo.toml)" + test -n "$upstream_version" + version="${upstream_version}-pwragent.dev.${GITHUB_RUN_NUMBER}" + fi + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Resolved version is not SemVer: $version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.platform }} + needs: metadata + strategy: + fail-fast: false + matrix: + include: + - platform: macos-aarch64 + runner: macos-15 + target: aarch64-apple-darwin + release_asset: "" + - platform: macos-x86_64 + runner: macos-15-intel + target: x86_64-apple-darwin + release_asset: "" + - platform: linux-aarch64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + release_asset: pwragent-codex-${{ needs.metadata.outputs.version }}-linux-aarch64.tar.gz + - platform: linux-x86_64 + runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + release_asset: pwragent-codex-${{ needs.metadata.outputs.version }}-linux-x86_64.tar.gz + runs-on: ${{ matrix.runner }} + timeout-minutes: 240 + permissions: + contents: read + id-token: none + env: + CODEX_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_ASSET: ${{ matrix.release_asset }} + # Unlike upstream, downstream builds keep debuginfo out of the release + # profile entirely; PwrDrvr products do not ship or upload dSYMs. + CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: "off" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: ${{ matrix.target }} + + # Split restore/save on purpose. The combined `actions/cache` skips its + # post-step save when the job fails, so a build killed at the timeout + # threw away every object it had compiled. `if: always()` keeps the + # partial target dir, letting the next attempt resume instead of + # restarting. Upstream uses the same split in bazel.yml, guarded with + # `!cancelled()`; that guard is omitted here because a timeout is exactly + # the case whose output is worth keeping. + - name: Restore Cargo cache + id: cargo_cache + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + codex-rs/target + key: cargo-${{ matrix.platform }}-${{ hashFiles('codex-rs/Cargo.lock') }} + restore-keys: | + cargo-${{ matrix.platform }}- + + - name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 + with: + target: ${{ matrix.target }} + + - name: Build release binaries + working-directory: codex-rs + shell: bash + run: | + set -euo pipefail + cargo build --target "${{ matrix.target }}" --release \ + --bin codex \ + --bin codex-app-server \ + --bin codex-code-mode-host + + - name: Save Cargo cache + if: always() + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + codex-rs/target + key: cargo-${{ matrix.platform }}-${{ hashFiles('codex-rs/Cargo.lock') }}-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Stage distribution + shell: bash + run: | + set -euo pipefail + release_dir="codex-rs/target/${{ matrix.target }}/release" + dest="stage/${{ matrix.platform }}" + mkdir -p "$dest" + for binary in codex codex-app-server codex-code-mode-host; do + install -m 0755 "${release_dir}/${binary}" "${dest}/${binary}" + done + cp LICENSE NOTICE "$dest/" + cat > "${dest}/PWRAGENT-BUILD.txt" <> "$GITHUB_OUTPUT" + printf "%s %s-signing-input.tgz\n" "$sha256" "${{ matrix.platform }}" \ + > "$tarball.sha256" + + - name: Upload macOS signing input + if: runner.os == 'macOS' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: signing-input-${{ matrix.platform }} + path: | + ${{ runner.temp }}/${{ matrix.platform }}-signing-input.tgz + ${{ runner.temp }}/${{ matrix.platform }}-signing-input.tgz.sha256 + if-no-files-found: error + retention-days: 7 + + # Finding 1: workflow_dispatch skips every signing job, so without this the + # macOS half of a manual run produces nothing an operator can use. + - name: Package unsigned manual-dispatch artifact + if: runner.os == 'macOS' && github.event_name == 'workflow_dispatch' + shell: bash + run: | + set -euo pipefail + tar -C "stage/${{ matrix.platform }}" \ + -czf "pwragent-codex-${CODEX_VERSION}-${{ matrix.platform }}-unsigned.tar.gz" . + + - name: Upload unsigned manual-dispatch artifact + if: runner.os == 'macOS' && github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: unsigned-${{ matrix.platform }} + path: pwragent-codex-*-${{ matrix.platform }}-unsigned.tar.gz + if-no-files-found: error + retention-days: 7 + + macos-sign: + name: Sign ${{ matrix.platform }} + if: >- + startsWith(github.ref, 'refs/tags/pwragent-v') + || (github.event_name == 'pull_request' + && contains(github.event.pull_request.labels.*.name, 'ci:release-signing')) + needs: + - metadata + - build + strategy: + fail-fast: false + matrix: + platform: + - macos-aarch64 + - macos-x86_64 + runs-on: macos-15 + timeout-minutes: 20 + environment: apple-signing + permissions: + contents: read + id-token: none + env: + CODEX_VERSION: ${{ needs.metadata.outputs.version }} + PLATFORM: ${{ matrix.platform }} + steps: + - name: Download macOS signing input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: signing-input-${{ matrix.platform }} + path: .release-input + + # Unlike windows-sign, this cannot compare against a job output: GitHub + # Actions cannot export per-entry outputs from a matrix job, and `build` + # is a matrix. Within-run artifacts are therefore the trust boundary on + # this side, and the checksum below guards transfer corruption rather + # than substitution. + - name: Verify macOS signing input + shell: bash + run: | + set -euo pipefail + cd .release-input + shasum -a 256 --check "${PLATFORM}-signing-input.tgz.sha256" + + - name: Expand macOS signing input + shell: bash + run: | + set -euo pipefail + mkdir -p stage + tar -C stage -xzf ".release-input/${PLATFORM}-signing-input.tgz" + + - name: Sign and verify macOS binaries + env: + # Base64-encoded Developer ID Application .p12 and its export + # password. Store both only on the apple-signing GitHub Environment. + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_TEAM_ID: T44CNHC4UH + shell: bash + run: | + set -euo pipefail + : "${CSC_LINK:?CSC_LINK is required}" + : "${CSC_KEY_PASSWORD:?CSC_KEY_PASSWORD is required}" + identity="Developer ID Application: PwrDrvr LLC (${APPLE_TEAM_ID})" + certificate="$RUNNER_TEMP/pwrdrvr-developer-id.p12" + keychain="$RUNNER_TEMP/pwrdrvr-release-signing.keychain-db" + keychain_password="$(openssl rand -hex 32)" + + cleanup() { + security delete-keychain "$keychain" >/dev/null 2>&1 || true + rm -f "$certificate" + } + trap cleanup EXIT + + certificate_base64="${CSC_LINK#data:application/x-pkcs12;base64,}" + printf '%s' "$certificate_base64" | base64 -D > "$certificate" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" + security import "$certificate" \ + -k "$keychain" \ + -P "$CSC_KEY_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$keychain_password" \ + "$keychain" + security find-identity -v -p codesigning "$keychain" | grep -F "\"${identity}\"" + + for binary in codex codex-app-server codex-code-mode-host; do + codesign \ + --force \ + --keychain "$keychain" \ + --options runtime \ + --timestamp \ + --sign "$identity" \ + "stage/${binary}" + codesign --verify --all-architectures --strict --verbose=2 "stage/${binary}" + codesign --display --verbose=4 "stage/${binary}" \ + 2> "$RUNNER_TEMP/${binary}-codesign.txt" + grep -Fx "Authority=${identity}" "$RUNNER_TEMP/${binary}-codesign.txt" + grep -Fx "TeamIdentifier=${APPLE_TEAM_ID}" "$RUNNER_TEMP/${binary}-codesign.txt" + done + + - name: Package signed macOS distribution + shell: bash + run: | + set -euo pipefail + asset="pwragent-codex-${CODEX_VERSION}-${PLATFORM}.tar.gz" + tar -C stage -czf "$asset" . + echo "ASSET=$asset" >> "$GITHUB_ENV" + + - name: Upload signed macOS release asset + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: release-${{ matrix.platform }} + path: ${{ env.ASSET }} + if-no-files-found: error + retention-days: 7 + + windows-prepare: + name: Prepare Windows signing input + needs: metadata + runs-on: windows-2022 + timeout-minutes: 240 + permissions: + contents: read + id-token: none + outputs: + signing-input-sha256: ${{ steps.archive.outputs.sha256 }} + env: + CODEX_VERSION: ${{ needs.metadata.outputs.version }} + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: x86_64-pc-windows-msvc + + - name: Restore Cargo cache + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + codex-rs/target + key: cargo-windows-x86_64-${{ hashFiles('codex-rs/Cargo.lock') }} + restore-keys: | + cargo-windows-x86_64- + + - name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 + with: + target: x86_64-pc-windows-msvc + + - name: Configure LLVM linker + uses: ./.github/actions/setup-msvc-env + with: + target: x86_64-pc-windows-msvc + + # Ahead of the build on purpose: this takes about a minute and depends on + # PSGallery, so failing it after a two-hour compile wastes the compile. + - name: Prepare pinned TrustedSigning client + shell: pwsh -NoProfile -NonInteractive -File {0} + run: ./scripts/pwragent-release/prepare-trusted-signing.ps1 -OutputRoot signing-tools + + - name: Build release binaries + working-directory: codex-rs + shell: bash + run: | + set -euo pipefail + export LIBSQLITE3_FLAGS=SQLITE_DISABLE_INTRINSIC + cargo build --target x86_64-pc-windows-msvc --release \ + --bin codex \ + --bin codex-app-server \ + --bin codex-code-mode-host \ + --bin codex-windows-sandbox-setup \ + --bin codex-command-runner + + - name: Save Cargo cache + if: always() + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + codex-rs/target + key: cargo-windows-x86_64-${{ hashFiles('codex-rs/Cargo.lock') }}-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Stage Windows distribution + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $dest = "stage/windows-x86_64" + New-Item -ItemType Directory -Force $dest | Out-Null + $releaseDir = "codex-rs/target/x86_64-pc-windows-msvc/release" + foreach ($binary in @( + "codex.exe", + "codex-app-server.exe", + "codex-code-mode-host.exe", + "codex-windows-sandbox-setup.exe", + "codex-command-runner.exe" + )) { + Copy-Item (Join-Path $releaseDir $binary) (Join-Path $dest $binary) + } + Copy-Item LICENSE, NOTICE $dest + @" + version=$env:CODEX_VERSION + source_repository=$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY + source_commit=$env:GITHUB_SHA + target=x86_64-pc-windows-msvc + platform=windows-x86_64 + "@ | Set-Content "$dest/PWRAGENT-BUILD.txt" + + - name: Archive Windows signing input + id: archive + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $archive = Join-Path $env:RUNNER_TEMP "windows-release-signing-input.tgz" + & tar.exe -czf $archive stage/windows-x86_64 signing-tools scripts/pwragent-release + if ($LASTEXITCODE -ne 0) { + throw "Failed to archive Windows signing input (exit code $LASTEXITCODE)." + } + $sha256 = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() + "sha256=$sha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "$sha256 windows-release-signing-input.tgz" | + Set-Content -Path "$archive.sha256" -Encoding ascii + + - name: Package unsigned manual-dispatch artifact + if: github.event_name == 'workflow_dispatch' + shell: pwsh + run: >- + Compress-Archive + -Path "stage/windows-x86_64/*" + -DestinationPath "pwragent-codex-$env:CODEX_VERSION-windows-x86_64-unsigned.zip" + + - name: Upload unsigned manual-dispatch artifact + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: unsigned-windows-x86_64 + path: pwragent-codex-*-windows-x86_64-unsigned.zip + if-no-files-found: error + retention-days: 7 + + - name: Upload Windows signing input + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: windows-release-signing-input + path: | + ${{ runner.temp }}/windows-release-signing-input.tgz + ${{ runner.temp }}/windows-release-signing-input.tgz.sha256 + if-no-files-found: error + retention-days: 7 + + windows-sign: + name: Authenticode-sign Windows x64 binaries + if: >- + startsWith(github.ref, 'refs/tags/pwragent-v') + || (github.event_name == 'pull_request' + && contains(github.event.pull_request.labels.*.name, 'ci:release-signing')) + needs: + - metadata + - windows-prepare + runs-on: windows-2022 + timeout-minutes: 30 + environment: windows-signing + permissions: + contents: read + id-token: none + steps: + - name: Download Windows signing input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows-release-signing-input + path: .release-input + + - name: Verify Windows signing input + shell: pwsh + env: + EXPECTED_SHA256: ${{ needs.windows-prepare.outputs.signing-input-sha256 }} + run: | + $ErrorActionPreference = "Stop" + $archive = ".release-input/windows-release-signing-input.tgz" + $actual = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() + if ($actual -ne $env:EXPECTED_SHA256) { + throw "Windows signing input digest mismatch: expected $env:EXPECTED_SHA256, got $actual." + } + $recorded = (Get-Content "$archive.sha256" -Raw).Trim() + if ($recorded -ne "$actual windows-release-signing-input.tgz") { + throw "Windows signing input checksum file does not match the downloaded archive." + } + + - name: Expand Windows signing input + shell: pwsh + run: | + & tar.exe -xzf .release-input/windows-release-signing-input.tgz + if ($LASTEXITCODE -ne 0) { + throw "Failed to expand Windows signing input (exit code $LASTEXITCODE)." + } + + - name: Sign and verify Windows binaries + shell: pwsh -NoProfile -NonInteractive -File {0} + env: + WIN_AZURE_SIGN_PUBLISHER_NAME: ${{ vars.WIN_AZURE_SIGN_PUBLISHER_NAME }} + WIN_AZURE_SIGN_ENDPOINT: ${{ vars.WIN_AZURE_SIGN_ENDPOINT }} + WIN_AZURE_SIGN_ACCOUNT: ${{ vars.WIN_AZURE_SIGN_ACCOUNT }} + WIN_AZURE_SIGN_PROFILE: ${{ vars.WIN_AZURE_SIGN_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: >- + ./scripts/pwragent-release/sign-windows-binaries.ps1 + -SigningToolsRoot signing-tools + -BinaryPath stage/windows-x86_64/codex.exe,stage/windows-x86_64/codex-app-server.exe,stage/windows-x86_64/codex-code-mode-host.exe,stage/windows-x86_64/codex-windows-sandbox-setup.exe,stage/windows-x86_64/codex-command-runner.exe + + - name: Package signed Windows distribution + shell: pwsh + env: + CODEX_VERSION: ${{ needs.metadata.outputs.version }} + run: >- + Compress-Archive + -Path "stage/windows-x86_64/*" + -DestinationPath "pwragent-codex-$env:CODEX_VERSION-windows-x86_64.zip" + + - name: Upload signed Windows release asset + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: release-windows-x86_64 + path: pwragent-codex-*-windows-x86_64.zip + if-no-files-found: error + retention-days: 7 + + release-candidate: + name: Assemble signed release candidate + if: >- + startsWith(github.ref, 'refs/tags/pwragent-v') + || (github.event_name == 'pull_request' + && contains(github.event.pull_request.labels.*.name, 'ci:release-signing')) + needs: + - metadata + - build + - macos-sign + - windows-sign + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: none + steps: + - name: Download release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: Generate checksums + shell: bash + run: | + set -euo pipefail + cd dist + mapfile -t assets < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' | sort) + test "${#assets[@]}" -eq 5 + sha256sum "${assets[@]}" > SHA256SUMS + + - name: Upload signed release candidate + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: signed-release-candidate + path: dist + if-no-files-found: error + retention-days: 7 + + release: + name: Publish GitHub release + if: startsWith(github.ref, 'refs/tags/pwragent-v') + needs: + - metadata + - release-candidate + runs-on: ubuntu-24.04 + permissions: + contents: write + id-token: none + steps: + - name: Download signed release candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: signed-release-candidate + path: dist + + - name: Publish immutable release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::Release $RELEASE_TAG already exists; downstream assets are immutable." >&2 + exit 1 + fi + gh release create "$RELEASE_TAG" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --prerelease \ + --title "PwrAgent Codex ${VERSION}" \ + --notes "Downstream Codex binaries for PwrDrvr products. Source commit: ${GITHUB_SHA}. macOS and Windows executables are signed by PwrDrvr LLC." diff --git a/docs/pwragent-distribution.md b/docs/pwragent-distribution.md new file mode 100644 index 000000000000..0bc4e965d9cf --- /dev/null +++ b/docs/pwragent-distribution.md @@ -0,0 +1,190 @@ +# PwrAgent Codex distribution + +Signed downstream Codex binaries for PwrDrvr products. One build, one signing +pass, consumed by PwrAgent, PwrSnap, and PwrGit — so none of them has to sign +Codex itself. + +Built by [`.github/workflows/pwragent-release.yml`](../.github/workflows/pwragent-release.yml). + +## What ships + +| Platform | Runner | Target | Asset | +| --- | --- | --- | --- | +| macOS arm64 | `macos-15` | `aarch64-apple-darwin` | `pwragent-codex--macos-aarch64.tar.gz` | +| macOS x64 | `macos-15-intel` | `x86_64-apple-darwin` | `pwragent-codex--macos-x86_64.tar.gz` | +| Linux arm64 | `ubuntu-24.04-arm` | `aarch64-unknown-linux-gnu` | `pwragent-codex--linux-aarch64.tar.gz` | +| Linux x64 | `ubuntu-24.04` | `x86_64-unknown-linux-gnu` | `pwragent-codex--linux-x86_64.tar.gz` | +| Windows x64 | `windows-2022` | `x86_64-pc-windows-msvc` | `pwragent-codex--windows-x86_64.zip` | + +Each archive contains `LICENSE`, `NOTICE`, a `PWRAGENT-BUILD.txt` provenance +stamp (version, source repository, source commit, target), and the binaries: + +- `codex` — the CLI. +- `codex-app-server` — the JSON-RPC surface PwrDrvr products drive. +- `codex-code-mode-host` — required for code mode; without it code mode fails + closed. +- `codex-windows-sandbox-setup`, `codex-command-runner` — Windows sandbox + helpers, Windows only. + +## Deliberate differences from upstream `rust-release.yml` + +Upstream's release pipeline cannot run on this fork as-is, so this is a separate +workflow rather than a reused one: + +| | Upstream | Downstream | +| --- | --- | --- | +| Runners | self-hosted groups (`codex-runners`, `macos-15-xlarge`) | GitHub-hosted only | +| Signing | OpenAI, `codesigning` environment, Azure Key Vault | PwrDrvr, `apple-signing` / `windows-signing` | +| Linux libc | MUSL, plus a bundled `bwrap` | glibc, no bundled `bwrap` | +| macOS layout | per-arch, DMG, dSYM symbol archives | per-arch tarballs, no DMG, no symbols | +| Windows arches | x64 and arm64 | x64 only | + +The MUSL and `bwrap` omissions are the ones most likely to matter later. +Upstream builds `bwrap` first and embeds its digest into `codex` so the bundled +sandbox helper can be verified at runtime; this pipeline does not, so Linux +sandboxing falls back to whatever `codex` does without a bundled `bwrap`. +Revisit if a PwrDrvr product ships Codex on Linux to end users. + +## Signing + +### macOS — Developer ID, `apple-signing` environment + +Identity: `Developer ID Application: PwrDrvr LLC (T44CNHC4UH)`. + +| Secret | Contents | +| --- | --- | +| `CSC_LINK` | Base64-encoded Developer ID Application `.p12`, optionally with the `data:application/x-pkcs12;base64,` prefix | +| `CSC_KEY_PASSWORD` | Export password for that `.p12` | + +Every mach-O in the archive is signed with `--options runtime --timestamp`, then +verified: `codesign --verify --all-architectures --strict`, plus an exact-match +check on both `Authority=` and `TeamIdentifier=`. + +These binaries are **signed but not notarized**. They are intended to be nested +inside a PwrDrvr application bundle that is itself notarized, which works +because the nested code carries the same Team ID and hardened runtime. Shipping +one of these binaries standalone to end users would need a notarization step +that does not exist here yet. + +### Windows — Azure Trusted Signing, `windows-signing` environment + +| Variable | Value | +| --- | --- | +| `WIN_AZURE_SIGN_ACCOUNT` | `pwrdrvrsigning` | +| `WIN_AZURE_SIGN_ENDPOINT` | `https://eus.codesigning.azure.net/` | +| `WIN_AZURE_SIGN_PUBLISHER_NAME` | `PwrDrvr LLC` | +| `WIN_AZURE_SIGN_PROFILE` | `pwrdrvr-public-trust` | + +| Secret | Contents | +| --- | --- | +| `AZURE_TENANT_ID` | Entra tenant for the signing service principal | +| `AZURE_CLIENT_ID` | Service principal application ID | +| `AZURE_CLIENT_SECRET` | Service principal secret | + +The service principal needs the **Trusted Signing Certificate Profile Signer** +role on the signing account. + +All five `.exe` files are signed in one `Invoke-TrustedSigning` call, then each +is verified individually for a `Valid` signature, a `CN=PwrDrvr LLC` signer, and +the presence of an RFC 3161 timestamp. + +### Loading the Apple secrets + +`pwrdrvr/grok-build` carries `scripts/release/upload-csc-link-from-1password.sh`, +which reads the Developer ID `.p12` out of 1Password and pushes it to a repo's +`apple-signing` environment. It takes the repository from `GITHUB_REPOSITORY`, +so it can populate this repo without being copied here. + +## Why the pipeline is split into prepare and sign jobs + +The jobs that build and stage (`build`, `windows-prepare`) enter no environment +and read no secrets. They hand off a tarball plus its SHA-256, and the signing +jobs verify that digest before touching a credential. The +`check-release-signing.py` contract enforces this separation, so a future edit +that starts reading a secret from a build job fails CI rather than quietly +widening the blast radius of a compromised build step. + +The Windows TrustedSigning client is downloaded, catalog-verified, and +checksummed in the unprivileged `windows-prepare` job and shipped to the signing +job as pinned bytes. The signing job is forbidden from calling `Save-Module` or +`Install-Module`, so it cannot pull new code while holding credentials. + +## Running it + +### On a pull request + +The signed path is gated behind the **`ci:release-signing`** label. Add the +label to run the whole pipeline including both signing jobs; the run produces a +`signed-release-candidate` artifact but publishes nothing. + +Without the label, `pwragent-release.yml` does not build at all — only +`pwragent-release-check.yml` runs, which validates the contract and the pinned +TrustedSigning client without secrets. + +### Testing a build on an Apple Silicon Mac + +Apply the **`ci:macos-unsigned`** label to get an `unsigned-macos-aarch64` +artifact on the run. That is a single `macos-15` job in +`pwragent-macos-unsigned.yml` — no signing, no secrets, no other platforms. +Removing and re-adding the label reruns it; pushes to a labeled PR refresh the +artifact. + +The workflow is separate from `pwragent-release.yml` on purpose. The release +pipeline is fail-closed and `check-release-signing.py` pins that property, so an +unsigned output does not belong inside it. + +Budget around two hours on a cold cache — standard hosted macOS is a 3-core M1. +It shares its cache key with the release workflow's `macos-aarch64` leg (same +target, same profile, same binaries), so whichever ran last leaves a warm +`codex-rs/target` for the other. + +Download, unpack, and clear quarantine — the binaries are neither signed nor +notarized, so Gatekeeper refuses them until you do: + +```bash +tar -xzf pwragent-codex-*-macos-aarch64-unsigned.tar.gz +``` + +```bash +xattr -dr com.apple.quarantine codex codex-app-server codex-code-mode-host +``` + +`PWRAGENT-BUILD.txt` in the tarball records `signed=no` alongside the source +commit. These builds are for smoke-testing only and must never be shipped. + +### Manually (`workflow_dispatch`) + +A manual run builds every platform but enters no signing environment. It emits +the two Linux tarballs plus `unsigned-macos-aarch64`, `unsigned-macos-x86_64`, +and `unsigned-windows-x86_64` artifacts. Those are for smoke-testing a build; +they carry no signature and must never be shipped. + +Note that a labeled PR run enters the protected environments from +`refs/pull//merge`. If either environment gets a branch protection +rule, that ref has to be allowed or the signing jobs will hang waiting for a +reviewer. + +### Publishing + +Push a tag: + +```bash +git tag pwragent-v0.0.0-pwragent.1 +git push fork pwragent-v0.0.0-pwragent.1 +``` + +The tag suffix after `pwragent-v` becomes the version verbatim and must be +SemVer. Releases are immutable: the publish step fails if the tag already has a +release rather than overwriting assets. + +Untagged runs (`workflow_dispatch`, or a labeled PR) derive +`-pwragent.dev.`. Upstream leaves the workspace +version at `0.0.0` on `main` and only bumps it on release branches, so dev +builds usually read `0.0.0-pwragent.dev.N`. + +## Branch layout + +`pwragent` is this fork's default branch and the integration branch PwrDrvr +builds from. Feature work lands on `agent/*` branches and merges into +`pwragent`. Rebasing `pwragent` onto a newer upstream `main` is a manual +operation; nothing here does it automatically. diff --git a/scripts/pwragent-release/check-release-signing.py b/scripts/pwragent-release/check-release-signing.py new file mode 100644 index 000000000000..0028a4387ecd --- /dev/null +++ b/scripts/pwragent-release/check-release-signing.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Pin fail-closed invariants in the downstream release signing workflow. + +Adapted from the equivalent check in pwrdrvr/grok-build. The point is that the +signed release path cannot quietly degrade into an unsigned one: the assertions +below fail the build if a signing job loses its environment, if a preparation +job starts reading secrets, or if the release stops depending on both signers. +""" + +from pathlib import Path +import re +import sys + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_PATH = ROOT / ".github/workflows/pwragent-release.yml" +CHECK_WORKFLOW_PATH = ROOT / ".github/workflows/pwragent-release-check.yml" +UNSIGNED_WORKFLOW_PATH = ROOT / ".github/workflows/pwragent-macos-unsigned.yml" +WINDOWS_SIGNER_PATH = ROOT / "scripts/pwragent-release/sign-windows-binaries.ps1" +WINDOWS_SIGNING_PREPARER_PATH = ( + ROOT / "scripts/pwragent-release/prepare-trusted-signing.ps1" +) +WINDOWS_SIGNING_VERIFIER_PATH = ( + ROOT / "scripts/pwragent-release/verify-trusted-signing-tools.ps1" +) +RUNBOOK_PATH = ROOT / "docs/pwragent-distribution.md" + +# Every executable the downstream distribution ships and therefore must sign. +UNIX_BINARIES = ("codex", "codex-app-server", "codex-code-mode-host") +WINDOWS_BINARIES = UNIX_BINARIES + ( + "codex-windows-sandbox-setup", + "codex-command-runner", +) +# linux x2, macos x2, windows x1 +EXPECTED_RELEASE_ASSETS = 5 + + +def fail(message: str) -> None: + print(f"release signing contract: {message}", file=sys.stderr) + raise SystemExit(1) + + +def require(text: str, fragment: str, scope: str) -> None: + if fragment not in text: + fail(f"{scope} must contain {fragment!r}") + + +def job(workflow: str, name: str) -> str: + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-z0-9][a-z0-9-]*:\n|\Z)", + workflow, + ) + if match is None: + fail(f"workflow job {name!r} is missing") + return match.group(0) + + +workflow = WORKFLOW_PATH.read_text(encoding="utf-8") +check_workflow = CHECK_WORKFLOW_PATH.read_text(encoding="utf-8") +unsigned_workflow = UNSIGNED_WORKFLOW_PATH.read_text(encoding="utf-8") +windows_signer = WINDOWS_SIGNER_PATH.read_text(encoding="utf-8") +windows_signing_preparer = WINDOWS_SIGNING_PREPARER_PATH.read_text(encoding="utf-8") +windows_signing_verifier = WINDOWS_SIGNING_VERIFIER_PATH.read_text(encoding="utf-8") +runbook = RUNBOOK_PATH.read_text(encoding="utf-8") + +require(workflow, "id-token: none", "workflow") +require( + workflow, + "run: python3 scripts/pwragent-release/check-release-signing.py", + "metadata job", +) +require(workflow, "pull_request:", "workflow") +require(workflow, "- labeled", "workflow") +require(workflow, "- synchronize", "workflow") +require(workflow, "'ci:release-signing'", "workflow") +for fragment in ( + "github.event.action == 'labeled' || github.event.action == 'unlabeled'", + "github.event.label.name != 'ci:release-signing'", + "github.run_id", + "github.event.action == 'synchronize'", + "github.event.action == 'reopened'", + "github.event.label.name == 'ci:release-signing'", +): + require(workflow, fragment, "PR signing trigger guard") + +# Upstream's own release pipeline runs on self-hosted runner groups that do not +# exist on this fork. Keeping the downstream build on hosted runners is what +# makes it runnable here at all, so it is part of the contract. Comments are +# stripped first so the header can name the upstream runners it is avoiding. +workflow_code = "\n".join( + line for line in workflow.splitlines() if not line.lstrip().startswith("#") +) +if "-runners" in workflow_code or "self-hosted" in workflow_code: + fail("the downstream workflow must only use GitHub-hosted runners") + +build = job(workflow, "build") +macos_sign = job(workflow, "macos-sign") +windows_prepare = job(workflow, "windows-prepare") +windows_sign = job(workflow, "windows-sign") +release_candidate = job(workflow, "release-candidate") +release = job(workflow, "release") + +for name, section in (("build", build), ("windows-prepare", windows_prepare)): + if "environment:" in section or "secrets." in section: + fail(f"{name} must remain a no-secret preparation job") + +# The macOS payload ships with its own checksum file. GitHub Actions cannot +# export per-entry outputs from a matrix job, so unlike windows-sign there is no +# out-of-band digest to compare against on this side; within-run artifacts are +# the trust boundary. Do not "strengthen" this with a second artifact holding +# the same digest -- the same job writes both, so it proves nothing. +require(build, "name: signing-input-${{ matrix.platform }}", "build") + +for binary in UNIX_BINARIES: + require(build, f"--bin {binary}", "build") + +for fragment in ( + "scripts/pwragent-release/prepare-trusted-signing.ps1", + "-OutputRoot signing-tools", + "stage/windows-x86_64 signing-tools scripts/pwragent-release", + "signing-input-sha256:", +): + require(windows_prepare, fragment, "windows-prepare") + +for binary in WINDOWS_BINARIES: + require(windows_prepare, f"--bin {binary}", "windows-prepare") + +for fragment in ( + "startsWith(github.ref, 'refs/tags/pwragent-v')", + "contains(github.event.pull_request.labels.*.name, 'ci:release-signing')", + "environment: apple-signing", + "CSC_LINK: ${{ secrets.CSC_LINK }}", + "CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}", + "APPLE_TEAM_ID: T44CNHC4UH", + "Developer ID Application: PwrDrvr LLC (${APPLE_TEAM_ID})", + "--options runtime", + "--timestamp", + "codesign --verify --all-architectures --strict", + "TeamIdentifier=${APPLE_TEAM_ID}", +): + require(macos_sign, fragment, "macos-sign") + +# Every shipped mach-O has to go through codesign, not just the CLI entrypoint. +require( + macos_sign, + "for binary in " + " ".join(UNIX_BINARIES) + "; do", + "macos-sign", +) + +for fragment in ( + "startsWith(github.ref, 'refs/tags/pwragent-v')", + "contains(github.event.pull_request.labels.*.name, 'ci:release-signing')", + "environment: windows-signing", + "scripts/pwragent-release/sign-windows-binaries.ps1", + "-SigningToolsRoot signing-tools", + "WIN_AZURE_SIGN_PUBLISHER_NAME: ${{ vars.WIN_AZURE_SIGN_PUBLISHER_NAME }}", + "WIN_AZURE_SIGN_ENDPOINT: ${{ vars.WIN_AZURE_SIGN_ENDPOINT }}", + "WIN_AZURE_SIGN_ACCOUNT: ${{ vars.WIN_AZURE_SIGN_ACCOUNT }}", + "WIN_AZURE_SIGN_PROFILE: ${{ vars.WIN_AZURE_SIGN_PROFILE }}", + "AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}", + "AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}", + "AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}", +): + require(windows_sign, fragment, "windows-sign") + +for binary in WINDOWS_BINARIES: + require(windows_sign, f"stage/windows-x86_64/{binary}.exe", "windows-sign") + +if "Install-Module" in windows_sign or "Save-Module" in windows_sign: + fail("windows-sign must not acquire PowerShell modules inside the protected job") + +for dependency in ("macos-sign", "windows-sign"): + require(release_candidate, f"- {dependency}", "release-candidate") +require( + release_candidate, + "contains(github.event.pull_request.labels.*.name, 'ci:release-signing')", + "release-candidate", +) +require( + release_candidate, + f'test "${{#assets[@]}}" -eq {EXPECTED_RELEASE_ASSETS}', + "release-candidate", +) +require(release_candidate, "name: signed-release-candidate", "release-candidate") +require(release_candidate, "contents: read", "release-candidate") +require(release, "- release-candidate", "release") +require(release, "name: signed-release-candidate", "release") +require(release, "contents: write", "release") + +for fragment in ( + "WIN_AZURE_SIGN_PUBLISHER_NAME = $env:WIN_AZURE_SIGN_PUBLISHER_NAME", + "AZURE_CLIENT_SECRET = $env:AZURE_CLIENT_SECRET", + "Invoke-TrustedSigning @signingParameters", + "Get-AuthenticodeSignature -LiteralPath $resolvedBinary", + "SignatureStatus]::Valid", + "TimeStamperCertificate", + "CN=$expectedPublisher", + "verify-trusted-signing-tools.ps1", + "$verifiedSigningTools.ModuleManifest", + "$verifiedSigningTools.LocalAppDataRoot", +): + require(windows_signer, fragment, "Windows signing script") + +# Each binary is verified after signing, not just the last one in the list. +require(windows_signer, "foreach ($resolvedBinary in $resolvedBinaries) {", "Windows signing script") + +for fragment in ( + '"modules/TrustedSigning/$trustedSigningVersion/TrustedSigning.psd1"', + "Get-FileHash -Algorithm SHA256", + "Get-ChildItem -LiteralPath $resolvedSigningToolsRoot -File -Recurse -Force", + "TrustedSigning input files are not covered by SHA256SUMS", + "$uncoveredFiles -join", + "Microsoft.Trusted.Signing.Client.1.0.95", +): + require(windows_signing_verifier, fragment, "TrustedSigning verifier") + +for fragment in ( + 'trustedSigningVersion = "0.5.8"', + "Save-Module", + "-RequiredVersion $trustedSigningVersion", + "Test-FileCatalog", + "-Detailed", + "$moduleFiles.FullName", + 'Name -ne "PSGetModuleInfo.xml"', + "duplicate catalog leaf names", + "SignatureStatus]::Valid", + 'catalogSigner -ne "Microsoft Corporation"', + "Get-EveryDependency", + "-File -Recurse -Force", + "$filesToChecksum", + 'Join-Path $resolvedOutputRoot "SHA256SUMS"', +): + require(windows_signing_preparer, fragment, "TrustedSigning preparer") + +if "Install-PackageProvider" in windows_signing_preparer: + fail("TrustedSigning preparer must not bootstrap the legacy NuGet provider") + +for fragment in ( + "trusted-signing-preparation:", + "runs-on: windows-2022", + "timeout-minutes: 10", + "scripts/pwragent-release/prepare-trusted-signing.ps1", + "-OutputRoot $env:RUNNER_TEMP/signing-tools", + "Verify signing client after archive round-trip", + "tar.exe -czf", + "tar.exe -xzf", + "scripts/pwragent-release/verify-trusted-signing-tools.ps1", + "id-token: none", +): + require(check_workflow, fragment, "release signing check workflow") + +if "environment:" in check_workflow or "secrets." in check_workflow: + fail("release signing check workflow must not enter an environment or read secrets") + +# The `ci:macos-unsigned` workflow exists so that testing a build on an Apple +# Silicon Mac does not require relaxing anything above. That only holds while it +# stays a plain unsigned build: it must not reach for credentials, and it must +# not publish. Comments are stripped so its header can describe what it avoids. +unsigned_workflow_code = "\n".join( + line + for line in unsigned_workflow.splitlines() + if not line.lstrip().startswith("#") +) +if "environment:" in unsigned_workflow_code or "secrets." in unsigned_workflow_code: + fail("the unsigned macOS workflow must not enter an environment or read secrets") +for fragment in ("softprops/action-gh-release", "gh release", "contents: write"): + if fragment in unsigned_workflow_code: + fail(f"the unsigned macOS workflow must not publish ({fragment!r})") +for fragment in ( + "'ci:macos-unsigned'", + "name: unsigned-macos-aarch64", + "signed=no", + "id-token: none", +): + require(unsigned_workflow, fragment, "unsigned macOS workflow") + +for fragment in ( + "Developer ID Application: PwrDrvr LLC (T44CNHC4UH)", + "`CSC_LINK`", + "`CSC_KEY_PASSWORD`", + "`WIN_AZURE_SIGN_ACCOUNT` | `pwrdrvrsigning`", + "`WIN_AZURE_SIGN_ENDPOINT` | `https://eus.codesigning.azure.net/`", + "`WIN_AZURE_SIGN_PUBLISHER_NAME` | `PwrDrvr LLC`", + "`WIN_AZURE_SIGN_PROFILE` | `pwrdrvr-public-trust`", + "`AZURE_TENANT_ID`", + "`AZURE_CLIENT_ID`", + "`AZURE_CLIENT_SECRET`", + "`ci:release-signing`", + "`signed-release-candidate`", + "`pwragent-v`", +): + require(runbook, fragment, "release signing runbook") + +print("release signing contract: ok") diff --git a/scripts/pwragent-release/prepare-trusted-signing.ps1 b/scripts/pwragent-release/prepare-trusted-signing.ps1 new file mode 100644 index 000000000000..f2ce96e45050 --- /dev/null +++ b/scripts/pwragent-release/prepare-trusted-signing.ps1 @@ -0,0 +1,119 @@ +param( + [Parameter(Mandatory = $true)] + [string]$OutputRoot +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +$trustedSigningVersion = "0.5.8" + +$resolvedOutputRoot = [System.IO.Path]::GetFullPath($OutputRoot) +$moduleRoot = Join-Path $resolvedOutputRoot "modules" +$localAppDataRoot = Join-Path $resolvedOutputRoot "localappdata" +New-Item -ItemType Directory -Force $moduleRoot, $localAppDataRoot | Out-Null + +Save-Module ` + -Name TrustedSigning ` + -RequiredVersion $trustedSigningVersion ` + -Repository PSGallery ` + -Path $moduleRoot + +$moduleManifest = Join-Path ` + $moduleRoot ` + "TrustedSigning/$trustedSigningVersion/TrustedSigning.psd1" +if (-not (Test-Path -LiteralPath $moduleManifest)) { + throw "TrustedSigning $trustedSigningVersion was not saved to $moduleManifest." +} + +$moduleMetadata = Import-PowerShellDataFile -LiteralPath $moduleManifest +if ([string]$moduleMetadata.ModuleVersion -ne $trustedSigningVersion) { + throw "Expected TrustedSigning $trustedSigningVersion, got $($moduleMetadata.ModuleVersion)." +} +if ([string]$moduleMetadata.CompanyName -ne "Microsoft") { + throw "Expected the TrustedSigning module publisher to be Microsoft." +} + +$catalogPath = Join-Path (Split-Path $moduleManifest) "catalog.cat" +$moduleFiles = @( + Get-ChildItem -LiteralPath (Split-Path $moduleManifest) -File -Recurse -Force | + Where-Object { + $_.Name -ne "PSGetModuleInfo.xml" -and + $_.FullName -ne $catalogPath + } +) +$duplicateLeafNames = @( + $moduleFiles | + Group-Object Name | + Where-Object Count -gt 1 | + Select-Object -ExpandProperty Name +) +if ($duplicateLeafNames.Count -ne 0) { + throw "TrustedSigning module has duplicate catalog leaf names: $($duplicateLeafNames -join ', ')" +} +$catalogResult = Test-FileCatalog ` + -Detailed ` + -Path $moduleFiles.FullName ` + -CatalogFilePath $catalogPath +if ([string]$catalogResult.Status -ne "Valid") { + $catalogKeys = @($catalogResult.CatalogItems.Keys) + $pathKeys = @($catalogResult.PathItems.Keys) + $mismatches = foreach ($key in @($catalogKeys + $pathKeys | Sort-Object -Unique)) { + $catalogHash = [string]$catalogResult.CatalogItems[$key] + $pathHash = [string]$catalogResult.PathItems[$key] + if ($catalogHash -ne $pathHash) { + "$key (catalog=$catalogHash, path=$pathHash)" + } + } + throw "TrustedSigning module catalog validation failed: $($catalogResult.Status); $($mismatches -join '; ')" +} +if ($null -eq $catalogResult.Signature) { + throw "TrustedSigning module catalog validation returned no signature." +} +if ($catalogResult.Signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "TrustedSigning module catalog signature is invalid: $($catalogResult.Signature.Status)" +} +if ($null -eq $catalogResult.Signature.SignerCertificate) { + throw "TrustedSigning module catalog validation returned no signer certificate." +} +$catalogSigner = $catalogResult.Signature.SignerCertificate.GetNameInfo( + [System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, + $false +) +if ($catalogSigner -ne "Microsoft Corporation") { + throw "Unexpected TrustedSigning module catalog signer: $catalogSigner" +} + +$env:LOCALAPPDATA = $localAppDataRoot +Import-Module $moduleManifest -Force -ErrorAction Stop +$dependencyModule = Join-Path ` + (Split-Path $moduleManifest) ` + "NugetInstall/NugetInstall.psd1" +Import-Module $dependencyModule -Force -ErrorAction Stop +$dependencies = Get-EveryDependency + +foreach ($dependencyPath in @( + $dependencies.DlibFolderPath, + $dependencies.SignToolFolderPath, + $dependencies.SignCliFolderPath +)) { + if (-not (Test-Path -LiteralPath $dependencyPath)) { + throw "TrustedSigning dependency was not prepared: $dependencyPath" + } +} + +$checksumManifest = Join-Path $resolvedOutputRoot "SHA256SUMS" +$filesToChecksum = @( + Get-ChildItem -LiteralPath $resolvedOutputRoot -File -Recurse -Force | + Where-Object { $_.FullName -ne $checksumManifest } | + Sort-Object FullName +) +$checksumLines = @( + $filesToChecksum | ForEach-Object { + $relativePath = [System.IO.Path]::GetRelativePath($resolvedOutputRoot, $_.FullName) + $sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant() + "$sha256 $($relativePath.Replace('\', '/'))" + } +) +$checksumLines | Set-Content -LiteralPath $checksumManifest -Encoding ascii + +Write-Host "Prepared pinned TrustedSigning $trustedSigningVersion and its dependencies in $resolvedOutputRoot." diff --git a/scripts/pwragent-release/sign-windows-binaries.ps1 b/scripts/pwragent-release/sign-windows-binaries.ps1 new file mode 100644 index 000000000000..1c396abf98d2 --- /dev/null +++ b/scripts/pwragent-release/sign-windows-binaries.ps1 @@ -0,0 +1,89 @@ +param( + [Parameter(Mandatory = $true)] + [string[]]$BinaryPath, + + [Parameter(Mandatory = $true)] + [string]$SigningToolsRoot +) + +$ErrorActionPreference = "Stop" +$expectedPublisher = "PwrDrvr LLC" + +$requiredEnvironment = [ordered]@{ + WIN_AZURE_SIGN_PUBLISHER_NAME = $env:WIN_AZURE_SIGN_PUBLISHER_NAME + WIN_AZURE_SIGN_ENDPOINT = $env:WIN_AZURE_SIGN_ENDPOINT + WIN_AZURE_SIGN_ACCOUNT = $env:WIN_AZURE_SIGN_ACCOUNT + WIN_AZURE_SIGN_PROFILE = $env:WIN_AZURE_SIGN_PROFILE + AZURE_TENANT_ID = $env:AZURE_TENANT_ID + AZURE_CLIENT_ID = $env:AZURE_CLIENT_ID + AZURE_CLIENT_SECRET = $env:AZURE_CLIENT_SECRET +} +$missing = @( + $requiredEnvironment.GetEnumerator() | + Where-Object { [string]::IsNullOrWhiteSpace([string]$_.Value) } | + ForEach-Object Key +) +if ($missing.Count -gt 0) { + throw "Windows release signing is required, but configuration is missing: $($missing -join ', ')" +} +if ($env:WIN_AZURE_SIGN_PUBLISHER_NAME -ne $expectedPublisher) { + throw "WIN_AZURE_SIGN_PUBLISHER_NAME must be '$expectedPublisher'." +} + +# Resolve every path before signing anything, so a typo fails the job before it +# spends a signing operation rather than halfway through the binary set. +$resolvedBinaries = @( + $BinaryPath | ForEach-Object { + $resolved = Resolve-Path -LiteralPath $_ -ErrorAction Stop + if ($resolved.Count -ne 1) { + throw "Expected exactly one path for '$_', got $($resolved.Count)." + } + $resolved.Path + } +) +if ($resolvedBinaries.Count -eq 0) { + throw "No binaries were supplied to sign." +} + +$verifiedSigningTools = & (Join-Path $PSScriptRoot "verify-trusted-signing-tools.ps1") ` + -SigningToolsRoot $SigningToolsRoot +$moduleManifest = $verifiedSigningTools.ModuleManifest +$env:LOCALAPPDATA = $verifiedSigningTools.LocalAppDataRoot + +Import-Module $moduleManifest -Force -ErrorAction Stop + +# One call per file. `Invoke-TrustedSigning -Files` is typed [string], not +# [string[]] -- passing an array fails argument transformation with "Cannot +# convert value to type System.String" before any signing happens. Sign and +# verify each binary in the same pass so a failure names the file it belongs to. +$expectedCommonName = "CN=$expectedPublisher" +foreach ($resolvedBinary in $resolvedBinaries) { + $signingParameters = @{ + Endpoint = $env:WIN_AZURE_SIGN_ENDPOINT + CodeSigningAccountName = $env:WIN_AZURE_SIGN_ACCOUNT + CertificateProfileName = $env:WIN_AZURE_SIGN_PROFILE + Files = $resolvedBinary + FileDigest = "SHA256" + TimestampRfc3161 = "http://timestamp.acs.microsoft.com" + TimestampDigest = "SHA256" + } + Invoke-TrustedSigning @signingParameters + + $signature = Get-AuthenticodeSignature -LiteralPath $resolvedBinary + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "Authenticode verification failed for ${resolvedBinary}: $($signature.Status) ($($signature.StatusMessage))" + } + if ($null -eq $signature.SignerCertificate) { + throw "Authenticode verification returned no signer certificate for $resolvedBinary." + } + if (-not $signature.SignerCertificate.Subject.StartsWith("$expectedCommonName,")) { + throw "Unexpected Authenticode signer for ${resolvedBinary}: $($signature.SignerCertificate.Subject)" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "The Authenticode signature for $resolvedBinary is valid but is not timestamped." + } + + Write-Host "Verified $resolvedBinary" + Write-Host " Authenticode signer: $($signature.SignerCertificate.Subject)" + Write-Host " RFC 3161 timestamp certificate: $($signature.TimeStamperCertificate.Subject)" +} diff --git a/scripts/pwragent-release/verify-trusted-signing-tools.ps1 b/scripts/pwragent-release/verify-trusted-signing-tools.ps1 new file mode 100644 index 000000000000..e0b1708553c3 --- /dev/null +++ b/scripts/pwragent-release/verify-trusted-signing-tools.ps1 @@ -0,0 +1,85 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SigningToolsRoot +) + +$ErrorActionPreference = "Stop" +$trustedSigningVersion = "0.5.8" + +$resolvedSigningToolsRoot = (Resolve-Path -LiteralPath $SigningToolsRoot).Path +$moduleManifest = Join-Path ` + $resolvedSigningToolsRoot ` + "modules/TrustedSigning/$trustedSigningVersion/TrustedSigning.psd1" +$localAppDataRoot = Join-Path $resolvedSigningToolsRoot "localappdata" +$checksumManifest = Join-Path $resolvedSigningToolsRoot "SHA256SUMS" +if (-not (Test-Path -LiteralPath $moduleManifest -PathType Leaf)) { + throw "Pinned TrustedSigning module is missing: $moduleManifest" +} +if (-not (Test-Path -LiteralPath $checksumManifest -PathType Leaf)) { + throw "Pinned TrustedSigning checksum manifest is missing." +} + +$rootPrefix = $resolvedSigningToolsRoot.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar +) + [System.IO.Path]::DirectorySeparatorChar +$verifiedPaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($line in Get-Content -LiteralPath $checksumManifest) { + if ($line -notmatch '^([a-f0-9]{64}) (.+)$') { + throw "Malformed TrustedSigning checksum entry: $line" + } + $expectedSha256 = $Matches[1] + $relativePath = $Matches[2].Replace('/', [System.IO.Path]::DirectorySeparatorChar) + $fullPath = [System.IO.Path]::GetFullPath( + (Join-Path $resolvedSigningToolsRoot $relativePath) + ) + if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "TrustedSigning checksum path escapes the prepared root: $relativePath" + } + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + throw "Prepared TrustedSigning file is missing: $relativePath" + } + $actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant() + if ($actualSha256 -ne $expectedSha256) { + throw "Prepared TrustedSigning checksum mismatch for $relativePath." + } + if (-not $verifiedPaths.Add($fullPath)) { + throw "Duplicate TrustedSigning checksum entry: $relativePath" + } +} + +$preparedFiles = @( + Get-ChildItem -LiteralPath $resolvedSigningToolsRoot -File -Recurse -Force | + Where-Object { $_.FullName -ne $checksumManifest } +) +$uncoveredFiles = @( + $preparedFiles | + Where-Object { -not $verifiedPaths.Contains($_.FullName) } | + ForEach-Object { + [System.IO.Path]::GetRelativePath($resolvedSigningToolsRoot, $_.FullName).Replace('\', '/') + } +) +if ($uncoveredFiles.Count -ne 0) { + throw "TrustedSigning input files are not covered by SHA256SUMS: $($uncoveredFiles -join ', ')" +} +if ($preparedFiles.Count -ne $verifiedPaths.Count) { + throw "TrustedSigning input file count does not match SHA256SUMS." +} + +foreach ($dependencyRoot in @( + "Microsoft.Windows.SDK.BuildTools/Microsoft.Windows.SDK.BuildTools.10.0.26100.4188", + "Microsoft.Trusted.Signing.Client/Microsoft.Trusted.Signing.Client.1.0.95", + "sign/sign.0.9.1-beta.24469.1" +)) { + $dependencyPath = Join-Path $localAppDataRoot "TrustedSigning/$dependencyRoot" + if (-not (Test-Path -LiteralPath $dependencyPath -PathType Container)) { + throw "Pinned TrustedSigning dependency is missing: $dependencyPath" + } +} + +[pscustomobject]@{ + ModuleManifest = $moduleManifest + LocalAppDataRoot = $localAppDataRoot +}