Files
maziggy 0d4275107a ci(windows-installer): keep installer artifacts 1 and 7 days, not 90
The unsigned installer is only needed while SignPath fetches it and the
signed one is attached to the release, but both were kept for the default
90 days on every tag, daily builds included, which filled the account's
Actions storage.
2026-10-16 11:50:46 +02:00

251 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
# Only needed while SignPath fetches it during this run; the default
# 90 days of every daily build filled the account's Actions storage.
retention-days: 1
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
# Release tags attach the installer to the release below; the run
# copy is only for downloading a manual or daily build.
retention-days: 7
- 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