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: jobs:
build: build:
runs-on: ubuntu-latest 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@ -129,6 +134,7 @@ jobs:
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: release - name: release
id: release
if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
uses: cycjimmy/semantic-release-action@v5 uses: cycjimmy/semantic-release-action@v5
with: with:
@ -140,9 +146,99 @@ jobs:
GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }} GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
GH_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 - name: Archive binaries
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}" name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
path: dist/OliveTin*.* 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 dst: /usr/share/man/man1/OliveTin.1.gz
release: release:
# Stay draft until the sign-windows job replaces unsigned Windows assets and undrafts.
draft: true
extra_files: extra_files:
- glob: ./dist/OliveTin-windows-amd64.msi - glob: ./dist/OliveTin-windows-amd64.msi
footer: | 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. 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). 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. - An active [Apple Developer Program](https://developer.apple.com/programs/) membership.
- A **Developer ID Application** certificate (not "Apple Development" or "Mac App Distribution"). - 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. - 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). 1. Open [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/certificates/list).
2. Create a certificate of type **Developer ID Application**. 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. 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`. 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). 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). 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. 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): 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'`. 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: 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. 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 | | 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. 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: 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`. 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) - GoReleaser: `notarize.macos` in [`.goreleaser.yml`](.goreleaser.yml)
- CI secrets: [`.github/workflows/build-and-release.yml`](.github/workflows/build-and-release.yml) (`release` step) - CI secrets: [`.github/workflows/build-and-release.yml`](.github/workflows/build-and-release.yml) (`release` step)
- [GoReleaser notarization docs](https://goreleaser.com/customization/notarize/) - [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}"