Merge branch 'next' of github.com:OliveTin/OliveTin into next

This commit is contained in:
jamesread 2026-07-18 22:46:14 +01:00
commit 25fd92396c
7 changed files with 383 additions and 19 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:
@ -139,6 +145,29 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
- 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
@ -146,3 +175,77 @@ jobs:
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
with:
persist-credentials: false
- 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

@ -182,7 +182,25 @@ nfpms:
- src: var/manpage/OliveTin.1.gz
dst: /usr/share/man/man1/OliveTin.1.gz
# Sign and notarize darwin binaries via quill (no macOS runner required).
# Skipped when MACOS_SIGN_P12 is unset so local/snapshot builds stay unsigned.
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
# Defaults to project_name when omitted; must match builds[].id (also project_name).
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
notarize:
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
key: "{{.Env.MACOS_NOTARY_KEY}}"
wait: true
timeout: 30m
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,67 @@
# macOS release signing
# 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.
OliveTin signs release binaries on two platforms:
Signing is **optional**. If the GitHub secrets below are not all set, GoReleaser skips macOS signing and publishes unsigned binaries (the previous behaviour).
* **macOS** — Developer ID + notarization via [quill](https://github.com/anchore/quill) inside GoReleaser (optional only when `MACOS_SIGN_P12` is unset).
* **Windows** — Authenticode via [SignPath Foundation](https://signpath.org/) in a separate GitHub Actions job (required before a draft release is published).
## Prerequisites
## 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 or Xcode is required.
Signing is **optional** only when `MACOS_SIGN_P12` is unset — GoReleaser then skips macOS signing and publishes unsigned binaries. When `MACOS_SIGN_P12` is set, the companion macOS secrets below are required or the release fails.
### 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 (OpenSSL, no Mac/Xcode)
Work in a private directory. Keep the private key offline and never commit it.
```sh
mkdir -p ~/apple-signing && cd ~/apple-signing
chmod 700 .
openssl genrsa -out developer_id_app.key 2048
openssl req -new -key developer_id_app.key -out developer_id_app.csr \
-subj "/emailAddress=you@example.com/CN=Your Name/C=GB"
```
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 a certificate of type **Developer ID Application**. Prefer **G2 Sub-CA** if the portal asks.
3. Upload `developer_id_app.csr` and download the resulting `.cer` (often named `developerID_application.cer`).
### 2. Create the notarization API key
Build a `.p12` that includes Apple's Developer ID G2 intermediate:
```sh
curl -fsSLO https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer
openssl x509 -inform DER -in developerID_application.cer -out developerID_application.pem
openssl x509 -inform DER -in DeveloperIDG2CA.cer -out DeveloperIDG2CA.pem
# Export password becomes MACOS_SIGN_PASSWORD.
# On OpenSSL 3 (e.g. Fedora), -legacy improves compatibility with some tooling:
openssl pkcs12 -export -legacy \
-inkey developer_id_app.key \
-in developerID_application.pem \
-certfile DeveloperIDG2CA.pem \
-out Certificates.p12
```
If you already have a Mac with the certificate in Keychain Access, you can export a `.p12` from there instead; the OpenSSL path above is enough when you do not.
#### 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 +72,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:
@ -48,9 +84,9 @@ In **Settings → Secrets and variables → Actions**, create:
| `MACOS_NOTARY_KEY_ID` | Key ID from App Store Connect (e.g. `ABC123DEF4`) |
| `MACOS_NOTARY_ISSUER_ID` | Issuer UUID from App Store Connect |
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. GoReleaser enables the step when `MACOS_SIGN_P12` is set; missing companion secrets will fail that release.
## Renewal
### Renewal
| Item | Typical lifetime | What to do |
|------|------------------|------------|
@ -60,7 +96,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 +107,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/)
- GoReleaser: `notarize.macos` in link:https://github.com/OliveTin/OliveTin/blob/main/.goreleaser.yml[`.goreleaser.yml`]
- CI secrets: link:https://github.com/OliveTin/OliveTin/blob/main/.github/workflows/build-and-release.yml[`.github/workflows/build-and-release.yml`] (`release` step)
- link:https://goreleaser.com/customization/notarize/[GoReleaser notarization docs]
## 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` ← link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-zip.xml[`signpath/windows-zip.xml`]
* slug `windows-msi` ← link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/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 link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/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 link:https://github.com/OliveTin/OliveTin/blob/main/.goreleaser.yml[`.goreleaser.yml`]
- CI job: `sign-windows` in link:https://github.com/OliveTin/OliveTin/blob/main/.github/workflows/build-and-release.yml[`.github/workflows/build-and-release.yml`]
- Publish helper: link:https://github.com/OliveTin/OliveTin/blob/main/var/windows/signpath-publish-signed.sh[`var/windows/signpath-publish-signed.sh`]
- SignPath artifact configs (reference only): link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-zip.xml[`signpath/windows-zip.xml`], link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-msi.xml[`signpath/windows-msi.xml`]
- link:https://docs.signpath.io/trusted-build-systems/github[SignPath GitHub Actions docs]
- link:https://docs.signpath.io/artifact-configuration/examples[SignPath 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,92 @@
#!/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}"
checksums_backup="${DIST_DIR}/${CHECKSUMS_NAME}.orig"
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
cp -f "${checksums_path}" "${checksums_backup}"
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}"
# Replace binaries first so a failed checksums upload leaves the draft recoverable.
gh release upload "${TAG}" \
"${DIST_DIR}/${ZIP_NAME}" \
"${DIST_DIR}/${MSI_NAME}" \
--clobber
if ! gh release upload "${TAG}" "${checksums_path}" --clobber; then
echo "Failed to upload updated ${CHECKSUMS_NAME}; restoring previous asset" >&2
restore_dir="$(mktemp -d)"
cp -f "${checksums_backup}" "${restore_dir}/${CHECKSUMS_NAME}"
gh release upload "${TAG}" "${restore_dir}/${CHECKSUMS_NAME}" --clobber
rm -rf "${restore_dir}"
exit 1
fi
gh release edit "${TAG}" --draft=false
echo "Published signed ${ZIP_NAME} and ${MSI_NAME} on release ${TAG}"