fix: Windows artifact signing

This commit is contained in:
jamesread 2026-07-18 10:36:18 +01:00
parent e2bf82ff9c
commit e322887c4a
7 changed files with 311 additions and 10 deletions

View File

@ -37,6 +37,11 @@ on:
jobs:
build:
runs-on: ubuntu-latest
outputs:
new_release_published: ${{ steps.release.outputs.new_release_published }}
new_release_git_tag: ${{ steps.release.outputs.new_release_git_tag }}
windows_zip_artifact_id: ${{ steps.upload-windows-zip.outputs.artifact-id }}
windows_msi_artifact_id: ${{ steps.upload-windows-msi.outputs.artifact-id }}
steps:
- name: Checkout
uses: actions/checkout@v6
@ -129,6 +134,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: release
id: release
if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
uses: cycjimmy/semantic-release-action@v5
with:
@ -140,9 +146,99 @@ jobs:
GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
- name: Upload unsigned Windows zip for SignPath
id: upload-windows-zip
if: steps.release.outputs.new_release_published == 'true'
uses: actions/upload-artifact@v7
with:
name: unsigned-windows-zip
path: dist/OliveTin-windows-amd64.zip
if-no-files-found: error
- name: Upload unsigned Windows MSI for SignPath
id: upload-windows-msi
if: steps.release.outputs.new_release_published == 'true'
uses: actions/upload-artifact@v7
with:
name: unsigned-windows-msi
path: dist/OliveTin-windows-amd64.msi
if-no-files-found: error
- name: Archive binaries
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v7
with:
name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
path: dist/OliveTin*.*
sign-windows:
name: Sign Windows artifacts (SignPath)
needs: build
if: needs.build.outputs.new_release_published == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Require SignPath configuration
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
SIGNPATH_SIGNING_POLICY_SLUG: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
run: |
missing=0
for name in SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG; do
if [[ -z "${!name}" ]]; then
echo "Missing required SignPath setting: ${name}" >&2
missing=1
fi
done
if [[ "${missing}" -ne 0 ]]; then
echo "Windows signing is required before a draft release can be published. Configure SignPath secrets/vars (see docs/modules/dev/pages/signing.adoc)." >&2
exit 1
fi
- name: Sign Windows zip
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: windows-zip
github-artifact-id: ${{ needs.build.outputs.windows_zip_artifact_id }}
wait-for-completion: true
output-artifact-directory: signed-windows-zip
- name: Sign Windows MSI
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: windows-msi
github-artifact-id: ${{ needs.build.outputs.windows_msi_artifact_id }}
wait-for-completion: true
output-artifact-directory: signed-windows-msi
- name: Publish signed Windows assets and undraft release
env:
GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
run: |
zip_path="$(find signed-windows-zip -type f -name 'OliveTin-windows-amd64.zip' | head -n 1)"
msi_path="$(find signed-windows-msi -type f -name 'OliveTin-windows-amd64.msi' | head -n 1)"
if [[ -z "${zip_path}" || -z "${msi_path}" ]]; then
echo "Signed Windows artifacts not found after SignPath:" >&2
find signed-windows-zip signed-windows-msi -type f >&2 || true
exit 1
fi
./var/windows/signpath-publish-signed.sh \
"${{ needs.build.outputs.new_release_git_tag }}" \
"${zip_path}" \
"${msi_path}"

View File

@ -183,6 +183,8 @@ nfpms:
dst: /usr/share/man/man1/OliveTin.1.gz
release:
# Stay draft until the sign-windows job replaces unsigned Windows assets and undrafts.
draft: true
extra_files:
- glob: ./dist/OliveTin-windows-amd64.msi
footer: |

View File

