From 26dae44973005c90ddf3f940374ecca39eb027b7 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Tue, 18 Aug 2026 22:24:26 -0400 Subject: [PATCH 1/4] Add the PwrAgent Codex release pipeline (#2) --- .github/workflows/pwragent-release-check.yml | 79 +++ .github/workflows/pwragent-release.yml | 646 ++++++++++++++++++ docs/pwragent-distribution.md | 159 +++++ .../pwragent-release/check-release-signing.py | 270 ++++++++ .../prepare-trusted-signing.ps1 | 119 ++++ .../sign-windows-binaries.ps1 | 88 +++ .../verify-trusted-signing-tools.ps1 | 85 +++ 7 files changed, 1446 insertions(+) create mode 100644 .github/workflows/pwragent-release-check.yml create mode 100644 .github/workflows/pwragent-release.yml create mode 100644 docs/pwragent-distribution.md create mode 100644 scripts/pwragent-release/check-release-signing.py create mode 100644 scripts/pwragent-release/prepare-trusted-signing.ps1 create mode 100644 scripts/pwragent-release/sign-windows-binaries.ps1 create mode 100644 scripts/pwragent-release/verify-trusted-signing-tools.ps1 diff --git a/.github/workflows/pwragent-release-check.yml b/.github/workflows/pwragent-release-check.yml new file mode 100644 index 000000000000..ad80489bb500 --- /dev/null +++ b/.github/workflows/pwragent-release-check.yml @@ -0,0 +1,79 @@ +# 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" + - "docs/pwragent-distribution.md" + - "scripts/pwragent-release/**" + push: + branches: + - pwragent + paths: + - ".github/workflows/pwragent-release*.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..1872283a08a2 --- /dev/null +++ b/.github/workflows/pwragent-release.yml @@ -0,0 +1,646 @@ +# 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. + +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: 120 + 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 }} + + - 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-${{ matrix.platform }}-${{ hashFiles('codex-rs/Cargo.lock') }} + restore-keys: | + cargo-registry-${{ 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: 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: 120 + 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: Cache Cargo registry + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: cargo-registry-windows-x86_64-${{ hashFiles('codex-rs/Cargo.lock') }} + restore-keys: | + cargo-registry-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: 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..30ea8a6b379f --- /dev/null +++ b/docs/pwragent-distribution.md @@ -0,0 +1,159 @@ +# 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. + +### 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..5b824a70f249 --- /dev/null +++ b/scripts/pwragent-release/check-release-signing.py @@ -0,0 +1,270 @@ +#!/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" +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") +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") + +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..f1e2bedb8d80 --- /dev/null +++ b/scripts/pwragent-release/sign-windows-binaries.ps1 @@ -0,0 +1,88 @@ +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 covering every file. Invoke-TrustedSigning accepts a file list, and a +# single call keeps the signing account round-trips proportional to releases +# rather than to the number of binaries Codex ships. +$signingParameters = @{ + Endpoint = $env:WIN_AZURE_SIGN_ENDPOINT + CodeSigningAccountName = $env:WIN_AZURE_SIGN_ACCOUNT + CertificateProfileName = $env:WIN_AZURE_SIGN_PROFILE + Files = $resolvedBinaries + FileDigest = "SHA256" + TimestampRfc3161 = "http://timestamp.acs.microsoft.com" + TimestampDigest = "SHA256" +} +Invoke-TrustedSigning @signingParameters + +$expectedCommonName = "CN=$expectedPublisher" +foreach ($resolvedBinary in $resolvedBinaries) { + $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 +} From e7e3a704e778e48266622da26f6fe096168e0f03 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Wed, 19 Aug 2026 13:42:33 -0400 Subject: [PATCH 2/4] Add a Linux x64 PR gate for codex-rs (#4) --- .github/workflows/pwragent-ci.yml | 139 ++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/pwragent-ci.yml 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 }} From f1ed3d507db6f0108abaf6b6420d83561768f2e8 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Wed, 19 Aug 2026 16:11:29 -0400 Subject: [PATCH 3/4] Fix Windows signing, raise build timeouts, keep the cache on failure (#6) Three fixes from the first end-to-end run. Authenticode signing failed before signing anything: Cannot process argument transformation on parameter 'Files'. Cannot convert value to type System.String. `Invoke-TrustedSigning -Files` is typed [string], not [string[]]. grok-build passes a single path, which is the shape the module actually accepts; batching five was my assumption and it was wrong. Now one call per binary, signing and verifying in the same pass so a failure names the file it belongs to. Five signing round-trips instead of one, which costs seconds. Both macOS builds were killed at the 120 minute timeout. Measured on standard hosted runners: linux-aarch64 43m, linux-x86_64 55m, windows-prepare 101m, macos-aarch64 114m, macos-x86_64 killed at 120m. Standard hosted macOS is a 3-core M1 and roughly 2x slower than ubuntu for this workspace. Raised to 240, well inside GitHub's 6 hour per-job ceiling. The cache was also not being kept. The combined `actions/cache` skips its post-step save when a job fails, so the timed-out macOS jobs discarded everything they had compiled. Split into restore/save with `if: always()`, and extended to cover codex-rs/target so a killed build resumes rather than restarting. Upstream uses the same split in bazel.yml with a `!cancelled()` guard; that guard is deliberately omitted here, since a timeout is precisely the case whose output is worth keeping. The save key carries run_id and run_attempt because cache entries are immutable once written. --- .github/workflows/pwragent-release.yml | 52 +++++++++++++++---- .../sign-windows-binaries.ps1 | 29 ++++++----- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pwragent-release.yml b/.github/workflows/pwragent-release.yml index 1872283a08a2..ac85d800f287 100644 --- a/.github/workflows/pwragent-release.yml +++ b/.github/workflows/pwragent-release.yml @@ -116,7 +116,7 @@ jobs: 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: 120 + timeout-minutes: 240 permissions: contents: read id-token: none @@ -138,16 +138,25 @@ jobs: with: targets: ${{ matrix.target }} - - name: Cache Cargo registry - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + # 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 - key: cargo-registry-${{ matrix.platform }}-${{ hashFiles('codex-rs/Cargo.lock') }} + codex-rs/target + key: cargo-${{ matrix.platform }}-${{ hashFiles('codex-rs/Cargo.lock') }} restore-keys: | - cargo-registry-${{ matrix.platform }}- + cargo-${{ matrix.platform }}- - name: Configure rusty_v8 artifact overrides and verify checksums uses: ./.github/actions/setup-rusty-v8 @@ -164,6 +173,17 @@ jobs: --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: | @@ -365,7 +385,7 @@ jobs: name: Prepare Windows signing input needs: metadata runs-on: windows-2022 - timeout-minutes: 120 + timeout-minutes: 240 permissions: contents: read id-token: none @@ -385,16 +405,17 @@ jobs: with: targets: x86_64-pc-windows-msvc - - name: Cache Cargo registry - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + - name: Restore Cargo cache + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db - key: cargo-registry-windows-x86_64-${{ hashFiles('codex-rs/Cargo.lock') }} + codex-rs/target + key: cargo-windows-x86_64-${{ hashFiles('codex-rs/Cargo.lock') }} restore-keys: | - cargo-registry-windows-x86_64- + cargo-windows-x86_64- - name: Configure rusty_v8 artifact overrides and verify checksums uses: ./.github/actions/setup-rusty-v8 @@ -425,6 +446,17 @@ jobs: --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: | diff --git a/scripts/pwragent-release/sign-windows-binaries.ps1 b/scripts/pwragent-release/sign-windows-binaries.ps1 index f1e2bedb8d80..1c396abf98d2 100644 --- a/scripts/pwragent-release/sign-windows-binaries.ps1 +++ b/scripts/pwragent-release/sign-windows-binaries.ps1 @@ -52,22 +52,23 @@ $env:LOCALAPPDATA = $verifiedSigningTools.LocalAppDataRoot Import-Module $moduleManifest -Force -ErrorAction Stop -# One call covering every file. Invoke-TrustedSigning accepts a file list, and a -# single call keeps the signing account round-trips proportional to releases -# rather than to the number of binaries Codex ships. -$signingParameters = @{ - Endpoint = $env:WIN_AZURE_SIGN_ENDPOINT - CodeSigningAccountName = $env:WIN_AZURE_SIGN_ACCOUNT - CertificateProfileName = $env:WIN_AZURE_SIGN_PROFILE - Files = $resolvedBinaries - FileDigest = "SHA256" - TimestampRfc3161 = "http://timestamp.acs.microsoft.com" - TimestampDigest = "SHA256" -} -Invoke-TrustedSigning @signingParameters - +# 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))" From 8da93b365da0964599bfd9316f0c2c5d1ddea1c5 Mon Sep 17 00:00:00 2001 From: huntharo Date: Wed, 19 Aug 2026 17:08:17 -0400 Subject: [PATCH 4/4] Add a ci:macos-unsigned label for unsigned macOS arm64 test builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Getting a build onto an Apple Silicon Mac meant either a workflow_dispatch run of the whole release pipeline, or applying `ci:release-signing` and pulling the unsigned `signing-input-macos-aarch64` tarball out of a run that also entered both signing environments. Neither is what you want when the question is just "does this change work on my machine". `ci:macos-unsigned` now runs one macos-15 job that builds aarch64-apple-darwin and attaches `unsigned-macos-aarch64`. The job summary carries the `xattr -dr com.apple.quarantine` line, since the binaries are neither signed nor notarized and Gatekeeper refuses them otherwise. This is a separate workflow rather than a second label inside pwragent-release.yml on purpose. That pipeline is fail-closed and check-release-signing.py pins the property; adding an unsigned escape hatch would have meant gating the signing jobs off inside the one file written to make that impossible. So the contract check is extended to cover the new workflow instead: it must not enter an environment, read secrets, take `contents: write`, or rename its artifact off the `unsigned-` prefix. Each assertion was mutation-tested. pwragent-release-check.yml gains the file in its paths so the check actually runs when it changes. Timeout and cache follow #6 rather than the pre-#6 shape: 240 minutes, and restore/save split with `if: always()` so a killed build keeps its partial target dir. The cache key is deliberately the release workflow's `cargo-macos-aarch64-` — same target, same release profile, same three binaries — so a cold two-hour build only happens when neither has run. Co-Authored-By: Claude Opus 5 --- .github/workflows/pwragent-macos-unsigned.yml | 205 ++++++++++++++++++ .github/workflows/pwragent-release-check.yml | 2 + .github/workflows/pwragent-release.yml | 5 + docs/pwragent-distribution.md | 31 +++ .../pwragent-release/check-release-signing.py | 24 ++ 5 files changed, 267 insertions(+) create mode 100644 .github/workflows/pwragent-macos-unsigned.yml 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 index ad80489bb500..8a610025396c 100644 --- a/.github/workflows/pwragent-release-check.yml +++ b/.github/workflows/pwragent-release-check.yml @@ -10,6 +10,7 @@ on: pull_request: paths: - ".github/workflows/pwragent-release*.yml" + - ".github/workflows/pwragent-macos-unsigned.yml" - "docs/pwragent-distribution.md" - "scripts/pwragent-release/**" push: @@ -17,6 +18,7 @@ on: - pwragent paths: - ".github/workflows/pwragent-release*.yml" + - ".github/workflows/pwragent-macos-unsigned.yml" - "docs/pwragent-distribution.md" - "scripts/pwragent-release/**" diff --git a/.github/workflows/pwragent-release.yml b/.github/workflows/pwragent-release.yml index ac85d800f287..7a370df68ceb 100644 --- a/.github/workflows/pwragent-release.yml +++ b/.github/workflows/pwragent-release.yml @@ -11,6 +11,11 @@ # # 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 diff --git a/docs/pwragent-distribution.md b/docs/pwragent-distribution.md index 30ea8a6b379f..0bc4e965d9cf 100644 --- a/docs/pwragent-distribution.md +++ b/docs/pwragent-distribution.md @@ -121,6 +121,37 @@ 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 diff --git a/scripts/pwragent-release/check-release-signing.py b/scripts/pwragent-release/check-release-signing.py index 5b824a70f249..0028a4387ecd 100644 --- a/scripts/pwragent-release/check-release-signing.py +++ b/scripts/pwragent-release/check-release-signing.py @@ -15,6 +15,7 @@ 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" @@ -56,6 +57,7 @@ def job(workflow: str, name: str) -> str: 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") @@ -250,6 +252,28 @@ def job(workflow: str, name: str) -> str: 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`",