Preserve uninstaller signing and versioned NSIS caches across approval gates

This commit is contained in:
Neil
2026-09-05 22:11:57 -07:00
parent 06ff9d000a
commit da3eabe527
14 changed files with 506 additions and 374 deletions
+40 -20
View File
@@ -42,13 +42,14 @@ jobs:
SIGNING_POLICY: ${{ inputs.signing_policy }}
ENVIRONMENT_PREFIX: ${{ inputs.environment_prefix }}
TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }}
run: |-
run: |
$ErrorActionPreference = 'Stop'
if ($env:PUBLISH -eq 'true') {
if ($env:SIGNING_POLICY -ne 'release-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows') { throw 'Publication requires production signing gates.' }
} elseif ($env:SIGNING_POLICY -ne 'test-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows-rehearsal' -or $env:TEST_CERTIFICATE_THUMBPRINT -notmatch '^[a-fA-F0-9]{40}$') {
throw 'Rehearsal requires isolated gates and a pinned test certificate.'
}
"ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP 'signing-electron-builder-cache')" >> $env:GITHUB_ENV
- name: Checkout
uses: actions/checkout@v6
with:
@@ -64,6 +65,7 @@ jobs:
git archive "$WORKFLOW_SHA" config/windows-signing | tar -x -C "$RUNNER_TEMP/signing-control"
git checkout "$WORKFLOW_SHA" -- .github/actions/install-signpath-module
git checkout "$WORKFLOW_SHA" -- config/scripts/resolve-7za-path.mjs config/scripts/generate-windows-blockmap.mjs
git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs config/scripts/replace-cached-nsis-elevate.mjs
- name: Setup pnpm
uses: pnpm/setup@v2
with:
@@ -78,11 +80,9 @@ jobs:
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |-
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: electron-builder-win-
path: ${{ runner.temp }}/signing-electron-builder-cache
key: electron-builder-signing-${{ inputs.signing_policy }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: electron-builder-signing-${{ inputs.signing_policy }}-
if: github.run_attempt == 1
- name: Install dependencies
uses: nick-fields/retry@v4
@@ -136,9 +136,10 @@ jobs:
timeout_minutes: 30
max_attempts: 3
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
command: if (Test-Path -LiteralPath $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH) { Remove-Item -LiteralPath $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH -Force -ErrorAction Stop }; node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config "$env:RUNNER_TEMP/signing-control/config/windows-signing/electron-builder-signing.config.cjs" --win --publish never
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}/uninstaller-signing/unsigned/orca-uninstaller.exe
if: github.run_attempt == 1
- name: Verify Windows node-pty ConPTY runtime
shell: pwsh
@@ -289,13 +290,14 @@ jobs:
SIGNING_POLICY: ${{ inputs.signing_policy }}
ENVIRONMENT_PREFIX: ${{ inputs.environment_prefix }}
TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }}
run: |-
run: |
$ErrorActionPreference = 'Stop'
if ($env:PUBLISH -eq 'true') {
if ($env:SIGNING_POLICY -ne 'release-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows') { throw 'Publication requires production signing gates.' }
} elseif ($env:SIGNING_POLICY -ne 'test-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows-rehearsal' -or $env:TEST_CERTIFICATE_THUMBPRINT -notmatch '^[a-fA-F0-9]{40}$') {
throw 'Rehearsal requires isolated gates and a pinned test certificate.'
}
"ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP 'signing-electron-builder-cache')" >> $env:GITHUB_ENV
- name: Checkout
uses: actions/checkout@v6
with:
@@ -311,6 +313,7 @@ jobs:
git archive "$WORKFLOW_SHA" config/windows-signing | tar -x -C "$RUNNER_TEMP/signing-control"
git checkout "$WORKFLOW_SHA" -- .github/actions/install-signpath-module
git checkout "$WORKFLOW_SHA" -- config/scripts/resolve-7za-path.mjs config/scripts/generate-windows-blockmap.mjs
git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs config/scripts/replace-cached-nsis-elevate.mjs
- name: Setup pnpm
uses: pnpm/setup@v2
with:
@@ -325,11 +328,9 @@ jobs:
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |-
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: electron-builder-win-
path: ${{ runner.temp }}/signing-electron-builder-cache
key: electron-builder-signing-${{ inputs.signing_policy }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: electron-builder-signing-${{ inputs.signing_policy }}-
if: github.run_attempt == 1
- name: Install dependencies
uses: nick-fields/retry@v4
@@ -371,6 +372,14 @@ jobs:
SIGNING_POLICY: ${{ inputs.signing_policy }}
TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }}
if: github.run_attempt == 1
- name: Restore signed uninstaller for the installer rebuild
shell: pwsh
run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/restore-uninstaller.ps1"'
env: &a1
TAG: ${{ inputs.tag }}
SIGNING_POLICY: ${{ inputs.signing_policy }}
TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }}
if: github.run_attempt == 1
- name: Replace cached elevate.exe with the signed copy
shell: pwsh
run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/replace-elevate.ps1"'
@@ -444,7 +453,7 @@ jobs:
CUT_BY: ${{ github.triggering_actor || github.actor }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
INNER_SIGNING_SUBMITTED: 'true'
INNER_SIGNING_SUBMITTED: "true"
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
@@ -521,13 +530,14 @@ jobs:
SIGNING_POLICY: ${{ inputs.signing_policy }}
ENVIRONMENT_PREFIX: ${{ inputs.environment_prefix }}
TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }}
run: |-
run: |
$ErrorActionPreference = 'Stop'
if ($env:PUBLISH -eq 'true') {
if ($env:SIGNING_POLICY -ne 'release-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows') { throw 'Publication requires production signing gates.' }
} elseif ($env:SIGNING_POLICY -ne 'test-signing' -or $env:ENVIRONMENT_PREFIX -ne 'windows-rehearsal' -or $env:TEST_CERTIFICATE_THUMBPRINT -notmatch '^[a-fA-F0-9]{40}$') {
throw 'Rehearsal requires isolated gates and a pinned test certificate.'
}
"ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP 'signing-electron-builder-cache')" >> $env:GITHUB_ENV
- name: Checkout
uses: actions/checkout@v6
with:
@@ -543,6 +553,7 @@ jobs:
git archive "$WORKFLOW_SHA" config/windows-signing | tar -x -C "$RUNNER_TEMP/signing-control"
git checkout "$WORKFLOW_SHA" -- .github/actions/install-signpath-module
git checkout "$WORKFLOW_SHA" -- config/scripts/resolve-7za-path.mjs config/scripts/generate-windows-blockmap.mjs
git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs config/scripts/replace-cached-nsis-elevate.mjs
- name: Setup pnpm
uses: pnpm/setup@v2
with:
@@ -555,11 +566,9 @@ jobs:
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |-
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: electron-builder-win-
path: ${{ runner.temp }}/signing-electron-builder-cache
key: electron-builder-signing-${{ inputs.signing_policy }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: electron-builder-signing-${{ inputs.signing_policy }}-
- name: Install dependencies
uses: nick-fields/retry@v4
with:
@@ -605,6 +614,15 @@ jobs:
TAG: ${{ inputs.tag }}
SIGNING_POLICY: ${{ inputs.signing_policy }}
TEST_CERTIFICATE_THUMBPRINT: ${{ vars.SIGNPATH_TEST_CERTIFICATE_THUMBPRINT }}
- name: Verify embedded uninstaller signature and receipt
shell: pwsh
run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/verify-uninstaller.ps1"'
env: *a1
- name: Verify shipped uninstaller during rehearsal
shell: pwsh
run: '& "$env:RUNNER_TEMP/signing-control/config/windows-signing/verify-shipped-uninstaller.ps1"'
env: *a1
if: "!inputs.publish"
- name: Upload Windows inner signing evidence
if: always()
uses: actions/upload-artifact@v7
@@ -613,6 +631,8 @@ jobs:
path: |
inner-signing-evidence.txt
inner-signing-list.txt
uninstaller-signing-evidence.txt
if-no-files-found: ignore
retention-days: 30
- name: Publish signed Windows release artifacts
+196 -72
View File
@@ -1,7 +1,7 @@
# Windows release signing gates
Windows releases wait for SignPath approval without reserving a runner. The same
GitHub Actions run builds unsigned inner binaries, waits at an environment gate,
GitHub Actions run builds unsigned inner binaries and exports the NSIS uninstaller, waits at an environment gate,
packages those signed binaries into NSIS, waits at a second gate, then verifies
and uploads the signed installer to the draft release. Publication depends on
successful finalization and the other platforms. Both signing waves remain
@@ -18,6 +18,11 @@ GitHub-hosted runners perform every signing stage. The existing isolated macOS
workflow can continue using Blacksmith. SignPath Foundation human approval is
unchanged. Rehearsal uses this exact stage graph with separate environments, the
test-signing policy and a pinned test certificate; it never publishes release assets.
The uninstaller shares the first signing request, so there is no third approval.
Its signed bytes and fresh embedding receipt survive checkpoint restoration; the
rehearsal additionally checks the uninstaller extracted or installed from the final EXE.
Tool caches use separate test/production keys and an isolated directory, including
versioned NSIS bundles resolved by the existing cached-elevate script.
## Recovery
@@ -36,85 +41,204 @@ test-signing policy and a pinned test certificate; it never publishes release as
- Service/API outages keep releases waiting. Check Cloud Run logs and Cloud Scheduler
failures. Do not remove the protection rule to recover a waiting release.
## Deployment and activation
## Setup: complete these steps before merging
Do not activate the production workflow until the rehearsal below passes. The release
preflight intentionally fails when the coordinator or protection configuration is missing.
No service, App or webhook is created by merely merging these files.
Merging changes the production release workflow immediately. It does **not** create
any infrastructure. If merged before setup, new release builds stop at signing
preflight. Deploy and rehearse from this PR branch first; merge after that succeeds.
There is no live coordinator URL yet. You enter the SignPath webhook settings in
**step 8**, after deployment gives you that URL.
1. Register a dedicated GitHub App owned by `stablyai`. Grant **Actions: read**,
**Contents: read**, **Deployments: read/write**, subscribe to
`deployment_protection_rule`, and install only on `stablyai/orca`. This service
does not need a user access token or repository administration access. Enable
the App's custom deployment protection rule support in GitHub settings.
Record its App ID and installation ID, and generate a private key. Leave the
webhook inactive until the service URL is available.
2. Create five separate Secret Manager secrets in the chosen GCP project:
`release-signing-github-private-key`, `release-signing-github-webhook`,
`release-signing-signpath-webhook`, `release-signing-reconcile`, and
`release-signing-signpath-api-token`. Generate distinct random 32-byte secrets
for the two webhooks and reconciliation. Store the App PEM as the first secret
and an Orca SignPath API credential as the last. Do not put credentials in git,
chat, the image, or ordinary Terraform environment settings.
3. Build the coordinator from the cloud workspace and push it to an existing
Artifact Registry repository. Cloud Run requires a Linux amd64 image:
The cloud/repository administrator performs steps 1–7; the SignPath configurator
performs step 8. The administrator then runs step 9. Commands below use Bash
(macOS/Linux or Git Bash on Windows), with authenticated `gh`, `gcloud`, Docker
Buildx and Terraform. Replace all capitalized placeholders with your own values.
```sh
docker buildx build --platform linux/amd64 \
-f cloud/apps/release-signing/Dockerfile \
-t REGION-docker.pkg.dev/PROJECT/REPOSITORY/release-signing:COMMIT \
--push cloud
```
### 1. Create and install the GitHub App
4. Use the **separate** Terraform root `cloud/infra/release-signing`, with a protected
remote state backend (configure using an ignored backend override for your GCS
bucket). Copy `terraform.tfvars.example` to ignored `terraform.tfvars`; fill App
IDs, project and immutable image digest. Enable Cloud Run, Secret Manager and
Cloud Scheduler APIs. Supply `TF_VAR_reconcile_token` from the reconciliation
secret. That value enters Scheduler and Terraform state; restrict access to both.
Run `terraform init`, `terraform plan`, then `terraform apply`. This root grants
the coordinator access only to its five secrets and uses no relay credentials.
Secret rotation requires a new service revision; Scheduler's header must match
the deployed reconciliation secret.
5. Set the App webhook URL to `<service URL>/webhooks/github`, its webhook secret
to the GitHub webhook secret, and activate it. Require successful GitHub ping
delivery. Configure the four environments using a repository administrator token:
Open **stablyai organization → Settings → Developer settings → GitHub Apps → New GitHub App**
([create App](https://github.com/organizations/stablyai/settings/apps/new)).
```sh
GH_TOKEN="$(gh auth token)" SIGNING_GATE_APP_ID=APP_ID \
node config/windows-signing/configure-environments.mjs
```
- Name: `Orca Release Signing` (or another available name).
- Homepage: `https://github.com/stablyai/orca`.
- Repository permissions: **Actions — Read**, **Contents — Read**, **Deployments — Read and write**.
- Subscribe to **Deployment protection rule** (`deployment_protection_rule`).
- Keep webhook delivery inactive until step 6.
- Create the App, then **Install App → stablyai → Only select repositories → orca**.
- From the App's General page, record its **App ID** and generate/download a private key.
- Record the **installation ID** from the installation settings URL (the number after `/installations/`).
The script creates `windows-{inner,installer}-signing` and
`windows-rehearsal-{inner,installer}-signing`, disables administrator bypass,
restricts branches to `main`, and enables the App rule. It refuses to overwrite
existing reviewer/timer or unexpected branch rules. For pre-merge rehearsal,
explicitly set `SIGNING_REHEARSAL_BRANCH` and the coordinator's rehearsal policy
branch to the review branch; restore both to `main` after merge.
6. Set repository variables `SIGNING_GATE_URL`, `SIGNING_GATE_APP_ID`, and
`SIGNPATH_TEST_CERTIFICATE_THUMBPRINT` (the 40-hex SHA-1 thumbprint of the SignPath
test signing certificate). `GET <service URL>/ready` must return the App ID,
repository and all four protected environments.
7. **In SignPath's webhook UI:** URL `<service URL>/webhooks/signpath`,
Authorization header `Bearer <value of release-signing-signpath-webhook>`.
Leave the experimental custom body template **off**. The standard payload is:
These are two different IDs. Do not paste the private key into the PR or chat.
```json
{"OrganizationId":"c37aa192-a27a-4377-9c90-5d6c95912dc0","SigningRequestId":"REQUEST_UUID","Status":"Completed"}
```
### 2. Confirm SignPath can sign every required file
In the Orca project, confirm `windows-inner-binaries-zip` covers the normal inner
PE files **and `uninstaller/orca-uninstaller.exe`**. The upstream uninstaller flow
could continue without that file; this PR deliberately stops the release if it is
missing or not signed. Keep `github-actions-windows-installer` for the outer EXE.
Both `release-signing` and `test-signing` must support this flow.
Record the **40-character certificate thumbprint** for the test-signing certificate.
This is a public certificate identifier, not an API token. Keep the existing
GitHub `SIGNPATH_API_TOKEN` Actions secret in place.
### 3. Store the coordinator's five credentials in Google Secret Manager
Choose the GCP project that will host this service. In that project's **Security →
Secret Manager**, create the following secrets with these exact names:
| Secret name | Secret value |
| --- | --- |
| `release-signing-github-private-key` | Entire downloaded GitHub App PEM file |
| `release-signing-github-webhook` | New random secret, at least 32 characters |
| `release-signing-signpath-webhook` | A different new random secret, at least 32 characters |
| `release-signing-reconcile` | A third new random secret, at least 32 characters |
| `release-signing-signpath-api-token` | SignPath API token with access to Orca signing requests |
Use a password manager to generate/store the three random values, or use
`openssl rand -hex 32` separately for each. The SignPath webhook secret is **not**
the SignPath API token. Never commit any of these values.
### 4. Build and push the service image from the PR branch
From the repository root:
```sh
git switch nwparker/async-release-signing
export SIGNING_PROJECT=YOUR_GCP_PROJECT
export SIGNING_REGION=us-central1
export SIGNING_REGISTRY=YOUR_EXISTING_ARTIFACT_REGISTRY_REPOSITORY
export SIGNING_IMAGE="$SIGNING_REGION-docker.pkg.dev/$SIGNING_PROJECT/$SIGNING_REGISTRY/release-signing:$(git rev-parse HEAD)"
gcloud services enable run.googleapis.com secretmanager.googleapis.com cloudscheduler.googleapis.com artifactregistry.googleapis.com --project "$SIGNING_PROJECT"
gcloud auth configure-docker "$SIGNING_REGION-docker.pkg.dev"
docker buildx build --platform linux/amd64 \
-f cloud/apps/release-signing/Dockerfile -t "$SIGNING_IMAGE" --push cloud
gcloud artifacts docker images describe "$SIGNING_IMAGE" --project "$SIGNING_PROJECT" --format='value(image_summary.fully_qualified_digest)'
```
Record the final `...@sha256:...` image reference. If there is no Artifact Registry
repository yet, create a **Docker** repository in the chosen region first.
### 5. Deploy the coordinator and the recovery scheduler
Copy `cloud/infra/release-signing/terraform.tfvars.example` to
`cloud/infra/release-signing/terraform.tfvars` (ignored by git). Fill in:
- `project`, `region`, and the full immutable image reference from step 4.
- `GITHUB_APP_ID` and `GITHUB_INSTALLATION_ID` from step 1.
- Leave the organization UUID and secret names at their supplied Orca values.
- In `SIGNING_POLICIES`, keep the **production** branch as `main`.
- Set the **rehearsal** branch to `nwparker/async-release-signing` for the pre-merge test.
For local Terraform runs, authenticate once with `gcloud auth application-default login`.
Use a protected remote Terraform state bucket for this separate root. Create
`cloud/infra/release-signing/backend_override.tf` (already ignored by git) containing:
```hcl
terraform {
backend "gcs" {}
}
```
Initialize with your bucket below. Do not use the relay Terraform state.
The scheduler's secret enters Terraform state, so only its administrators should
have access to that bucket.
```sh
export TF_VAR_reconcile_token="$(gcloud secrets versions access latest --secret=release-signing-reconcile --project "$SIGNING_PROJECT")"
terraform -chdir=cloud/infra/release-signing init \
-backend-config="bucket=YOUR_PROTECTED_STATE_BUCKET" \
-backend-config="prefix=release-signing"
terraform -chdir=cloud/infra/release-signing plan
terraform -chdir=cloud/infra/release-signing apply
export SIGNING_GATE_URL="$(terraform -chdir=cloud/infra/release-signing output -raw url)"
unset TF_VAR_reconcile_token
```
The Terraform **`url` output is the actual service URL**. The service scales down
when idle; Cloud Scheduler calls it every five minutes to recover missed callbacks.
### 6. Connect the GitHub App and protect the environments
Return to the App's **General** settings:
- Webhook URL: append `/webhooks/github` to the service URL from step 5.
- Webhook secret: value of **`release-signing-github-webhook`**.
- Enable webhook delivery. Verify a successful ping under **Recent deliveries**.
Then, from the repository root, run this with a repository administrator's `gh` login:
```sh
GH_TOKEN="$(gh auth token)" SIGNING_GATE_APP_ID=YOUR_APP_ID \
SIGNING_REHEARSAL_BRANCH=nwparker/async-release-signing \
node config/windows-signing/configure-environments.mjs
```
It enables the App rule on four environments, disables administrator bypass, and
restricts production to `main` and rehearsal to the PR branch. It refuses to replace
unexpected pre-existing rules; inspect any reported conflict in **Repository Settings → Environments**.
### 7. Set repository variables and check readiness
Open **stablyai/orca → Settings → Secrets and variables → Actions → Variables**.
Create these **repository variables** (not secrets):
| Variable | Value |
| --- | --- |
| `SIGNING_GATE_URL` | Service URL from step 5, without `/webhooks/...` |
| `SIGNING_GATE_APP_ID` | App ID from step 1, not the installation ID |
| `SIGNPATH_TEST_CERTIFICATE_THUMBPRINT` | 40-character test certificate thumbprint from step 2 |
Open `<service URL>/ready` or run `curl --fail "$SIGNING_GATE_URL/ready"`.
It must return HTTP 200 with the correct App ID, `stablyai/orca`, and all four
signing environment names. If it fails, finish the App/environment configuration
before proceeding. `/health` alone does not verify the protection rules.
### 8. Enter the webhook in SignPath — this is the SignPath UI step
In SignPath's webhook configuration, enter:
| SignPath field | Exact value to supply |
| --- | --- |
| **URL** | Service URL from step 5 followed by **`/webhooks/signpath`** |
| **Authorization header** | **`Bearer `** followed by the value of **`release-signing-signpath-webhook`** |
| **Use custom body template (experimental)** | **Off** |
Include the space after `Bearer`. Use the dedicated SignPath webhook secret from
step 3; do not use either the API token or the GitHub webhook secret. Save/enable
the webhook before running the rehearsal. The standard SignPath body is supported.
If the UI offers event selection, include **Completed, Failed, Denied, Canceled**.
### 9. Rehearse before merging
In **GitHub Actions → Windows signing rehearsal → Run workflow**:
- Branch: **`nwparker/async-release-signing`**.
- Tag: an existing Orca stable or RC release tag.
- Run it. This uses the test certificate and does not publish release assets.
The operator must verify both waits release their runner and resume through the App;
SignPath's API provenance matches the GitHub run and workflow commit; and the evidence
contains verified inner binaries, `elevate.exe`, and the actual shipped uninstaller.
The uninstaller uses the first request, so there should still be only **two** requests.
Also exercise denial, duplicate delivery, rerun finalization, and missed-webhook
recovery. A green unit-test run is not a substitute for this live rehearsal.
### 10. Merge, then return rehearsal settings to main
After the live rehearsal, review, and required CI pass, merge the PR. The next
production release uses the new signing gates and still requires normal Foundation
approvals. Keep all four custom protection rules enabled.
For future rehearsals, change the branch policy on **each**
`windows-rehearsal-*-signing` environment from the PR branch to **`main`** in GitHub's
Environment settings. Change the coordinator's rehearsal branch in `SIGNING_POLICIES`
to `main` too, and apply the Terraform update (load `TF_VAR_reconcile_token` again as
in step 5). Production branch settings already remain on `main` throughout.
If credentials are rotated later, deploy a new Cloud Run revision; the scheduler's
Authorization value must match that revision's reconciliation secret.
8. Dispatch `windows-signing-rehearsal.yml` against an existing stable/RC tag.
Verify both gates enter waiting before runner allocation and resume through the
App. Confirm the SignPath API reports `origin.buildData.url` as the GitHub run URL
(optionally `/job/ID`) and `origin.repositoryData.commitId` as the run's `head_sha`.
The built tag commit is separately pinned in the checkpoint. If actual API
semantics differ, correct the binding and regression tests before activation;
do not relax provenance checks. Exercise denial, duplicate delivery, rerun
finalization, and missed-webhook recovery via `/reconcile`. Download the evidence
and verify the nested installer payload passed under the pinned test certificate.
9. After successful rehearsal and review, activate the production caller. A real
release requires the normal Foundation approvals. Confirm both signing gates and
the signed installer evidence before allowing publication.
## Checks
+1
View File
@@ -3,3 +3,4 @@
*.tfplan
*.tfvars
*.tfvars.json
backend_override.tf
@@ -320,45 +320,40 @@ describe('a cached elevate.exe miss is not silent', () => {
})
})
describe('release-cut.yml swaps the cached elevate.exe through the resolver', () => {
function swapStep() {
const workflow = parse(
readFileSync(join(projectRoot, '.github/workflows/release-cut.yml'), 'utf8')
describe('Windows signing swaps the cached elevate.exe through the resolver', () => {
const workflow = parse(
readFileSync(join(projectRoot, '.github/workflows/release-windows-signing.yml'), 'utf8')
)
const step = workflow.jobs.package.steps.find(
(candidate) => candidate.name === 'Replace cached elevate.exe with the signed copy'
)
const script = readFileSync(
join(projectRoot, 'config/windows-signing/replace-elevate.ps1'),
'utf8'
)
it('delegates to the authoritative toolset resolver', () => {
expect(step.run).toContain('replace-elevate.ps1')
expect(script).toContain('node config/scripts/replace-cached-nsis-elevate.mjs $signed')
expect(script).not.toContain('electron-builder\\Cache\\nsis')
})
it('blocks the rebuild when the resolver reports a miss', () => {
expect(script).toMatch(/if \(\$LASTEXITCODE -ne 0\) \{ throw /)
expect(step['continue-on-error']).toBeUndefined()
})
it('requires the correct certificate and isolates test caches from production', () => {
expect(script).toContain('Assert-SigningCertificate')
const policy = readFileSync(
join(projectRoot, 'config/windows-signing/signature-policy.ps1'),
'utf8'
)
const step = workflow.jobs.build.steps.find(
(candidate) => candidate.name === 'Replace cached elevate.exe with the signed copy'
expect(policy).toContain("$signature.Status -ne 'Valid'")
expect(policy).toContain(
"$signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'"
)
expect(step).toBeDefined()
return step
}
it('delegates the cache lookup to the script instead of an inline path', () => {
const step = swapStep()
expect(step.run).toContain('node config/scripts/replace-cached-nsis-elevate.mjs $signed')
// The hardcoded miss that shipped v1.4.193/v1.4.194 unsigned.
expect(step.run).not.toContain('electron-builder\\Cache\\nsis')
expect(step.run).not.toContain('-ErrorAction SilentlyContinue')
})
it('fails the step when the swap reports a miss', () => {
const step = swapStep()
// Matched as an executed statement: downgrading this to a Write-Host restores
// the silent fail-open that let the unsigned helper ship.
expect(step.run).toMatch(/if \(\$LASTEXITCODE -ne 0\) \{/)
expect(step.run).toMatch(/^\s*throw \$message\s*$/m)
expect(step.run).toContain('GITHUB_STEP_SUMMARY')
})
// Why kept: windows-signing-rehearsal.yml shares the electron-builder-win-<hash>
// cache key, so dropping this guard would let a test certificate reach a release cache.
it('still refuses to stage anything but a SignPath-signed helper', () => {
const step = swapStep()
expect(step.run).toContain("$signature.Status -ne 'Valid'")
expect(step.run).toContain("$subject -notlike '*CN=SignPath Foundation*'")
})
// The inner-signing chain stays fail-open: a loud red step, not an unbuildable release.
it('keeps the step unable to fail the release job', () => {
expect(swapStep()['continue-on-error']).toBe(true)
const cache = workflow.jobs.package.steps.find(
(s) => s.name === 'Cache electron-builder downloads'
)
expect(cache.with.key).toContain('${{ inputs.signing_policy }}')
expect(cache.with['restore-keys']).toContain('${{ inputs.signing_policy }}')
})
})
@@ -1,5 +1,4 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
@@ -204,229 +203,83 @@ describe('Windows signing workflow contract', () => {
// CI can sign it is the export/import relay through win.signtoolOptions.sign.
// Every link is asserted here the way Orca.exe and conpty_console_list.node are.
describe('Windows NSIS uninstaller signing', () => {
const releaseSteps = () => readWorkflow('.github/workflows/release-cut.yml').jobs.build.steps
const stepNamed = (steps, name) => steps.find((step) => step.name === name)
const workflow = readWorkflow('.github/workflows/release-windows-signing.yml')
const allSteps = Object.values(workflow.jobs).flatMap((job) => job.steps)
const step = (name) => allSteps.find((s) => s.name === name)
const script = (name) => readFileSync(join(projectDir, `config/windows-signing/${name}`), 'utf8')
const EXPORT_ENV = 'ORCA_WIN_UNINSTALLER_EXPORT_PATH'
const SIGNED_ENV = 'ORCA_WIN_UNINSTALLER_SIGNED_PATH'
it('exports the uninstaller from the first Windows build', () => {
const build = stepNamed(releaseSteps(), 'Build Windows release artifacts')
expect(build.env[EXPORT_ENV]).toContain('uninstaller-signing')
expect(build.env[EXPORT_ENV]).toContain('orca-uninstaller.exe')
it('exports outside the checkout and includes the uninstaller in the first request', () => {
const build = step('Build Windows release artifacts')
expect(build.env.ORCA_WIN_UNINSTALLER_EXPORT_PATH).toContain('${{ runner.temp }}')
expect(build.with.command).toContain('electron-builder-signing.config.cjs')
expect(script('stage-inner.ps1')).toContain('uninstaller/orca-uninstaller.exe')
expect(script('stage-inner.ps1')).toContain(
"throw 'The NSIS build did not export an uninstaller"
)
expect(allSteps.filter((s) => s.uses?.startsWith('signpath/'))).toHaveLength(2)
})
// Why this is a test and not a comment: `files` in the electron-builder config
// is all-negation, so app-builder packs whatever is left in the checkout root.
// These steps retry, and a retried attempt would pack an unsigned .exe into
// app.asar — the very defect this chain removes. Every relay path must live
// outside the checkout.
it('keeps every relay path out of the packed checkout', () => {
const relayEnvValues = [
...releaseSteps(),
...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps
].flatMap((step) => [step.env?.[EXPORT_ENV], step.env?.[SIGNED_ENV]].filter(Boolean))
it('restores the signed uninstaller before rebuilding and rejects missing or invalid signatures', () => {
const names = workflow.jobs.package.steps.map((s) => s.name)
expect(names.indexOf('Restore signed uninstaller for the installer rebuild')).toBeLessThan(
names.indexOf('Rebuild NSIS installer from signed unpacked app')
)
expect(script('restore-uninstaller.ps1')).toContain('Assert-SigningCertificate')
expect(script('package-installer.ps1')).toContain('ORCA_WIN_UNINSTALLER_SIGNED_PATH')
expect(script('package-installer.ps1')).toContain('Remove-Item -LiteralPath $receipt')
expect(script('package-installer.ps1')).toContain('verify-uninstaller.ps1')
})
expect(relayEnvValues.length).toBe(4)
for (const value of relayEnvValues) {
expect(value).toContain('runner.temp')
expect(value).not.toContain('github.workspace')
}
const relayScripts = [
...releaseSteps(),
...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps
]
.map((step) => step.run ?? '')
.filter((run) => run.includes('uninstaller-signing'))
expect(relayScripts.length).toBeGreaterThan(0)
for (const run of relayScripts) {
// Why count occurrences rather than assert `toContain` once: a step
// carrying two relay paths could root the first in RUNNER_TEMP and leave
// the second bare-relative — which resolves against the checkout, and is
// exactly the shape of the defect this test exists to catch.
const mentions = run.match(/uninstaller-signing/g) ?? []
const rooted = run.match(/Join-Path \$env:RUNNER_TEMP 'uninstaller-signing/g) ?? []
expect(rooted.length, run).toBe(mentions.length)
expect(run).not.toContain('$env:GITHUB_WORKSPACE')
it('preserves the relay hook when the release tag predates it', () => {
const wrapper = script('electron-builder-signing.config.cjs')
expect(wrapper).toContain('config/electron-builder.config.cjs')
expect(wrapper).toContain('sign: signWindowsUninstallerViaSignPath')
for (const job of Object.values(workflow.jobs)) {
const load = job.steps.find((s) => s.name === 'Load signing control from the workflow commit')
expect(load.run).toContain(
'git checkout "$WORKFLOW_SHA" -- config/scripts/windows-uninstaller-signing.cjs'
)
}
})
it('stages the uninstaller into the same request as the inner binaries', () => {
const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing')
expect(stage.run).toContain('uninstaller-signing\\unsigned\\orca-uninstaller.exe')
expect(stage.run).toContain('uninstaller\\orca-uninstaller.exe')
// No third SignPath request: exactly two submissions, as budgeted for the
// 1h + 4h approval waits inside the 360-minute job cap.
const submissions = releaseSteps().filter(
(step) => step.uses === 'signpath/github-action-submit-signing-request@v2'
)
expect(submissions).toHaveLength(2)
})
// A staged-but-unreturned uninstaller must not fail the inner chain, or a
// SignPath artifact-configuration gap would cost the inner-binary signatures.
it('keeps the uninstaller out of the inner-binary copy-back list', () => {
const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing')
const restoreInner = stepNamed(
releaseSteps(),
'Restore signed inner binaries into unpacked app'
)
expect(stage.run).not.toMatch(/\$list\.Add\(['"]uninstaller/)
expect(restoreInner.run).not.toContain('orca-uninstaller.exe')
})
// This step's outcome gates the upload of every inner binary, so a filesystem
// error while staging the uninstaller must not escape — otherwise one
// uninstaller-specific failure costs every inner-binary signature, which is
// strictly worse than the behaviour before this chain existed.
it('cannot let an uninstaller staging failure cost the inner-binary signatures', () => {
const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing')
const uninstallerBlock = stage.run.slice(stage.run.indexOf('$exportedUninstaller'))
expect(stage.run).toMatch(/try \{[\s\S]*\$exportedUninstaller[\s\S]*\} catch \{/)
expect(uninstallerBlock).toContain('::warning::Could not stage the NSIS uninstaller')
expect(uninstallerBlock).not.toContain('throw')
// Explicit, so the catch does not silently depend on GitHub's
// $ErrorActionPreference='Stop' default for `shell: pwsh`.
expect(uninstallerBlock).toContain('New-Item -ItemType Directory -Force -Path (Split-Path')
expect(uninstallerBlock).toMatch(/New-Item[^\r\n]*-ErrorAction Stop/)
expect(uninstallerBlock).toMatch(/Copy-Item[^\r\n]*-ErrorAction Stop/)
// The upload it gates still keys off this step, so the catch is load-bearing.
expect(stepNamed(releaseSteps(), 'Upload unsigned inner binaries for SignPath').if).toContain(
"steps.stage-inner.outcome == 'success'"
)
})
it('re-injects the signed uninstaller into the rebuilt installer', () => {
const steps = releaseSteps()
const restore = stepNamed(steps, 'Restore signed uninstaller for the installer rebuild')
const rebuild = stepNamed(steps, 'Rebuild NSIS installer from signed unpacked app')
const names = steps.map((step) => step.name)
expect(restore.if).toContain('github.run_attempt == 1')
expect(restore.if).toContain("steps.restore-signed-inner.outcome == 'success'")
expect(restore.run).toContain('orca-uninstaller.exe')
expect(names.indexOf(restore.name)).toBeLessThan(names.indexOf(rebuild.name))
expect(rebuild.env[SIGNED_ENV]).toContain('uninstaller-signing')
// The rebuild must not depend on the uninstaller leg: a missing signed
// uninstaller ships today's installer, it does not skip the rebuild.
expect(rebuild.if).not.toContain('restore-signed-uninstaller')
})
// NSIS hides the uninstaller in a compressed data section the bundled 7za
// cannot read, so the gate proves it from the sign hook's digest receipt
// instead of extracting it — and only when the relay actually ran.
it('reports the embedded uninstaller in the inner-binary evidence gate', () => {
const gate = stepNamed(releaseSteps(), 'Verify Windows inner binary signatures')
expect(gate.env.UNINSTALLER_SIGNING_COMPLETED).toBe(
"${{ steps.restore-signed-uninstaller.outcome == 'success' }}"
)
expect(gate.run).toContain('.embedded-sha256')
expect(gate.run).toContain("$env:UNINSTALLER_SIGNING_COMPLETED -eq 'true'")
expect(gate.run).toContain('not signed by SignPath Foundation: Uninstall Orca.exe')
// The uninstaller must not join the 7z payload loop, which cannot see it.
expect(gate.run).not.toContain("$targets += 'Uninstall Orca.exe'")
})
it('rehearses the uninstaller leg end to end', () => {
const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse
.steps
const names = steps.map((step) => step.name)
const pack = stepNamed(steps, 'Package Windows app and export the NSIS uninstaller')
const rebuild = stepNamed(steps, 'Build NSIS installer from signed unpacked app')
const verify = stepNamed(steps, 'Verify signatures end to end')
// --dir never produces an uninstaller, so the rehearsal has to build the
// installer the way release-cut's first Windows pass does.
expect(pack.run).toContain('--win --publish never')
expect(pack.run).not.toContain('--dir')
expect(pack.env[EXPORT_ENV]).toContain('orca-uninstaller.exe')
expect(names).toContain('Restore signed uninstaller for the installer rebuild')
expect(rebuild.env[SIGNED_ENV]).toContain('orca-uninstaller.exe')
expect(verify.run).toContain('.embedded-sha256')
// The receipt only proves the import leg ran. The rehearsal is where the
// shipped uninstaller itself gets checked — the release job cannot install
// onto the runner it publishes from.
expect(verify.run).toContain('shipped: Uninstall Orca.exe')
expect(verify.run).toContain('-tnsis')
expect(verify.run).toContain("-ArgumentList '/S'")
})
// This workflow is the merge gate, so it must not be able to fail on its own
// artefact: 7-Zip's NSIS handler is unreliable enough that its output has to
// be corroborated before a signature verdict is drawn from it.
it('never lets an unreliable extract fail the rehearsal', () => {
const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse
.steps
const verify = stepNamed(steps, 'Verify signatures end to end')
// The 7-Zip route is only trusted when it reproduces the relayed bytes;
// otherwise it falls through to the install route rather than failing.
expect(verify.run).toContain(
'Write-Host "7-Zip\'s NSIS output did not match the relayed digest; falling back to a silent install."'
)
expect(verify.run).toMatch(/\$installedUninstaller = \$null\r?\n\s*\}/)
// The comparison that is not tautological: a file NSIS wrote out, against
// the digest the sign hook recorded.
expect(verify.run).toContain('$shippedDigest -ne $expectedDigest')
expect(verify.run).toContain('the uninstaller the installer ships is not the relayed one')
// An installer that prompts must not hang to the 360-minute job cap, and
// the app it launches must not outlive the step holding install-dir handles.
expect(verify.run).toContain('-PassThru')
expect(verify.run).toContain('$installerProcess.WaitForExit(300000)')
expect(verify.run).toContain('the silent install did not exit within 5 minutes')
expect(verify.run).toMatch(/for \(\$attempt = 0; \$attempt -lt 20; \$attempt\+\+\)/)
expect(verify.run).toContain("Get-Process -Name 'orca-terminal-daemon'")
})
// resources\elevate.exe is downgraded to advisory because app-builder-lib's
// CopyElevateHelper clobbers it on every nsis pack — a pre-existing defect
// that predates the uninstaller relay and is being tracked separately. The
// escape hatch it needed is the kind that quietly grows until the gate
// asserts nothing, so pin it to exactly that one file.
it('confines the advisory escape hatch to elevate.exe', () => {
const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse
.steps
const verify = stepNamed(steps, 'Verify signatures end to end')
const advisoryCalls = verify.run
.split('\n')
.filter((line) => line.includes('-Advisory') && line.includes('Test-Signature'))
expect(advisoryCalls).toHaveLength(1)
expect(advisoryCalls[0]).toContain('installed: $relative')
expect(verify.run).toContain("if ($relative -eq 'resources\\elevate.exe')")
// Both uninstaller verdicts stay fatal — the whole point of the gate.
for (const call of ['relayed: orca-uninstaller.exe', 'shipped: Uninstall Orca.exe']) {
const line = verify.run
.split('\n')
.find((it) => it.includes(`Test-Signature`) && it.includes(call))
expect(line, call).toBeDefined()
expect(line, call).not.toContain('-Advisory')
it('keeps certificate policies and versioned tool caches isolated', () => {
for (const job of Object.values(workflow.jobs)) {
expect(job.steps[0].run).toContain('ELECTRON_BUILDER_CACHE=$(Join-Path $env:RUNNER_TEMP')
const cache = job.steps.find((s) => s.name === 'Cache electron-builder downloads')
expect(cache.with.key).toContain('${{ inputs.signing_policy }}')
expect(cache.with['restore-keys']).toContain('${{ inputs.signing_policy }}')
}
// An advisory must still reach the evidence artifact, or downgrading it
// becomes indistinguishable from deleting the check.
expect(verify.run).toContain('ADVISORY (known pre-existing')
expect(verify.run).toContain('$script:advisories.Add($problem)')
expect(script('replace-elevate.ps1')).toContain(
'node config/scripts/replace-cached-nsis-elevate.mjs'
)
expect(script('replace-elevate.ps1')).toContain('Assert-SigningCertificate')
expect(script('checkpoint.ps1')).toContain('$env:ELECTRON_BUILDER_CACHE')
expect(script('checkpoint.ps1')).not.toContain("@('nsis', 'nsis-resources')")
})
it('wires the electron-builder sign hook that the relay depends on', () => {
const require = createRequire(import.meta.url)
const configPath = resolve(projectDir, 'config/electron-builder.config.cjs')
delete require.cache[require.resolve(configPath)]
const config = require(configPath)
it('checkpoints and verifies the signed bytes and fresh embedding receipt before publishing', () => {
expect(script('checkpoint.ps1')).toContain('orca-uninstaller.exe.embedded-sha256')
expect(script('verify-uninstaller.ps1')).toContain('Get-FileHash')
expect(script('verify-uninstaller.ps1')).toContain('Assert-SigningCertificate')
const names = workflow.jobs.finalize.steps.map((s) => s.name)
expect(names.indexOf('Verify embedded uninstaller signature and receipt')).toBeLessThan(
names.indexOf('Publish signed Windows release artifacts')
)
expect(step('Verify embedded uninstaller signature and receipt').if).toBeUndefined()
expect(step('Upload Windows inner signing evidence').with.path).toContain(
'uninstaller-signing-evidence.txt'
)
})
expect(typeof config.win.signtoolOptions.sign).toBe('function')
delete require.cache[require.resolve(configPath)]
it('retains the shipped-uninstaller rehearsal with bounded install fallback', () => {
expect(step('Verify shipped uninstaller during rehearsal').if).toBe('!inputs.publish')
const verify = script('verify-shipped-uninstaller.ps1')
expect(verify).toContain('-tnsis')
expect(verify).toContain('WaitForExit(300000)')
expect(verify).toContain('-ArgumentList "/S /D=$installRoot"')
expect(verify).toContain('$actual -cne $expectedDigest')
expect(verify).toContain("'shipped: Uninstall Orca.exe'")
expect(verify).not.toContain('-Advisory')
expect(verify).toContain("Get-Process -Name 'orca-terminal-daemon'")
})
})
+22 -18
View File
@@ -10,16 +10,21 @@ if ($env:MODE -eq 'save') {
if ($env:GITHUB_RUN_ATTEMPT -ne '1') { throw 'Never create a signing checkpoint on a rerun.' }
if ($env:REQUEST_ID -notmatch '^[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$') { throw 'Invalid signing request ID.' }
New-Item -ItemType Directory -Force $directory | Out-Null
$cache = Join-Path $env:GITHUB_WORKSPACE 'signing-nsis-cache'
New-Item -ItemType Directory -Force $cache | Out-Null
foreach ($name in @('nsis', 'nsis-resources')) {
$from = Join-Path "$env:LOCALAPPDATA/electron-builder/Cache" $name
if (Test-Path -LiteralPath $from) {
Copy-Item -LiteralPath $from -Destination $cache -Recurse -Force
$cache = Join-Path $env:GITHUB_WORKSPACE 'signing-tool-cache'
if (-not $env:ELECTRON_BUILDER_CACHE -or -not (Test-Path -LiteralPath $env:ELECTRON_BUILDER_CACHE -PathType Container)) { throw 'The isolated electron-builder cache is required.' }
if (Test-Path -LiteralPath $cache) { Remove-Item -LiteralPath $cache -Recurse -Force }
Copy-Item -LiteralPath $env:ELECTRON_BUILDER_CACHE -Destination $cache -Recurse -Force
if (@(Get-ChildItem $cache -Recurse -File -Filter elevate.exe).Count -eq 0) { throw 'The NSIS tool cache must be checkpointed with the build.' }
$uninstaller = Join-Path $env:GITHUB_WORKSPACE 'signing-uninstaller'
if (Test-Path -LiteralPath $uninstaller) { Remove-Item -LiteralPath $uninstaller -Recurse -Force }
New-Item -ItemType Directory -Force $uninstaller | Out-Null
if ($stage -eq 'installer') {
& "$PSScriptRoot/verify-uninstaller.ps1"
foreach ($name in @('orca-uninstaller.exe', 'orca-uninstaller.exe.embedded-sha256')) {
Copy-Item -LiteralPath "$env:RUNNER_TEMP/uninstaller-signing/signed/$name" -Destination $uninstaller -Force
}
}
if (-not (Test-Path -LiteralPath "$cache/nsis")) { throw 'The NSIS tool cache must be checkpointed with the build.' }
tar -czf "$directory/checkpoint.tar.gz" dist/win-unpacked dist/orca-windows-setup.exe dist/latest.yml inner-signing-list.txt signing-nsis-cache
tar -czf "$directory/checkpoint.tar.gz" dist/win-unpacked dist/orca-windows-setup.exe dist/latest.yml inner-signing-list.txt signing-tool-cache signing-uninstaller
if ($LASTEXITCODE -ne 0) { throw 'Could not archive the exact Windows build.' }
$manifest = @{
version = 1; repository = $env:GITHUB_REPOSITORY; runId = $env:GITHUB_RUN_ID
@@ -48,21 +53,20 @@ if ($env:MODE -eq 'save') {
$entries = @(tar -tzf "$directory/checkpoint.tar.gz")
if ($LASTEXITCODE -ne 0) { throw 'Cannot inspect checkpoint archive.' }
foreach ($entry in $entries) {
if ($entry -match '(^[/\\]|^[A-Za-z]:|(^|[/\\])\.\.([/\\]|$))' -or $entry -notmatch '^(dist/(win-unpacked(/|$)|orca-windows-setup\.exe$|latest\.yml$)|inner-signing-list\.txt$|signing-nsis-cache(/|$))') { throw "Unexpected checkpoint entry: $entry" }
if ($entry -match '(^[/\\]|^[A-Za-z]:|(^|[/\\])\.\.([/\\]|$))' -or $entry -notmatch '^(dist/(win-unpacked(/|$)|orca-windows-setup\.exe$|latest\.yml$)|inner-signing-list\.txt$|signing-tool-cache(/|$)|signing-uninstaller(/|$))') { throw "Unexpected checkpoint entry: $entry" }
}
$types = @(tar -tvzf "$directory/checkpoint.tar.gz")
if ($LASTEXITCODE -ne 0 -or @($types | Where-Object { $_ -notmatch '^[d-]' }).Count -gt 0) { throw 'Checkpoint links and special files are not supported.' }
tar -xzf "$directory/checkpoint.tar.gz" -C $env:GITHUB_WORKSPACE
if ($LASTEXITCODE -ne 0) { throw 'Could not restore Windows build checkpoint.' }
$cacheRoot = "$env:LOCALAPPDATA/electron-builder/Cache"
New-Item -ItemType Directory -Force $cacheRoot | Out-Null
foreach ($name in @('nsis', 'nsis-resources')) {
$from = Join-Path "$env:GITHUB_WORKSPACE/signing-nsis-cache" $name
$to = Join-Path $cacheRoot $name
if (Test-Path -LiteralPath $from) {
if (Test-Path -LiteralPath $to) { Remove-Item -LiteralPath $to -Recurse -Force }
Copy-Item -LiteralPath $from -Destination $to -Recurse -Force
}
if (-not $env:ELECTRON_BUILDER_CACHE) { throw 'Missing isolated tool cache destination.' }
if (Test-Path -LiteralPath $env:ELECTRON_BUILDER_CACHE) { Remove-Item -LiteralPath $env:ELECTRON_BUILDER_CACHE -Recurse -Force }
Copy-Item -LiteralPath "$env:GITHUB_WORKSPACE/signing-tool-cache" -Destination $env:ELECTRON_BUILDER_CACHE -Recurse -Force
if ($stage -eq 'installer') {
$signedDirectory = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed'
if (Test-Path -LiteralPath $signedDirectory) { Remove-Item -LiteralPath $signedDirectory -Recurse -Force }
New-Item -ItemType Directory -Force (Split-Path $signedDirectory) | Out-Null
Copy-Item -LiteralPath "$env:GITHUB_WORKSPACE/signing-uninstaller" -Destination $signedDirectory -Recurse -Force
}
"SIGNPATH_REQUEST_ID=$($manifest.requestId)" >> $env:GITHUB_ENV
"SIGNING_CHECKPOINT_SOURCE_SHA=$sourceSha" >> $env:GITHUB_ENV
@@ -0,0 +1,14 @@
const { join } = require('node:path')
const base = require(join(process.cwd(), 'config/electron-builder.config.cjs'))
const { signWindowsUninstallerViaSignPath } = require(
join(process.cwd(), 'config/scripts/windows-uninstaller-signing.cjs')
)
// Load the release tag's packaging settings, including tags predating the relay hook.
module.exports = {
...base,
win: {
...base.win,
signtoolOptions: { ...base.win?.signtoolOptions, sign: signWindowsUninstallerViaSignPath }
}
}
+10 -5
View File
@@ -1,6 +1,11 @@
$ErrorActionPreference = 'Stop'
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
. "$PSScriptRoot/signature-policy.ps1"
$env:ORCA_WIN_UNINSTALLER_SIGNED_PATH = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe'
if (-not (Test-Path -LiteralPath $env:ORCA_WIN_UNINSTALLER_SIGNED_PATH)) { throw 'Missing signed NSIS uninstaller.' }
Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $env:ORCA_WIN_UNINSTALLER_SIGNED_PATH) 'Uninstall Orca.exe'
$receipt = "$env:ORCA_WIN_UNINSTALLER_SIGNED_PATH.embedded-sha256"
if (Test-Path -LiteralPath $receipt) { Remove-Item -LiteralPath $receipt -Force }
pnpm exec electron-builder --config "$PSScriptRoot/electron-builder-signing.config.cjs" --win --publish never --prepackaged "$env:GITHUB_WORKSPACE/dist/win-unpacked"
if ($LASTEXITCODE -ne 0) { throw 'The signed NSIS rebuild failed.' }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) { throw 'The NSIS rebuild did not produce an installer.' }
& "$PSScriptRoot/verify-uninstaller.ps1"
+2 -3
View File
@@ -3,6 +3,5 @@ $ErrorActionPreference = 'Stop'
$signed = 'dist/win-unpacked/resources/elevate.exe'
if (-not (Test-Path -LiteralPath $signed)) { throw 'Missing elevate.exe in the checkpoint.' }
Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $signed) $signed
$cached = @(Get-ChildItem "$env:LOCALAPPDATA/electron-builder/Cache/nsis" -Recurse -Filter elevate.exe)
if ($cached.Count -eq 0) { throw 'The restored NSIS cache has no elevate.exe to replace.' }
foreach ($file in $cached) { Copy-Item -LiteralPath $signed -Destination $file.FullName -Force }
node config/scripts/replace-cached-nsis-elevate.mjs $signed
if ($LASTEXITCODE -ne 0) { throw 'Could not replace the NSIS toolset elevate.exe used by the rebuild.' }
@@ -0,0 +1,10 @@
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot/signature-policy.ps1"
$signed = 'signed-inner/uninstaller/orca-uninstaller.exe'
if (-not (Test-Path -LiteralPath $signed -PathType Leaf)) {
throw 'SignPath must return uninstaller/orca-uninstaller.exe; include it in windows-inner-binaries-zip.'
}
Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $signed) $signed
$directory = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed'
New-Item -ItemType Directory -Force $directory | Out-Null
Copy-Item -LiteralPath $signed -Destination "$directory/orca-uninstaller.exe" -Force
@@ -7,7 +7,7 @@ function Test-Case([string]$name, [scriptblock]$body) {
Write-Host "PASS $name"
}
function Assert-Throws([scriptblock]$body, [string]$message) {
try { & $body } catch {
try { & $body 6> $null } catch {
if ("$_" -notlike "*$message*") { throw "Expected '$message', received '$_'" }
return
}
@@ -96,6 +96,28 @@ try {
& "$control/restore-inner.ps1"
if ((Get-Content 'dist/win-unpacked/Orca.exe') -ne 'signed-fixture') { throw 'Restore failed' }
}
$env:RUNNER_TEMP = Join-Path $temporary 'runner'
New-Item -ItemType Directory -Force 'signed-inner/uninstaller' | Out-Null
Test-Case 'missing signed uninstaller blocks rebuild' {
Assert-Throws { & "$control/restore-uninstaller.ps1" } 'SignPath must return'
}
Set-Content 'signed-inner/uninstaller/orca-uninstaller.exe' 'signed-uninstaller'
Test-Case 'signed uninstaller restored outside checkout' { & "$control/restore-uninstaller.ps1" }
$signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe'
Test-Case 'missing embedding receipt blocks publication' {
Assert-Throws { & "$control/verify-uninstaller.ps1" } 'must embed'
}
Test-Case 'wrong embedding receipt blocks publication' {
Set-Content "$signedUninstaller.embedded-sha256" ('a' * 64)
Assert-Throws { & "$control/verify-uninstaller.ps1" } 'does not match'
}
(Get-FileHash -LiteralPath $signedUninstaller -Algorithm SHA256).Hash.ToLowerInvariant() | Set-Content "$signedUninstaller.embedded-sha256"
Test-Case 'signed uninstaller and embedding receipt verified' { & "$control/verify-uninstaller.ps1" }
Test-Case 'bad uninstaller signature blocks publication' {
$global:OrcaSigningTestBadSignature = $true
Assert-Throws { & "$control/verify-uninstaller.ps1" } 'Unexpected rehearsal'
$global:OrcaSigningTestBadSignature = $false
}
$env:GITHUB_WORKSPACE = $temporary
$env:GITHUB_REPOSITORY = 'stablyai/orca'
$env:GITHUB_RUN_ID = '123'
@@ -107,9 +129,10 @@ try {
$env:MODE = 'save'
$env:REQUEST_ID = '11111111-1111-4111-8111-111111111111'
$env:LOCALAPPDATA = Join-Path $temporary 'local'
$env:ELECTRON_BUILDER_CACHE = "$env:LOCALAPPDATA/electron-builder/Cache"
$env:GITHUB_ENV = Join-Path $temporary 'github-env'
New-Item -ItemType Directory -Force "$env:LOCALAPPDATA/electron-builder/Cache/nsis" | Out-Null
Set-Content "$env:LOCALAPPDATA/electron-builder/Cache/nsis/elevate.exe" 'cache'
New-Item -ItemType Directory -Force "$env:LOCALAPPDATA/electron-builder/Cache/nsis@1.2.1/nsis-bundle" | Out-Null
Set-Content "$env:LOCALAPPDATA/electron-builder/Cache/nsis@1.2.1/nsis-bundle/elevate.exe" 'cache'
Set-Content 'dist/orca-windows-setup.exe' 'installer'
Set-Content 'dist/latest.yml' 'metadata'
function git { $global:LASTEXITCODE = 0; return ('b' * 40) }
@@ -123,7 +146,7 @@ try {
function gh {
$global:LASTEXITCODE = 0
if ($args[0] -eq 'api') {
return (@{total_count = 1; artifacts = @(@{name = "orca-signing-inner-123-1-$env:REQUEST_ID"; expired = $false; workflow_run = @{id = 123; head_sha = $env:GITHUB_SHA}})} | ConvertTo-Json -Depth 5)
return (@{total_count = 1; artifacts = @(@{name = "orca-signing-$env:STAGE-123-1-$env:REQUEST_ID"; expired = $false; workflow_run = @{id = 123; head_sha = $env:GITHUB_SHA}})} | ConvertTo-Json -Depth 5)
}
Copy-Item 'original-checkpoint' 'signing-checkpoint' -Recurse
}
@@ -131,6 +154,21 @@ try {
& "$control/checkpoint.ps1"
if ((Get-Content $env:GITHUB_ENV -Raw) -notlike "*SIGNPATH_REQUEST_ID=$env:REQUEST_ID*") { throw 'Request identity missing' }
}
Test-Case 'installer checkpoint restores receipt and versioned tool cache on a fresh runner' {
$env:MODE = 'save'
$env:STAGE = 'installer'
$env:GITHUB_RUN_ATTEMPT = '1'
& "$control/checkpoint.ps1"
Remove-Item 'original-checkpoint' -Recurse -Force
Copy-Item 'signing-checkpoint' 'original-checkpoint' -Recurse
Remove-Item "$env:RUNNER_TEMP/uninstaller-signing" -Recurse -Force
Remove-Item $env:ELECTRON_BUILDER_CACHE -Recurse -Force
$env:MODE = 'restore'
$env:GITHUB_RUN_ATTEMPT = '2'
& "$control/checkpoint.ps1"
& "$control/verify-uninstaller.ps1"
if (-not (Test-Path "$env:ELECTRON_BUILDER_CACHE/nsis@1.2.1/nsis-bundle/elevate.exe")) { throw 'Versioned tool cache lost on resume' }
}
Test-Case 'corrupt checkpoint rejected' {
Add-Content 'original-checkpoint/checkpoint.tar.gz' 'corrupt'
Assert-Throws { & "$control/checkpoint.ps1" } 'SHA-256 mismatch'
+5
View File
@@ -31,3 +31,8 @@ Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
$exported = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/unsigned/orca-uninstaller.exe'
if (-not (Test-Path -LiteralPath $exported -PathType Leaf)) { throw 'The NSIS build did not export an uninstaller for signing.' }
New-Item -ItemType Directory -Force (Join-Path $stage.FullName 'uninstaller') | Out-Null
Copy-Item -LiteralPath $exported -Destination (Join-Path $stage.FullName 'uninstaller/orca-uninstaller.exe') -Force
@@ -0,0 +1,48 @@
$ErrorActionPreference = 'Stop'
& "$PSScriptRoot/verify-uninstaller.ps1"
. "$PSScriptRoot/signature-policy.ps1"
$signed = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe'
$expectedDigest = (Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash.ToLowerInvariant()
try {
$installedUninstaller = $null
$full7z = 'C:/Program Files/7-Zip/7z.exe'
$extract = Join-Path $env:RUNNER_TEMP 'uninstaller-nsis-extract'
if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force }
if (Test-Path -LiteralPath $full7z) {
& $full7z x -tnsis 'dist/orca-windows-setup.exe' "-o$extract" -y 2>&1 | Out-Null
# NSIS extraction is trustworthy only when it reproduces the relayed bytes.
$matches = @(Get-ChildItem $extract -Recurse -File -Filter 'Uninstall*.exe' -ErrorAction SilentlyContinue |
Where-Object { (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -eq $expectedDigest })
if ($matches.Count -eq 1) { $installedUninstaller = $matches[0] }
}
if ($null -eq $installedUninstaller) {
$installRoot = Join-Path $env:RUNNER_TEMP 'uninstaller-rehearsal-install'
if (Test-Path -LiteralPath $installRoot) { Remove-Item -LiteralPath $installRoot -Recurse -Force }
$installerProcess = Start-Process -FilePath (Resolve-Path 'dist/orca-windows-setup.exe') -ArgumentList "/S /D=$installRoot" -PassThru
try {
if (-not $installerProcess.WaitForExit(300000)) { throw 'The silent install did not exit within 5 minutes.' }
if ($installerProcess.ExitCode -ne 0) { throw "The silent install failed: $($installerProcess.ExitCode)" }
} finally {
if (-not $installerProcess.HasExited) { $installerProcess | Stop-Process -Force -ErrorAction SilentlyContinue }
for ($attempt = 0; $attempt -lt 20; $attempt++) {
$running = @(Get-Process -Name 'Orca' -ErrorAction SilentlyContinue)
if ($running.Count -gt 0) {
$running | Stop-Process -Force -ErrorAction SilentlyContinue
break
}
Start-Sleep -Milliseconds 500
}
Get-Process -Name 'orca-terminal-daemon' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
$matches = @(Get-ChildItem $installRoot -Recurse -File -Filter 'Uninstall*.exe' -ErrorAction SilentlyContinue)
if ($matches.Count -ne 1) { throw 'The silent install must produce exactly one uninstaller.' }
$installedUninstaller = $matches[0]
}
$actual = (Get-FileHash -LiteralPath $installedUninstaller.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -cne $expectedDigest) { throw 'The shipped uninstaller does not match the signed bytes.' }
Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $installedUninstaller.FullName) 'shipped: Uninstall Orca.exe'
'VERIFIED shipped Uninstall Orca.exe: signature and digest match' | Add-Content 'uninstaller-signing-evidence.txt'
} catch {
"VERDICT: FAILED — $_" | Add-Content 'uninstaller-signing-evidence.txt'
throw
}
@@ -0,0 +1,16 @@
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot/signature-policy.ps1"
$signed = Join-Path $env:RUNNER_TEMP 'uninstaller-signing/signed/orca-uninstaller.exe'
$receipt = "$signed.embedded-sha256"
try {
if (-not (Test-Path -LiteralPath $signed -PathType Leaf) -or -not (Test-Path -LiteralPath $receipt -PathType Leaf)) {
throw 'The installer rebuild must embed the signed uninstaller and produce a receipt.'
}
Assert-SigningCertificate (Get-AuthenticodeSignature -FilePath $signed) $signed
$actual = (Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash.ToLowerInvariant()
if ((Get-Content -LiteralPath $receipt -Raw).Trim() -cne $actual) { throw 'Embedded uninstaller receipt does not match the signed bytes.' }
"VERIFIED Uninstall Orca.exe: signed bytes handed to NSIS ($actual)" | Set-Content 'uninstaller-signing-evidence.txt'
} catch {
"VERDICT: FAILED — $_" | Set-Content 'uninstaller-signing-evidence.txt'
throw
}