mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Preserve uninstaller signing and versioned NSIS caches across approval gates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user