From 8a47bce6bd361880674a9192edc74feb53d2220f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 13 Aug 2026 00:00:19 -0400 Subject: [PATCH] CI: build installers on release publish and attach them to the Gitea release - .gitea/workflows/release-build.yml: on release published (plus a manual back-fill trigger), build the Windows NSIS installer and macOS dmg/zip (x64 + arm64) on the self-hosted runners and upload them to the release. - scripts/ci/upload-release-assets.mjs: attaches release/ artifacts via the Gitea API; uploads first, then removes stale same-named assets. - electron-builder.unsigned.yml: ad-hoc fallback config used while the mac runner has no Developer ID identity / notarization credentials. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/release-build.yml | 103 +++++++++++++++++++++++++++ electron-builder.unsigned.yml | 14 ++++ scripts/ci/upload-release-assets.mjs | 79 ++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 .gitea/workflows/release-build.yml create mode 100644 electron-builder.unsigned.yml create mode 100644 scripts/ci/upload-release-assets.mjs diff --git a/.gitea/workflows/release-build.yml b/.gitea/workflows/release-build.yml new file mode 100644 index 00000000..ded13b49 --- /dev/null +++ b/.gitea/workflows/release-build.yml @@ -0,0 +1,103 @@ +name: Build Release Installers + +# Builds the Windows and macOS installers whenever a release is published on +# Gitea and attaches them to that release. Only releases whose tag points at a +# commit containing this file will trigger it; to back-fill installers onto an +# older release, run this workflow manually with the release ID as input +# (note: a manual run builds the dispatched ref's code, not the old tag's). +# +# Runner facts these jobs rely on (host-mode act_runner on both machines): +# - windows-latest: node 24 + pnpm + git + curl on PATH; default shell is +# Windows PowerShell 5.1, `shell: bash` is broken (WSL stub) - use cmd. +# - macos-latest: node 24 (nvm) + corepack on PATH, no global pnpm; Xcode and +# notarytool installed. Signed builds additionally need: +# 1. the "Developer ID Application: Yang Zhang (9PDD8M2SMS)" certificate +# WITH private key imported into the runner user's login keychain, and +# (one-time, so headless codesign does not hang on a keychain prompt): +# security unlock-keychain ~/Library/Keychains/login.keychain-db +# security set-key-partition-list -S apple-tool:,apple: -s \ +# ~/Library/Keychains/login.keychain-db +# 2. the APPLE_API_ISSUER repo secret (App Store Connect issuer ID) for +# notarization, used together with the API key file in the repo. +# Without these the macos job falls back to ad-hoc-signed, un-notarized +# installers and emits a warning. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_id: + description: "Gitea release ID to attach installers to (back-fill)" + required: true + +jobs: + windows: + name: Windows installer (NSIS x64) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + shell: cmd + run: call pnpm install --frozen-lockfile + - name: Build installer + shell: cmd + run: call pnpm run package:win + - name: List artifacts + shell: cmd + run: dir release + - name: Upload installer to release + shell: cmd + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_ID: ${{ github.event.release.id || inputs.release_id }} + run: node scripts/ci/upload-release-assets.mjs + + macos: + name: macOS installers (dmg + zip, x64 + arm64) + runs-on: macos-latest + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + steps: + - uses: actions/checkout@v4 + - name: Enable pnpm via corepack + run: | + corepack enable pnpm + pnpm --version + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Detect signing capability + id: signing + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + if security find-identity -v -p codesigning | grep -q "Developer ID Application: Yang Zhang (9PDD8M2SMS)" && [ -n "$APPLE_API_ISSUER" ]; then + echo "signed=true" >> "$GITHUB_OUTPUT" + echo "Developer ID identity and notarization credentials found: building signed + notarized installers." + else + echo "signed=false" >> "$GITHUB_OUTPUT" + echo "::warning::Building ad-hoc-signed, un-notarized macOS installers. To enable signing + notarization, see the setup steps in the header of this workflow file." + fi + - name: Build installers (signed + notarized) + if: steps.signing.outputs.signed == 'true' + env: + APPLE_API_KEY: ${{ github.workspace }}/ApiKey_ZDCSZ5IE7071.p8 + APPLE_API_KEY_ID: ZDCSZ5IE7071 + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + # forceCodeSigning makes a signing failure fail the build instead of + # silently attaching unsigned installers to the release. + run: | + pnpm build + pnpm exec electron-builder --mac --x64 --arm64 --publish never -c.forceCodeSigning=true + - name: Build installers (ad-hoc fallback) + if: steps.signing.outputs.signed != 'true' + run: | + pnpm build + pnpm exec electron-builder --mac --x64 --arm64 --publish never --config electron-builder.unsigned.yml + - name: List artifacts + run: ls -la release + - name: Upload installers to release + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_ID: ${{ github.event.release.id || inputs.release_id }} + run: node scripts/ci/upload-release-assets.mjs diff --git a/electron-builder.unsigned.yml b/electron-builder.unsigned.yml new file mode 100644 index 00000000..f23b63eb --- /dev/null +++ b/electron-builder.unsigned.yml @@ -0,0 +1,14 @@ +# CI fallback config: used by the release workflow when the runner has no +# "Developer ID Application" identity in its keychain or the APPLE_API_ISSUER +# secret is missing. +# +# identity "-" ad-hoc signs the bundle so it carries a fresh valid signature; +# leaving it unsigned (identity: null) would keep the Electron dist's stale +# signature, which macOS on Apple Silicon rejects outright as "damaged". +# Ad-hoc builds are still not notarized: on first launch users must allow the +# app via System Settings > Privacy & Security (or `xattr -cr` the app). +extends: ./electron-builder.yml +mac: + identity: "-" + hardenedRuntime: false + notarize: false diff --git a/scripts/ci/upload-release-assets.mjs b/scripts/ci/upload-release-assets.mjs new file mode 100644 index 00000000..39b49231 --- /dev/null +++ b/scripts/ci/upload-release-assets.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/* + * Attaches installer artifacts from release/ to the Gitea release that + * triggered the workflow. The new asset is uploaded first and only then are + * older same-named assets deleted (Gitea allows duplicate attachment names), + * so a failed re-run never removes a previously good download. + * + * Required env: GITHUB_SERVER_URL, GITHUB_REPOSITORY (set by the runner), + * GITEA_TOKEN, RELEASE_ID (set by the workflow step). + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const server = process.env.GITHUB_SERVER_URL; +const repo = process.env.GITHUB_REPOSITORY; +const token = process.env.GITEA_TOKEN; +const releaseId = process.env.RELEASE_ID; + +for (const [name, value] of Object.entries({ GITHUB_SERVER_URL: server, GITHUB_REPOSITORY: repo, GITEA_TOKEN: token, RELEASE_ID: releaseId })) { + if (!value) { + console.error(`missing required env var ${name}`); + process.exit(1); + } +} + +const apiBase = `${server.replace(/\/$/, '')}/api/v1/repos/${repo}/releases/${releaseId}`; +const authHeader = { Authorization: `token ${token}` }; + +const INSTALLER_EXTS = new Set(['.exe', '.dmg', '.zip', '.AppImage', '.deb']); +const releaseDir = path.resolve('release'); + +const files = fs + .readdirSync(releaseDir, { withFileTypes: true }) + .filter((e) => e.isFile() && INSTALLER_EXTS.has(path.extname(e.name))) + .map((e) => e.name) + .sort(); + +if (files.length === 0) { + console.error(`no uploadable artifacts found in ${releaseDir}`); + process.exit(1); +} + +const releaseRes = await fetch(apiBase, { headers: authHeader }); +if (!releaseRes.ok) { + console.error(`failed to fetch release ${releaseId}: ${releaseRes.status} ${await releaseRes.text()}`); + process.exit(1); +} +const existingAssets = (await releaseRes.json()).assets ?? []; + +for (const name of files) { + const filePath = path.join(releaseDir, name); + const sizeMb = (fs.statSync(filePath).size / 1024 / 1024).toFixed(1); + + const stale = existingAssets.filter((a) => a.name === name); + + const form = new FormData(); + form.append('attachment', await fs.openAsBlob(filePath), name); + const res = await fetch(`${apiBase}/assets?name=${encodeURIComponent(name)}`, { + method: 'POST', + headers: authHeader, + body: form, + }); + if (!res.ok) { + console.error(`failed to upload ${name}: ${res.status} ${await res.text()}`); + process.exit(1); + } + console.log(`uploaded ${name} (${sizeMb} MB)`); + + for (const asset of stale) { + const del = await fetch(`${apiBase}/assets/${asset.id}`, { method: 'DELETE', headers: authHeader }); + if (!del.ok && del.status !== 404) { + console.error(`failed to delete stale asset ${name} (id ${asset.id}): ${del.status} ${await del.text()}`); + process.exit(1); + } + console.log(`deleted stale asset ${name} (id ${asset.id})`); + } +} + +console.log(`done: ${files.length} asset(s) attached to release ${releaseId}`);