feat(FN-5605): add linux gpg signing for release artifacts

Implements Linux GPG signing for the release pipeline by adding a `sign-linux.sh` helper, wiring it into the release and test-release workflows, including `.asc` signature files in release collectors, and documenting the full signing workflow in CODE_SIGNING.md with a note in the desktop README.

Fusion-Task-Id: FN-5605

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5605
This commit is contained in:
gsxdsm
2026-05-27 02:17:40 -07:00
parent d767e2ecbd
commit 294efb02fd
6 changed files with 241 additions and 7 deletions

View File

@@ -252,6 +252,22 @@ jobs:
exit 1
fi
- name: Sign Linux desktop artifacts
if: ${{ env.LINUX_GPG_PRIVATE_KEY != '' }}
env:
LINUX_GPG_PRIVATE_KEY: ${{ secrets.LINUX_GPG_PRIVATE_KEY }}
LINUX_GPG_PASSPHRASE: ${{ secrets.LINUX_GPG_PASSPHRASE }}
LINUX_GPG_KEY_ID: ${{ secrets.LINUX_GPG_KEY_ID }}
shell: bash
run: |
shopt -s nullglob
artifacts=(
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage
packages/desktop/dist-electron/Fusion-*-linux-*.deb
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz
)
bash scripts/sign-linux.sh "${artifacts[@]}"
- name: Generate desktop Linux checksums
shell: bash
run: |
@@ -268,10 +284,13 @@ jobs:
path: |
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage.sha256
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage.asc
packages/desktop/dist-electron/Fusion-*-linux-*.deb
packages/desktop/dist-electron/Fusion-*-linux-*.deb.sha256
packages/desktop/dist-electron/Fusion-*-linux-*.deb.asc
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.sha256
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.asc
# ── Create GitHub Release ─────────────────────────────────────────────
github-release:
@@ -290,7 +309,7 @@ jobs:
- name: Collect release files
run: |
mkdir release-files
find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" \) -exec cp {} release-files/ \;
find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" \) -exec cp {} release-files/ \;
ls -la release-files/
- name: Create GitHub Release

View File

@@ -255,6 +255,22 @@ jobs:
exit 1
fi
- name: Sign Linux desktop artifacts
if: ${{ env.LINUX_GPG_PRIVATE_KEY != '' }}
env:
LINUX_GPG_PRIVATE_KEY: ${{ secrets.LINUX_GPG_PRIVATE_KEY }}
LINUX_GPG_PASSPHRASE: ${{ secrets.LINUX_GPG_PASSPHRASE }}
LINUX_GPG_KEY_ID: ${{ secrets.LINUX_GPG_KEY_ID }}
shell: bash
run: |
shopt -s nullglob
artifacts=(
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage
packages/desktop/dist-electron/Fusion-*-linux-*.deb
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz
)
bash scripts/sign-linux.sh "${artifacts[@]}"
- name: Generate desktop Linux checksums
shell: bash
run: |
@@ -271,10 +287,13 @@ jobs:
path: |
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage.sha256
packages/desktop/dist-electron/Fusion-*-linux-*.AppImage.asc
packages/desktop/dist-electron/Fusion-*-linux-*.deb
packages/desktop/dist-electron/Fusion-*-linux-*.deb.sha256
packages/desktop/dist-electron/Fusion-*-linux-*.deb.asc
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.sha256
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.asc
# ── Collect all artifacts ─────────────────────────────────────────────
collect:
@@ -291,7 +310,7 @@ jobs:
- name: Combine artifacts
run: |
mkdir combined
find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" \) -exec cp {} combined/ \;
find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" \) -exec cp {} combined/ \;
ls -la combined/
- name: Upload combined archive

View File

