This commit is contained in:
Bane Stojkovic
2026-10-09 10:57:18 +02:00
parent 486e83b6b5
commit 84c97d21e3
8 changed files with 304 additions and 2 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Sign Windows Launcher
# The launch4j launcher is a prebuilt binary that changes only when it is regenerated, and
# Inno Setup cannot sign its payload after the fact. It is therefore signed on its own here
# and the signed copy is committed to the repository, which keeps releases down to a single
# signing request. Run this whenever deploy/win/diylc-x64.exe is rebuilt, and once more when
# the production certificate replaces the test certificate.
on:
workflow_dispatch:
inputs:
signing-policy-slug:
description: 'SignPath signing policy'
type: choice
default: test-signing
options:
- test-signing
- release-signing
permissions:
actions: read
contents: read
env:
SIGNPATH_ORGANIZATION_ID: 95470a00-59e6-430c-9c81-7393ad16d4e8
SIGNPATH_PROJECT_SLUG: diy-layout-creator
jobs:
launcher:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Upload unsigned launcher
id: upload-unsigned-launcher
uses: actions/upload-artifact@v7
with:
name: unsigned-launcher
path: diylc/diylc-swing/deploy/win/diylc-x64.exe
if-no-files-found: error
retention-days: 1
- name: Sign launcher
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ inputs.signing-policy-slug }}
artifact-configuration-slug: windows-launcher
github-artifact-id: ${{ steps.upload-unsigned-launcher.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Confirm the signature is present
run: python3 diylc/diylc-swing/deploy/win/signpath/is-signed.py signed/diylc-x64.exe
- name: Upload signed launcher
uses: actions/upload-artifact@v7
with:
name: signed-launcher
path: signed/diylc-x64.exe
if-no-files-found: error
- name: Explain the manual step
env:
POLICY: ${{ inputs.signing-policy-slug }}
run: |
{
echo "## Signed with the \`$POLICY\` policy"
echo
echo "Download the **signed-launcher** artifact and commit it over"
echo "\`diylc/diylc-swing/deploy/win/diylc-x64.exe\`."
if [ "$POLICY" != "release-signing" ]; then
echo
echo "> This is a test signature. Do not commit it — a test-signed binary in a release"
echo "> is worse than an unsigned one, because it names a publisher Windows does not trust."
fi
} >> "$GITHUB_STEP_SUMMARY"
+116
View File
@@ -0,0 +1,116 @@
name: Sign Windows Artifacts
# Signs the Inno Setup installer. The launcher it embeds is signed separately and committed
# by the Sign Windows Launcher workflow, because Inno Setup cannot sign its payload after the
# fact and the launcher changes only when launch4j regenerates it.
on:
workflow_dispatch:
inputs:
signing-policy-slug:
description: 'SignPath signing policy'
type: choice
default: test-signing
options:
- test-signing
- release-signing
permissions:
actions: read
contents: read
env:
SIGNPATH_ORGANIZATION_ID: 95470a00-59e6-430c-9c81-7393ad16d4e8
SIGNPATH_PROJECT_SLUG: diy-layout-creator
jobs:
windows:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
java-version: '21'
distribution: 'temurin'
cache: maven
# Nothing downstream fails when the committed launcher is unsigned: the zip and the
# installed application would just silently ship an unsigned executable.
- name: Check the committed launcher carries a signature
env:
POLICY: ${{ inputs.signing-policy-slug }}
run: |
if python3 diylc/diylc-swing/deploy/win/signpath/is-signed.py \
diylc/diylc-swing/deploy/win/diylc-x64.exe; then
exit 0
fi
if [ "$POLICY" = "release-signing" ]; then
echo "::error::deploy/win/diylc-x64.exe is unsigned. Run the Sign Windows Launcher workflow with the release policy, commit the signed launcher, then re-run this workflow."
exit 1
fi
echo "::warning::deploy/win/diylc-x64.exe is unsigned; the zip and the installed application will ship an unsigned launcher."
- name: Resolve project version
working-directory: diylc
run: |
echo "DIYLC_VERSION=$(mvn -q -N help:evaluate -Dexpression=project.version -DforceStdout)" >> "$GITHUB_ENV"
- name: Build Windows zip and installer
working-directory: diylc
run: |
mvn -B -DskipTests -P win64-zip package
mvn -B -DskipTests -P win64-installer package
# The artifact configuration matches a fixed path, so the versioned installer is staged
# under a stable name for the round trip and renamed back afterwards.
- name: Stage installer for signing
run: |
mkdir -p staging
cp "diylc/diylc-swing/target/diylc-${DIYLC_VERSION}-win64.exe" staging/diylc-setup.exe
- name: Upload unsigned installer
id: upload-unsigned-installer
uses: actions/upload-artifact@v7
with:
name: unsigned-installer
path: staging/diylc-setup.exe
if-no-files-found: error
retention-days: 1
- name: Sign installer
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ inputs.signing-policy-slug }}
artifact-configuration-slug: windows-installer
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Restore signed installer under its release name
run: |
python3 diylc/diylc-swing/deploy/win/signpath/is-signed.py signed/diylc-setup.exe
cp signed/diylc-setup.exe "diylc/diylc-swing/target/diylc-${DIYLC_VERSION}-win64.exe"
- name: Report signatures
run: |
sudo apt-get update
sudo apt-get install -y osslsigncode
# chain validation fails against the self-signed test certificate by design; what
# matters is that a signature is present and names the expected subject
osslsigncode verify -in "diylc/diylc-swing/target/diylc-${DIYLC_VERSION}-win64.exe" || true
- name: Upload signed Windows artifacts
uses: actions/upload-artifact@v7
with:
name: diylc-windows-signed
path: |
diylc/diylc-swing/target/diylc-${{ env.DIYLC_VERSION }}-win64.exe
diylc/diylc-swing/target/diylc-${{ env.DIYLC_VERSION }}-win64.zip
if-no-files-found: error
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -152,5 +152,5 @@ buildNumber.properties
###### Custom ######
######################
# Deployment folder
deploy/
# Deployment folder (build output; the packaging sources under diylc-swing/deploy are tracked)
/deploy/
@@ -0,0 +1,44 @@
# SignPath code signing (Windows)
DIYLC signs its Windows artifacts through the [SignPath Foundation](https://signpath.org/) OSS
program. The private key lives in SignPath's HSM and never reaches this repository or a developer
machine; signing happens inside GitHub Actions, which SignPath has registered as a trusted build
system so that it can verify each artifact came from this repository.
The XML files here are the **artifact configurations**. They are not read by the Maven build — they
are the reviewed source of truth for what has to be pasted into the SignPath web UI. Keep them in
sync with the UI whenever the Windows packaging changes.
| File | Slug in SignPath | Workflow | Signs |
| --- | --- | --- | --- |
| `installer.xml` | `windows-installer` | `sign-windows.yml` | the Inno Setup installer, once per release |
| `launcher.xml` | `windows-launcher` | `sign-launcher.yml` | the launch4j launcher, only when it is rebuilt |
## Why the launcher is signed separately and committed
Inno Setup compresses its payload into a custom container that cannot be signed through, so
`diylc-x64.exe` must already carry its signature when the installer is compiled. That launcher is a
prebuilt binary with six changes in its entire history, so signing it on every release would buy
nothing and cost an extra approval each time. It is signed on its own by `sign-launcher.yml` and the
signed copy is committed, which leaves releases with a single signing request.
Two consequences worth remembering:
- **Re-sign the launcher when the production certificate replaces the test certificate.** A
committed test-signed launcher is worse than an unsigned one, because it names a publisher that
Windows does not trust.
- **Re-sign it whenever launch4j regenerates it.** Nothing downstream fails if you forget, so
`sign-windows.yml` checks the committed launcher with `is-signed.py` and refuses to produce a
release if it is unsigned.
The win64 zip needs no signing request of its own: it packs the launcher that is already signed. The
bundled Temurin JRE is left alone too, since Adoptium ships those binaries Authenticode-signed.
## One-time setup in SignPath
1. The predefined **GitHub.com** trusted build system has to be added to the organization and linked
to the project. This needs organization administrator rights, so in an OSS organization the
SignPath Foundation does it on request.
2. Create the two artifact configurations above with the slugs from the table.
3. Create an API token for the CI user and store it in this repository as the `SIGNPATH_API_TOKEN`
secret. The organization ID and project slug are not secret and sit in the workflow files.
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Artifact configuration slug: windows-installer
Signs the Inno Setup installer. The workflow stages it under a version-independent
name because the path below is fixed while the release file name carries the version. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="diylc-setup.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Exits 0 if the given PE file carries an Authenticode signature, 1 if it does not.
`osslsigncode verify` also fails on an untrusted chain, which is the normal result for the
SignPath test certificate, so it cannot answer this question on its own. This reads the PE
Certificate Table data directory instead, which says whether a signature is present without
making any claim about its validity.
"""
import struct
import sys
def has_signature(path):
with open(path, "rb") as f:
data = f.read()
pe = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe:pe + 4] != b"PE\0\0":
raise ValueError("not a PE file: %s" % path)
optional_header = pe + 24
magic = struct.unpack_from("<H", data, optional_header)[0]
if magic == 0x10B:
directories = optional_header + 96
elif magic == 0x20B:
directories = optional_header + 112
else:
raise ValueError("unknown optional header magic 0x%x in %s" % (magic, path))
# data directory entry 4 is the Certificate Table
_, size = struct.unpack_from("<II", data, directories + 4 * 8)
return size > 0
if __name__ == "__main__":
signed = has_signature(sys.argv[1])
print("signed" if signed else "unsigned")
sys.exit(0 if signed else 1)
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Artifact configuration slug: windows-launcher
Signs the launch4j launcher that both the win64 zip and the installer embed.
GitHub Actions artifacts are always served as zip files, hence the zip-file root. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="diylc-x64.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>