mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
The SignPath Foundation production certificate does not sign on demand the way the self-signed test certificate does. Every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build produced it. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample while the test policy approves in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job, which uploads the unsigned artifact and exposes its id. A second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour and the job timeout sized to sit outside it. Separating them is what buys the recovery: the artifact is uploaded before the wait begins and is addressed by id, so a missed approval window costs a re-run of the second job alone rather than a rebuild. Raising the timeout in place would not have given that. The second job runs for unsigned builds too. Daily prereleases are deliberately left unsigned to preserve the signing quota, and gating the whole job on the signing decision would have meant a second copy of the alias, artifact and release steps for them to run through. The decision itself moves into a named step that echoes it, so a tag that came out unsigned can be explained from the run log rather than by re-reading the expression. It is one source of truth feeding both jobs, which a job-level env could not be. Every step body is otherwise unchanged. The property worth keeping is that none of the alias, upload and release-attach steps carry always(), so GitHub skips all three when signing fails or times out and an unsigned .exe cannot reach a release; that is now written next to them, because it is easy to break by adding a condition without noticing. The policy slug stays at test-signing and the signature check stays lenient -- the test certificate is self-signed and reports UnknownError, so requiring Valid would fail every run until the production certificate is imported. Both are the cutover. The restructure behaves identically under the test policy, the request simply completing at once instead of waiting, so it can be proven green beforehand.
245 lines
11 KiB
YAML
245 lines
11 KiB
YAML
name: Windows Installer
|
|
|
|
# Build the Windows installer .exe.
|
|
#
|
|
# Triggers:
|
|
# - Tag push matching v* (release builds, uploaded as a release asset)
|
|
# - Manual dispatch (for testing the build pipeline)
|
|
#
|
|
# Release tags are Authenticode-signed through the SignPath Foundation OSS
|
|
# program. Daily prereleases are deliberately left unsigned so they don't burn
|
|
# the OSS signing quota; use the `sign` dispatch input to exercise the signing
|
|
# path by hand.
|
|
#
|
|
# The work is split across two jobs on purpose. Signing requests against the
|
|
# Foundation *production* certificate require a human to approve them in the
|
|
# SignPath UI, so the submit step can sit waiting for up to an hour. Keeping
|
|
# the compile in its own job means that wait doesn't hold the build hostage:
|
|
# the unsigned artifact is already uploaded and addressable by id, so a missed
|
|
# approval window is recovered by re-running `publish` alone rather than
|
|
# rebuilding the installer from scratch.
|
|
#
|
|
# Note that `publish` runs for unsigned builds too -- it is the single place
|
|
# that produces the release-facing artifact, so the daily-prerelease path and
|
|
# the signed-tag path share one set of alias/upload/attach steps.
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
workflow_dispatch:
|
|
inputs:
|
|
sign:
|
|
description: 'Submit the installer to SignPath for signing'
|
|
type: boolean
|
|
default: false
|
|
|
|
# Least-privilege per CodeQL actions/missing-workflow-permissions.
|
|
# contents: write is required by softprops/action-gh-release to attach
|
|
# the .exe to a tag release; the manual-dispatch path doesn't trigger
|
|
# that step and could run with read-only, but a single workflow-level
|
|
# block keeps the surface auditable in one place.
|
|
# actions: read lets the SignPath connector download the uploaded artifact
|
|
# through the API (and lets download-artifact do the same in `publish`). Declaring
|
|
# a permissions block at all drops every scope we don't name to `none`, so the
|
|
# signing step fails to fetch the artifact without it.
|
|
permissions:
|
|
contents: write
|
|
actions: read
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: windows-latest
|
|
timeout-minutes: 30
|
|
|
|
outputs:
|
|
# The artifact id is how SignPath addresses the thing to sign; see the
|
|
# upload step below for why that indirection exists.
|
|
artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
|
|
sign: ${{ steps.decide.outputs.sign }}
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
# Sign real release tags but not `-daily.` prereleases, and let a manual
|
|
# run opt in. GitHub's `||` returns the *last* operand when everything is
|
|
# falsy (an empty string here, not `false`), so this normalises the answer
|
|
# to the literal strings 'true'/'false' once, in one place, and every use
|
|
# site compares against 'true' rather than treating it as a boolean.
|
|
# Echoing the decision makes "why wasn't my tag signed?" answerable from
|
|
# the run log alone.
|
|
- name: Decide whether this build gets signed
|
|
id: decide
|
|
shell: bash
|
|
run: |
|
|
if [ "${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}" = "true" ]; then
|
|
echo "sign=true" >> "$GITHUB_OUTPUT"
|
|
echo "signing ENABLED for $GITHUB_REF"
|
|
else
|
|
echo "sign=false" >> "$GITHUB_OUTPUT"
|
|
echo "signing SKIPPED for $GITHUB_REF"
|
|
fi
|
|
|
|
- name: Setup Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.13'
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
|
|
# Inno Setup 6.x is pre-installed on windows-latest runners (under
|
|
# C:\Program Files (x86)\Inno Setup 6\). No install step needed.
|
|
|
|
- name: Stage installer artifacts
|
|
working-directory: installers/windows
|
|
run: python build.py
|
|
shell: pwsh
|
|
|
|
- name: Compile installer (ISCC)
|
|
working-directory: installers/windows
|
|
run: |
|
|
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
|
|
shell: pwsh
|
|
|
|
# SignPath signs a *GitHub artifact*, not a workspace path: the connector
|
|
# pulls the artifact back out through the API, which is why this upload
|
|
# has to happen before signing and why upload-artifact must be v4 or newer
|
|
# (older versions expose no `artifact-id` output). Kept as a separate,
|
|
# clearly-named artifact so an unsigned build is never mistaken for a
|
|
# signed one when downloading from the run page.
|
|
- name: Upload unsigned installer
|
|
id: upload_unsigned
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: bambuddy-windows-installer-unsigned
|
|
path: installers/windows/build/output/*.exe
|
|
if-no-files-found: error
|
|
|
|
publish:
|
|
needs: build
|
|
runs-on: windows-latest
|
|
# Sized to sit outside the signing wait below: an hour for a human to
|
|
# approve the request in the SignPath UI, plus headroom for the download,
|
|
# verification and upload either side of it.
|
|
timeout-minutes: 70
|
|
|
|
env:
|
|
SIGN: ${{ needs.build.outputs.sign }}
|
|
|
|
steps:
|
|
# Rehydrate the compiled installer into the same path the build job used,
|
|
# so every step below is identical whether or not signing ran. No checkout
|
|
# is needed here -- nothing in this job reads the repository.
|
|
- name: Download unsigned installer
|
|
uses: actions/download-artifact@v7
|
|
with:
|
|
name: bambuddy-windows-installer-unsigned
|
|
path: installers/windows/build/output
|
|
|
|
# The artifact arrives at SignPath as a .zip (that is simply what
|
|
# upload-artifact produces), so the artifact configuration on the SignPath
|
|
# side describes a <zip-file> wrapping the <pe-file>. With skip-decompress
|
|
# left at its default the signed archive is extracted again here, so
|
|
# `signed/` ends up holding the bare .exe.
|
|
#
|
|
# The wait timeout is explicit because the action defaults to 600s, which
|
|
# is fine for the auto-approved test policy but far too short once a human
|
|
# has to approve each production request by hand.
|
|
- name: Sign installer (SignPath)
|
|
if: env.SIGN == 'true'
|
|
uses: signpath/github-action-submit-signing-request@v2
|
|
with:
|
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
# Not a credential -- the organization ID appears in ordinary SignPath
|
|
# URLs and is useless without the API token above.
|
|
organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386'
|
|
project-slug: 'bambuddy'
|
|
signing-policy-slug: 'test-signing'
|
|
github-artifact-id: ${{ needs.build.outputs.artifact-id }}
|
|
wait-for-completion: true
|
|
wait-for-completion-timeout-in-seconds: '3600'
|
|
output-artifact-directory: installers/windows/build/signed
|
|
|
|
# Replace the unsigned binary in-place so every downstream step (alias,
|
|
# artifact upload, release attachment) keeps working off one directory and
|
|
# cannot accidentally publish the unsigned copy.
|
|
- name: Promote signed installer
|
|
if: env.SIGN == 'true'
|
|
shell: pwsh
|
|
working-directory: installers/windows/build
|
|
run: |
|
|
$signed = @(Get-ChildItem -Path signed -Filter *.exe)
|
|
if ($signed.Count -ne 1) {
|
|
throw "expected exactly one signed .exe, found $($signed.Count)"
|
|
}
|
|
Move-Item -Force $signed[0].FullName (Join-Path output $signed[0].Name)
|
|
Write-Host "promoted signed installer: $($signed[0].Name)"
|
|
|
|
# Fail loudly rather than shipping an unsigned .exe under a signed
|
|
# release. The test certificate is self-signed, so Windows reports the
|
|
# signature as untrusted (`UnknownError`) -- that is expected and is not
|
|
# what this checks. Only the absence of a signature is treated as a
|
|
# failure; swap in a stricter assertion once the production certificate
|
|
# is imported.
|
|
- name: Verify signature
|
|
if: env.SIGN == 'true'
|
|
shell: pwsh
|
|
working-directory: installers/windows/build/output
|
|
run: |
|
|
Get-ChildItem -Filter *.exe | ForEach-Object {
|
|
$sig = Get-AuthenticodeSignature $_.FullName
|
|
if ($sig.Status -eq 'NotSigned') {
|
|
throw "$($_.Name) carries no Authenticode signature"
|
|
}
|
|
Write-Host "$($_.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)"
|
|
}
|
|
|
|
# Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned
|
|
# copy alongside the versioned filename so external surfaces (website,
|
|
# wiki, newsletters) can link to a stable URL that survives version
|
|
# bumps:
|
|
#
|
|
# https://github.com/maziggy/bambuddy/releases/latest/download/bambuddy-windows-x64-setup.exe
|
|
#
|
|
# GitHub's `latest` redirect excludes prereleases, so this URL always
|
|
# points at whatever was released as a full release. Daily prereleases
|
|
# are excluded from the alias because (a) the unversioned name would be
|
|
# semantically confusing next to the date-stamped versioned name on a
|
|
# daily prerelease page, and (b) there's no stable "latest daily" URL
|
|
# anyway (`latest` skips prereleases), so the alias adds no value there.
|
|
#
|
|
# Runs after signing so the alias is a copy of the *signed* binary.
|
|
#
|
|
# This and the two steps after it carry no `always()`/`failure()`, so
|
|
# GitHub ANDs an implicit `success()` into each. A failed or timed-out
|
|
# signing therefore skips the alias, the artifact upload and the release
|
|
# attachment -- an unsigned .exe cannot reach a release. Preserve that
|
|
# property through any future edit.
|
|
- name: Create unversioned alias (non-daily tags only)
|
|
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')
|
|
shell: pwsh
|
|
working-directory: installers/windows/build/output
|
|
run: |
|
|
$versioned = Get-ChildItem -Filter "bambuddy-*-windows-x64-setup.exe" | Select-Object -First 1
|
|
if (-not $versioned) { throw "no versioned installer .exe found" }
|
|
Copy-Item $versioned.FullName "bambuddy-windows-x64-setup.exe"
|
|
Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)"
|
|
|
|
- name: Upload installer artifact
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: bambuddy-windows-installer
|
|
path: installers/windows/build/output/*.exe
|
|
if-no-files-found: error
|
|
|
|
- name: Attach installer to release
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
uses: softprops/action-gh-release@v2
|
|
with:
|
|
files: installers/windows/build/output/*.exe
|
|
fail_on_unmatched_files: true
|