@@ -1,6 +1,6 @@
# Code Signing Setup Guide
This document explains how to configure code signing for `kb` release binaries so they don't trigger OS security warnings on macOS (Gatekeeper) or Windows (SmartScreen).
This document explains how to configure code signing for `kb` release binaries so they don't trigger OS security warnings on macOS (Gatekeeper) or Windows (SmartScreen), and how Linux desktop artifact signatures are published for authenticity verification.
## Overview
@@ -8,7 +8,7 @@ The release workflow automatically signs binaries when the appropriate secrets a
- **macOS**: Codesign with hardened runtime + Apple notarization
- **Windows**: Authenticode signing with timestamp
- **Linux**: No signing (no standard code signing requirement for Linux CLI tools)
- **Linux**: GPG detached-signature signing of `.AppImage` / `.deb` / `.tar.gz` desktop artifacts
Signing is **optional** — if secrets are not configured, the build succeeds and signing steps are skipped.
@@ -32,6 +32,14 @@ Signing is **optional** — if secrets are not configured, the build succeeds an
| `WINDOWS_CERTIFICATE_BASE64` | Base64-encoded `.pfx` Authenticode code signing certificate |
| `WINDOWS_CERTIFICATE_PASSWORD` | Password for the `.pfx` certificate |
### Linux Signing
| Secret | Description |
|--------|-------------|
| `LINUX_GPG_PRIVATE_KEY` | Base64 of an ASCII-armored exported private key (`gpg --armor --export-secret-keys`) |
| `LINUX_GPG_PASSPHRASE` | Passphrase that unlocks the exported private key |
| `LINUX_GPG_KEY_ID` | Long key ID or full fingerprint used with `gpg --local-user` |
## macOS Setup Instructions
### 1. Obtain a Developer ID Application Certificate
@@ -71,6 +79,53 @@ You can find it by running:
security find-identity -v -p codesigning
```
## Linux Signing
### 1. Generate a release signing key
```bash
gpg --full-generate-key
```
Recommended: RSA 4096, with either no expiration or a 2-year expiration.
### 2. Export and base64-encode the private key for GitHub Actions secret storage
```bash
gpg --armor --export-secret-keys <key-id> | base64 -w0 | pbcopy
```
Linux clipboard alternative:
```bash
gpg --armor --export-secret-keys <key-id> | base64 -w0 | xclip -selection clipboard
```
Store this output as `LINUX_GPG_PRIVATE_KEY`.
### 3. Set Linux signing secrets
Set repository-level Actions secrets:
- `LINUX_GPG_PRIVATE_KEY`
- `LINUX_GPG_PASSPHRASE`
- `LINUX_GPG_KEY_ID`
### 4. Publish the public key for users
Export and publish the public key in release notes and/or a repository `KEYS` file:
```bash
gpg --armor --export <key-id>
```
### 5. User verification flow
```bash
gpg --import KEYS && gpg --verify Fusion-<version>-linux-x64.AppImage.asc Fusion-<version>-linux-x64.AppImage
```
If `LINUX_GPG_PRIVATE_KEY` is absent (forks/initial setup), CI continues and ships unsigned Linux artifacts without `.asc` sidecars.
## Windows Setup Instructions
### 1. Obtain an Authenticode Code Signing Certificate
@@ -104,8 +159,9 @@ Paste the result as the `WINDOWS_CERTIFICATE_BASE64` secret.
2. Each platform job builds the standalone binary
3. **macOS jobs**: `scripts/sign-macos.sh` runs codesign + notarization
4. **Windows jobs**: `scripts/sign-windows.ps1` runs Authenticode signing
5. Checksums are generated **after** signing (so they match the signed binaries)
6. Signed binaries and checksums are uploaded to the GitHub Release
5. **Linux desktop jobs**: `scripts/sign-linux.sh` runs `gpg --detach-sign --armor` for each `.AppImage` / `.deb` / `.tar.gz` artifact and verifies each signature with `gpg --verify`
6. Checksums are generated **after** signing (so they match the signed binaries)
7. Signed binaries and checksums are uploaded to the GitHub Release
The test-release workflow (`workflow_dispatch`) includes the same signing steps but guards them with secret-availability checks — signing is skipped if secrets are not configured.
@@ -139,6 +195,21 @@ Desktop Windows packaging (`.github/workflows/desktop-windows.yml`) now uses the
- Double-check the `WINDOWS_CERTIFICATE_PASSWORD` secret matches the password used when exporting the `.pfx`
### Linux signing skipped
- Expected when `LINUX_GPG_PRIVATE_KEY` is not set (forks, dry runs, first-time setup)
- In this path, Linux artifacts upload without `.asc` sidecars by design
### Linux signing fails with "No secret key"
- `LINUX_GPG_KEY_ID` does not match the imported private key fingerprint/key ID
- Re-export and re-check key ID via `gpg --list-secret-keys --keyid-format=long`
### Linux verification fails with "BAD signature"
- Artifact changed between sign and verify; investigate runner storage path and artifact staging
- Ensure signatures are generated from the exact file that is later uploaded
### Signing step skipped
- In the test-release workflow, signing is intentionally skipped when secrets are not configured

View File

@@ -357,8 +357,20 @@ Desktop packaging is configured in `electron-builder.yml`.
- macOS: `Fusion-<version>-mac-arm64.dmg`, `Fusion-<version>-mac-x64.dmg`, matching `.zip` variants, `.sha256` sidecars, and `.blockmap` files.
- Linux: `Fusion-<version>-linux-x64.AppImage` with `.sha256`, plus best-effort `.deb` and `.tar.gz` outputs (and sidecars) when available on the runner image.
- Tag-less release rehearsal workflow (`.github/workflows/test-release.yml`) mirrors that artifact collection path without publishing a real GitHub Release.
- macOS Developer ID signing/notarization and Linux signing are not wired in these release jobs yet; release-attached macOS/Linux artifacts are currently unsigned and follow-up tasks will add signing flows.
- Linux desktop artifacts now include detached GPG signature sidecars: `*.AppImage.asc`, `*.deb.asc`, and `*.tar.gz.asc` when Linux signing secrets are configured in CI.
- Linux `.deb` and `.tar.gz` outputs are best-effort and may be absent on some runner images without failing the release.
### Linux signing
Every signed Linux desktop release includes `*.asc` detached signature sidecars alongside the binary artifacts.
Verification example:
```bash
gpg --import KEYS && gpg --verify Fusion-<version>-linux-x64.AppImage.asc Fusion-<version>-linux-x64.AppImage
```
The public key (`KEYS`) must be distributed out-of-band. See `docs/CODE_SIGNING.md` for canonical setup, key publication, and troubleshooting guidance.
- Isolated manual Windows build path: `.github/workflows/desktop-windows.yml` (`workflow_dispatch` on `windows-latest`) runs `electron-builder --win --x64 --arm64 --publish never`.
- ARM64 artifacts are cross-built on the `windows-latest` x64 runner; execution/validation still requires a Windows ARM64 device or emulator.
- Deep link protocol: `fusion://`

