Browse Source

fix: Windows artifact signing

jamesread 2 months ago
parent
commit
e322887c4a

+ 96 - 0
.github/workflows/build-and-release.yml

@@ -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}"

+ 2 - 0
.goreleaser.yml

@@ -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: |

+ 99 - 10
docs/modules/dev/pages/signing.adoc

@@ -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)

+ 18 - 0
docs/modules/dev/signpath/README.md

@@ -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.

+ 8 - 0
docs/modules/dev/signpath/windows-msi.xml

@@ -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>

+ 7 - 0
docs/modules/dev/signpath/windows-zip.xml

@@ -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>

+ 81 - 0
var/windows/signpath-publish-signed.sh

@@ -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}"