Sign Windows engine releases with Azure Artifact Signing (#742)

* Sign Windows engine releases with Azure Artifact Signing

Isolate OIDC signing behind a maintainer-approved release environment and publish only verified, timestamped company-signed Windows output.

AI assistance: implemented and verified with Codex under maintainer direction.

* Clarify same-run artifact permissions

Keep least-privilege tokens: the pinned artifact actions use ACTIONS_RUNTIME_TOKEN for same-run transfers. Guard against opting into cross-run downloads and use role-based reviewer wording.

AI assistance: prepared and verified with Codex under maintainer direction.
This commit is contained in:
Paul Bakaus
2026-09-05 15:38:55 -07:00
committed by GitHub
parent 707fb6061c
commit bb7663ecfb
4 changed files with 196 additions and 4 deletions
+70 -4
View File
@@ -11,8 +11,10 @@ name: release-engine
on:
push:
tags: ['engine-v*']
# Same-run artifact transfers use ACTIONS_RUNTIME_TOKEN, not GITHUB_TOKEN;
# they do not require actions: read/write. Keep downloads scoped to this run.
permissions:
contents: write
contents: read
jobs:
build:
strategy:
@@ -53,15 +55,79 @@ jobs:
run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: impeccable-${{ matrix.short }}
# Keep unsigned Windows output outside the publish job's pattern.
name: ${{ matrix.short == 'windows-x64' && 'unsigned-windows-x64' || format('impeccable-{0}', matrix.short) }}
path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }}
if-no-files-found: error
publish:
sign-windows:
needs: build
runs-on: windows-latest
timeout-minutes: 15
environment: windows-signing
permissions:
contents: read
id-token: write
steps:
# A fresh runner signs only this run's engine. It does not check out or
# execute repository code with the Azure identity available.
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: unsigned-windows-x64
path: unsigned
- name: Azure login (OIDC)
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Sign Windows engine
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
with:
endpoint: https://eus.codesigning.azure.net/
signing-account-name: impeccable-signing
certificate-profile-name: impeccable-windows
files: ${{ github.workspace }}\unsigned\impeccable.exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
description: Impeccable engine
description-url: https://impeccable.style
exclude-environment-credential: true
# Only the preceding OIDC Azure CLI login is used. Other credential
# types are excluded by this pinned action's defaults.
exclude-azure-cli-credential: false
cache-dependencies: false
- name: Verify signed engine
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$signature = Get-AuthenticodeSignature -LiteralPath 'unsigned/impeccable.exe'
if ($signature.Status -ne 'Valid') {
throw "Invalid Windows signature: $($signature.Status) — $($signature.StatusMessage)"
}
$publisher = $signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)
if ($publisher -cne 'Renaissance Geek, Inc.') {
throw "Unexpected Windows publisher: $publisher"
}
if ($null -eq $signature.TimeStamperCertificate) {
throw 'The Windows signature has no timestamp.'
}
Write-Output "Verified publisher: $publisher; certificate: $($signature.SignerCertificate.Thumbprint)"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: impeccable-windows-x64
path: unsigned/impeccable.exe
if-no-files-found: error
publish:
needs: [build, sign-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: { path: artifacts }
with:
pattern: impeccable-*
path: artifacts
- name: Lay out release assets with checksums
run: |
set -e
+52
View File
@@ -0,0 +1,52 @@
# Windows engine signing
Engine releases sign `impeccable.exe` with Azure Artifact Signing before
computing release checksums. The publisher is **Renaissance Geek, Inc.**
Existing release assets are immutable; never replace a shipped unsigned binary
with signed bytes under the same version.
## Access boundary
- Azure account: `impeccable-signing`, East US (`https://eus.codesigning.azure.net/`).
- Public Trust certificate profile: `impeccable-windows`.
- User-assigned managed identity: `impeccable-release-signing`, in the same resource group.
- Its only Azure role is **Artifact Signing Certificate Profile Signer**, scoped
to `impeccable-signing/certificateProfiles/impeccable-windows`, not the account
or subscription.
- Federated credential: `github-windows-signing`, issuer
`https://token.actions.githubusercontent.com`, audience `api://AzureADTokenExchange`,
subject `repo:pbakaus/impeccable:environment:windows-signing`.
- GitHub environment: `windows-signing`, restricted to **tags** matching
`engine-v*`, with `pbakaus` as required reviewer and administrator bypass off.
Self-review allows the required reviewer to approve releases they trigger.
- Environment variables `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and
`AZURE_SUBSCRIPTION_ID` contain public identifiers, not secrets. No client
secret, private key, or PFX is stored in GitHub.
## Release and verification
Run the normal `bun run release:engine` flow, then review and approve the
`windows-signing` deployment for that tag in GitHub Actions. Check the tag's
commit and workflow before approving: the environment approval grants that job
the ability to sign as the company.
The build job uploads Windows output as `unsigned-windows-x64`. A separate
Windows runner downloads that artifact from the same run, signs exactly
`impeccable.exe`, and requires a valid Authenticode signature, the expected
publisher, and a timestamp before uploading `impeccable-windows-x64`. That
runner does not check out repository code or execute the downloaded engine.
Only its job receives an OIDC token; only the publish job can write releases.
Publication waits for successful signing and downloads only `impeccable-*`
artifacts, so it cannot package the unsigned intermediate.
RFC 3161 timestamping is required because Azure issues short-lived signing
certificates. Don't pin a leaf certificate thumbprint: Azure rotates them.
If signing or verification fails, fix the cause and retry; don't add an
unsigned fallback or weaken the environment gate.
The workflow configuration is regression-tested by
`bun test tests/release-engine-workflow.test.js`. An actual protected engine
release is still needed to verify Azure OIDC and Authenticode end to end.
References: [Azure signing roles](https://learn.microsoft.com/en-us/azure/artifact-signing/tutorial-assign-roles),
[official signing action](https://github.com/Azure/artifact-signing-action).
+2
View File
@@ -33,6 +33,7 @@ export const SUITES = {
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/)/,
/^ENGINE_VERSION$/,
/^README(\.npm)?\.md$/,
/^\.github\/workflows\/release-engine\.yml$/,
/^cli\/bin\//,
],
commands: [
@@ -50,6 +51,7 @@ export const SUITES = {
'tests/validate-plugin-versions.test.js',
'tests/validate-plugin-manifest.test.js',
'tests/plugin-paths.test.js',
'tests/release-engine-workflow.test.js',
],
},
{
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const workflow = Bun.YAML.parse(readFileSync(new URL('../.github/workflows/release-engine.yml', import.meta.url), 'utf8'));
const action = (job, name) => job.steps.find(step => step.uses?.startsWith(`${name}@`));
describe('engine release signing boundary', () => {
test('only the protected signing job receives an OIDC token', () => {
expect(workflow.on).toEqual({ push: { tags: ['engine-v*'] } });
expect(workflow.permissions).toEqual({ contents: 'read' });
const sign = workflow.jobs['sign-windows'];
expect(sign.environment).toBe('windows-signing');
expect(sign.needs).toBe('build');
expect(sign.permissions).toEqual({ contents: 'read', 'id-token': 'write' });
expect(workflow.jobs.build.permissions?.['id-token']).toBeUndefined();
expect(workflow.jobs.publish.permissions).toEqual({ contents: 'write' });
expect(sign.steps.some(step => step.uses?.startsWith('actions/checkout@'))).toBe(false);
});
test('publication waits for signing and cannot collect the unsigned artifact', () => {
const unsignedName = 'unsigned-windows-x64';
expect(action(workflow.jobs.build, 'actions/upload-artifact').with.name).toContain(unsignedName);
const sign = workflow.jobs['sign-windows'];
expect(action(sign, 'actions/download-artifact').with.name).toBe(unsignedName);
expect(action(sign, 'actions/upload-artifact').with.name).toBe('impeccable-windows-x64');
expect(workflow.jobs.publish.needs).toEqual(['build', 'sign-windows']);
expect(action(workflow.jobs.publish, 'actions/download-artifact').with.pattern).toBe('impeccable-*');
expect(unsignedName.startsWith('impeccable-')).toBe(false);
expect(workflow.jobs.publish.steps.find(step => step.name === 'Lay out release assets with checksums').run).toContain('sha256sum');
});
test('artifact downloads stay on the same-run runtime-token path', () => {
for (const name of ['sign-windows', 'publish']) {
const download = action(workflow.jobs[name], 'actions/download-artifact');
// Supplying github-token opts into the public API path, which requires
// separate Actions permissions and can read other workflow runs.
for (const input of ['github-token', 'repository', 'run-id']) {
expect(download.with[input]).toBeUndefined();
}
}
});
test('signs exactly the engine with timestamping, then verifies before upload', () => {
const sign = workflow.jobs['sign-windows'];
const signing = action(sign, 'azure/artifact-signing-action');
expect(signing.with.files).toBe('${{ github.workspace }}\\unsigned\\impeccable.exe');
expect(signing.with['certificate-profile-name']).toBe('impeccable-windows');
expect(signing.with['signing-account-name']).toBe('impeccable-signing');
expect(signing.with['timestamp-rfc3161']).toBe('http://timestamp.acs.microsoft.com');
expect(signing.with['file-digest']).toBe('SHA256');
expect(signing.with['timestamp-digest']).toBe('SHA256');
expect(signing.with['exclude-environment-credential']).toBe(true);
expect(signing.with['cache-dependencies']).toBe(false);
const verify = sign.steps.find(step => step.name === 'Verify signed engine');
expect(sign.steps.indexOf(verify)).toBeGreaterThan(sign.steps.indexOf(signing));
expect(sign.steps.indexOf(verify)).toBeLessThan(sign.steps.indexOf(action(sign, 'actions/upload-artifact')));
expect(verify.run).toContain("$signature.Status -ne 'Valid'");
expect(verify.run).toContain("$publisher -cne 'Renaissance Geek, Inc.'");
expect(verify.run).toContain('$null -eq $signature.TimeStamperCertificate');
expect(verify.run).toContain('throw');
expect(sign.steps.some(step => step['continue-on-error'])).toBe(false);
expect(action(sign, 'actions/upload-artifact').if).toBeUndefined();
});
test('every third-party action is pinned to a commit', () => {
for (const job of Object.values(workflow.jobs)) {
for (const step of job.steps) {
if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/);
}
}
});
});