View File

@@ -59,3 +59,44 @@ describe("desktop release workflow wiring", () => {
);
});
});
describe("desktop linux signing wiring", () => {
it("wires Linux GPG secret-guarded signing and asc uploads in both workflows", async () => {
const release = await readRepoFile(".github/workflows/release.yml");
const testRelease = await readRepoFile(".github/workflows/test-release.yml");
for (const workflow of [release, testRelease]) {
expect(workflow).toContain("secrets.LINUX_GPG_PRIVATE_KEY");
expect(workflow).toContain("secrets.LINUX_GPG_PASSPHRASE");
expect(workflow).toContain("secrets.LINUX_GPG_KEY_ID");
expect(workflow).toContain("LINUX_GPG_PRIVATE_KEY != ''");
expect(workflow).toContain("scripts/sign-linux.sh");
expect(workflow).toContain("Fusion-*-linux-*.AppImage.asc");
expect(workflow).toContain("Fusion-*-linux-*.deb.asc");
expect(workflow).toContain("Fusion-*-linux-*.tar.gz.asc");
expect(workflow).not.toMatch(/echo\s+["']?\$\{?\s*(secrets\.)?LINUX_GPG_PASSPHRASE/);
expect(workflow).not.toMatch(/cat\s+["']?\$\{?\s*(secrets\.)?LINUX_GPG_PASSPHRASE/);
}
});
it("collectors in both workflows include asc signatures", async () => {
const release = await readRepoFile(".github/workflows/release.yml");
const testRelease = await readRepoFile(".github/workflows/test-release.yml");
expect(release).toContain('-name "*.asc"');
expect(testRelease).toContain('-name "*.asc"');
});
it("sign-linux.sh contains expected guarded gpg sign and verify shape", async () => {
const script = await readRepoFile("scripts/sign-linux.sh");
expect(script).toContain("set -euo pipefail");
expect(script).toContain("LINUX_GPG_PRIVATE_KEY");
expect(script).toContain("LINUX_GPG_PASSPHRASE");
expect(script).toContain("LINUX_GPG_KEY_ID");
expect(script).toContain("gpg --verify");
expect(script).toContain("--detach-sign");
expect(script).toContain("--armor");
expect(script).toContain("Linux signing skipped (LINUX_GPG_PRIVATE_KEY not set)");
});
});

72
scripts/sign-linux.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# sign-linux.sh — Create GPG detached ASCII signatures for Linux release artifacts.
#
# Usage:
# LINUX_GPG_PRIVATE_KEY=... LINUX_GPG_PASSPHRASE=... LINUX_GPG_KEY_ID=... \
# bash scripts/sign-linux.sh path/to/file [path/to/file...]
#
# Environment variables:
# LINUX_GPG_PRIVATE_KEY — Base64-encoded ASCII-armored private key
# LINUX_GPG_PASSPHRASE — Passphrase for private key
# LINUX_GPG_KEY_ID — Key ID or fingerprint used for --local-user
#
# Exit semantics:
# 0 when signing is skipped (missing LINUX_GPG_PRIVATE_KEY),
# 0 on successful sign + verify for all files,
# non-zero on signing/verification/import failures.
#
# Do not invoke outside CI unless GNUPGHOME is isolated.
set -euo pipefail
if [[ "$#" -eq 0 ]]; then
echo "ERROR: No artifact paths provided."
echo "Usage: $0 <artifact-path> [artifact-path ...]"
exit 1
fi
if [[ -z "${LINUX_GPG_PRIVATE_KEY:-}" ]]; then
echo "Linux signing skipped (LINUX_GPG_PRIVATE_KEY not set)"
exit 0
fi
if [[ -z "${LINUX_GPG_PASSPHRASE:-}" ]]; then
echo "ERROR: LINUX_GPG_PASSPHRASE is required when signing is enabled."
exit 1
fi
if [[ -z "${LINUX_GPG_KEY_ID:-}" ]]; then
echo "ERROR: LINUX_GPG_KEY_ID is required when signing is enabled."
exit 1
fi
GNUPGHOME="${RUNNER_TEMP:-$(mktemp -d)}/gnupg-fusion"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"
export GNUPGHOME
cleanup() {
unset LINUX_GPG_PASSPHRASE
rm -rf "$GNUPGHOME"
}
trap cleanup EXIT
echo "$LINUX_GPG_PRIVATE_KEY" | base64 -d | gpg --batch --import
for file in "$@"; do
if [[ ! -f "$file" ]]; then
echo "ERROR: Artifact not found: $file"
exit 1
fi
gpg --batch --yes \
--pinentry-mode loopback \
--passphrase "$LINUX_GPG_PASSPHRASE" \
--local-user "$LINUX_GPG_KEY_ID" \
--armor \
--detach-sign \
--output "${file}.asc" \
"$file"
gpg --verify "${file}.asc" "$file"
done