mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user