@ -1,31 +1,38 @@
# macOS release signing
# Release signing
OliveTin signs release binaries on two platforms:
* **macOS** — Developer ID + notarization via [quill](https://github.com/anchore/quill) inside GoReleaser (optional if secrets are missing).
* **Windows** — Authenticode via [SignPath Foundation](https://signpath.org/) in a separate GitHub Actions job (required before a draft release is published).
## macOS release signing
Release builds can sign and notarize the `darwin` binaries using [quill](https://github.com/anchore/quill) via GoReleaser. This runs on the existing Linux CI runner; no macOS runner is required.
Signing is **optional**. If the GitHub secrets below are not all set, GoReleaser skips macOS signing and publishes unsigned binaries (the previous behaviour).
## Prerequisites
### Prerequisites
- An active [Apple Developer Program](https://developer.apple.com/programs/) membership.
- A **Developer ID Application** certificate (not "Apple Development" or "Mac App Distribution").
- An [App Store Connect API key](https://appstoreconnect.apple.com/access/integrations/api) with at least **Developer** access.
## One-time setup
### One-time setup
### 1. Create the signing certificate
#### 1. Create the signing certificate
1. Open [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/certificates/list).
2. Create a certificate of type **Developer ID Application**.
3. Download the `.cer` file and double-click it to add it to **Keychain Access** on a Mac.
4. In Keychain Access, export the certificate as a **Personal Information Exchange (`.p12`)** file. You will set an export password — remember it; this becomes `MACOS_SIGN_PASSWORD`.
### 2. Create the notarization API key
#### 2. Create the notarization API key
1. Open [App Store Connect → Users and Access → Integrations → App Store Connect API](https://appstoreconnect.apple.com/access/integrations/api).
2. Create a key with **Developer** role (or Admin).
3. Download the `.p8` file once (it cannot be downloaded again). Note the **Key ID** shown in the portal and the **Issuer ID** at the top of the API keys page.
### 3. Base64-encode the key files
#### 3. Base64-encode the key files
Run on a machine that has the files (Linux or macOS):
@ -36,7 +43,7 @@ base64 -w0 < ./AuthKey_XXXXXX.p8 # MACOS_NOTARY_KEY
On macOS without GNU coreutils, use `base64 -i file | tr -d '\n'`.
### 4. Add GitHub repository secrets
#### 4. Add GitHub repository secrets
In **Settings → Secrets and variables → Actions**, create:
@ -50,7 +57,7 @@ In **Settings → Secrets and variables → Actions**, create:
All five must be present for signing to run. Any missing secret disables signing for that release.
## Renewal
### Renewal
| Item | Typical lifetime | What to do |
|------|------------------|------------|
@ -60,7 +67,7 @@ All five must be present for signing to run. Any missing secret disables signing
After updating secrets, the next release on `main` (via semantic-release) will use the new credentials automatically.
## Verifying a signed release
### Verifying a signed release
On a Mac, download a `OliveTin-darwin-*.tar.gz` release artifact and run:
@ -71,8 +78,90 @@ spctl -a -vv -t execute OliveTin-darwin-arm64/OliveTin
A signed and notarized binary should report `accepted` with `source=Notarized Developer ID`.
## Configuration reference
### Configuration reference
- GoReleaser: `notarize.macos` in [`.goreleaser.yml`](.goreleaser.yml)
- CI secrets: [`.github/workflows/build-and-release.yml`](.github/workflows/build-and-release.yml) (`release` step)
- [GoReleaser notarization docs](https://goreleaser.com/customization/notarize/)
## Windows release signing (SignPath)
Windows Authenticode signing uses [SignPath Foundation](https://signpath.org/) (free for qualifying open-source projects). It does **not** use GoReleaser Pro.
GoReleaser creates a **draft** GitHub release with unsigned Windows assets. A separate `sign-windows` job submits those assets to SignPath, replaces them on the draft (including updated `checksums.txt`), then publishes the release.
Signed artifacts:
* `OliveTin.exe` inside `OliveTin-windows-amd64.zip`
* nested `OliveTin.exe` and the `OliveTin-windows-amd64.msi` installer (deep signing)
Signing is **required** to publish. If SignPath secrets/vars are missing, `sign-windows` fails and the draft stays unpublished.
### Prerequisites
- Approval for the [SignPath Foundation open-source program](https://signpath.io/product/open-source).
- The SignPath GitHub App installed on the OliveTin organization/repository.
- A SignPath project linked to this repository, with a release signing policy.
### One-time setup
#### 1. Apply for SignPath Foundation
1. Open https://signpath.io/product/open-source and apply with the OliveTin GitHub repository URL.
2. After approval, create (or confirm) the organization and project in the SignPath portal.
#### 2. Install the SignPath GitHub App
1. Install the SignPath GitHub App and grant access to the OliveTin repository.
2. Link the Trusted Build System **GitHub.com** to the SignPath project (required so SignPath can verify the workflow artifact origin).
#### 3. Create artifact configurations
In the SignPath project, create two artifact configurations with these slugs (must match CI). Paste the XML from the reference copies in this repo (SignPath does **not** load them automatically):
* slug `windows-zip` ← [`signpath/windows-zip.xml`](../signpath/windows-zip.xml)
* slug `windows-msi` ← [`signpath/windows-msi.xml`](../signpath/windows-msi.xml)
Use **Custom** XML in the SignPath UI and paste the file contents. Do **not** use **Upload an artifact sample** on these `.xml` files — SignPath will treat them as XML documents to sign (`xml-file`), which is unavailable on the Foundation/Open Source plan. See [`signpath/README.md`](../signpath/README.md).
#### 4. Add GitHub secrets and variables
In **Settings → Secrets and variables → Actions**:
| Kind | Name | Value |
|------|------|-------|
| Secret | `SIGNPATH_API_TOKEN` | CI submitter API token from SignPath |
| Variable | `SIGNPATH_ORGANIZATION_ID` | SignPath organization ID |
| Variable | `SIGNPATH_PROJECT_SLUG` | SignPath project slug (e.g. `olivetin`) |
| Variable | `SIGNPATH_SIGNING_POLICY_SLUG` | Signing policy slug (e.g. `release-signing`) |
#### 5. Pipeline behaviour
On a new semantic-release from `main`:
1. GoReleaser publishes container images and creates a **draft** GitHub release (including unsigned Windows zip/MSI).
2. The build job uploads those Windows files as GitHub Actions artifacts.
3. The `sign-windows` job submits each artifact to SignPath, waits for completion, then runs `var/windows/signpath-publish-signed.sh` to clobber-upload signed files, refresh `checksums.txt`, and undraft the release.
All jobs in this chain use GitHub-hosted runners (required by SignPath for OSS projects).
### Verifying a signed release
On Windows, download `OliveTin-windows-amd64.msi` or extract `OliveTin.exe` from the zip, then either:
* Right-click → **Properties** → **Digital Signatures**, or
* Run:
```bat
signtool verify /pa OliveTin.exe
signtool verify /pa OliveTin-windows-amd64.msi
```
### Configuration reference
- Draft release: `release.draft: true` in [`.goreleaser.yml`](.goreleaser.yml)
- CI job: `sign-windows` in [`.github/workflows/build-and-release.yml`](.github/workflows/build-and-release.yml)
- Publish helper: [`var/windows/signpath-publish-signed.sh`](var/windows/signpath-publish-signed.sh)
- SignPath artifact configs (reference only): [`signpath/windows-zip.xml`](../signpath/windows-zip.xml), [`signpath/windows-msi.xml`](../signpath/windows-msi.xml)
- [SignPath GitHub Actions docs](https://docs.signpath.io/trusted-build-systems/github)
- [SignPath artifact configuration examples](https://docs.signpath.io/artifact-configuration/examples)

View File

@ -0,0 +1,18 @@
# SignPath artifact configurations (reference only)
These XML files are **paste templates** for the SignPath web UI. SignPath does not load them from this repository.
## Do not upload these `.xml` files as artifact samples
If you use **Upload an artifact sample** on `windows-zip.xml` / `windows-msi.xml`, SignPath treats them as XML documents to sign and generates an `<xml-file>` configuration. That feature is not available on SignPath Foundation / Open Source, and you get an error like:
> feature which is not currently available (XML element name: 'xml-file')
## Correct setup
1. In the SignPath project, **Add** an artifact configuration.
2. Choose **Custom** (edit XML), not “upload sample” of these files.
3. Paste the full contents of `windows-zip.xml` or `windows-msi.xml`.
4. Set the slug to `windows-zip` or `windows-msi` (must match CI).
Optional: use **Upload an artifact sample** with a real `OliveTin-windows-amd64.zip` or `.msi` from a build, then trim the generated config to match these references.

View File

@ -0,0 +1,8 @@
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<msi-file path="OliveTin-windows-amd64.msi">
<pe-file path="OliveTin.exe">
<authenticode-sign/>
</pe-file>
<authenticode-sign/>
</msi-file>
</artifact-configuration>

View File

@ -0,0 +1,7 @@
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file path="OliveTin-windows-amd64.zip">
<pe-file path="OliveTin-windows-amd64/OliveTin.exe">
<authenticode-sign/>
</pe-file>
</zip-file>
</artifact-configuration>

View File

@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
ARCH="${ARCH:-amd64}"
ZIP_NAME="OliveTin-windows-${ARCH}.zip"
MSI_NAME="OliveTin-windows-${ARCH}.msi"
CHECKSUMS_NAME="checksums.txt"
usage() {
echo "Usage: $(basename "$0") <release-tag> <signed-zip-path> <signed-msi-path>" >&2
exit 1
}
TAG="${1:-}"
SIGNED_ZIP="${2:-}"
SIGNED_MSI="${3:-}"
if [[ -z "${TAG}" || -z "${SIGNED_ZIP}" || -z "${SIGNED_MSI}" ]]; then
usage
fi
if [[ ! -f "${SIGNED_ZIP}" ]]; then
echo "Signed zip not found: ${SIGNED_ZIP}" >&2
exit 1
fi
if [[ ! -f "${SIGNED_MSI}" ]]; then
echo "Signed MSI not found: ${SIGNED_MSI}" >&2
exit 1
fi
if ! command -v gh >/dev/null; then
echo "gh is required to update the GitHub release" >&2
exit 1
fi
mkdir -p "${DIST_DIR}"
cp -f "${SIGNED_ZIP}" "${DIST_DIR}/${ZIP_NAME}"
cp -f "${SIGNED_MSI}" "${DIST_DIR}/${MSI_NAME}"
checksums_path="${DIST_DIR}/${CHECKSUMS_NAME}"
if ! gh release download "${TAG}" --pattern "${CHECKSUMS_NAME}" --dir "${DIST_DIR}" --clobber; then
echo "Failed to download ${CHECKSUMS_NAME} from release ${TAG}" >&2
exit 1
fi
if [[ ! -f "${checksums_path}" ]]; then
echo "${CHECKSUMS_NAME} not found after download from release ${TAG}" >&2
exit 1
fi
update_checksum() {
local file_name="${1}"
local new_checksum
new_checksum="$(cd "${DIST_DIR}" && sha256sum "${file_name}")"
if [[ -f "${checksums_path}" ]] && grep -qF " ${file_name}" "${checksums_path}"; then
local tmp
tmp="$(mktemp)"
grep -vF " ${file_name}" "${checksums_path}" > "${tmp}" || true
printf '%s\n' "${new_checksum}" >> "${tmp}"
mv "${tmp}" "${checksums_path}"
else
printf '%s\n' "${new_checksum}" >> "${checksums_path}"
fi
}
update_checksum "${ZIP_NAME}"
update_checksum "${MSI_NAME}"
gh release upload "${TAG}" \
"${DIST_DIR}/${ZIP_NAME}" \
"${DIST_DIR}/${MSI_NAME}" \
"${checksums_path}" \
--clobber
gh release edit "${TAG}" --draft=false
echo "Published signed ${ZIP_NAME} and ${MSI_NAME} on release ${TAG}"