desktop-release-github-actions.yml yaml
547 lines 21.2 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 10 hours ago
1 # Example: signed desktop installer release CI (§QR.4 / K11-style template).
2 # Vendored documentation parity — copy to .github/workflows/desktop-release.yml
3 # in the kit dogfood repo (already present) or a fork.
4 #
5 # Secrets (§QR.6.2) — repository Actions secrets only, never in-repo:
6 # APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY,
7 # APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD
8 # (alternate notarization: APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER + APPLE_TEAM_ID)
9 # WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_PASSWORD
10 # LINUX_SIGNING_KEY, LINUX_SIGNING_KEY_PASSWORD
11 #
12 # Kit dogfood mode: App Store Connect API key preferred for Apple CI when configured.
13 #
14 # Overseer Kit — signed desktop installer release (§QR.4–§QR.8).
15 # Kit dogfood workflow. Secrets live only in GitHub Actions repository secrets
16 # (§QR.6) — never commit private keys, .p12, .pfx, or passwords.
17 #
18 # Triggers: tag push matching v* (VERSION must equal tag without leading v),
19 # or workflow_dispatch with frozen inputs.
20 # Forbidden: pull_request / schedule / feature-branch push publish.
21
22 name: desktop-release
23
24 on:
25 push:
26 tags:
27 - "v*"
28 workflow_dispatch:
29 inputs:
30 version:
31 description: Kit VERSION (must equal VERSION file)
32 required: true
33 type: string
34 publish:
35 description: Upload assets to GitHub Release when true
36 required: false
37 type: boolean
38 default: true
39 allow_partial:
40 description: Allow incomplete platform set (honest unavailable rows)
41 required: false
42 type: boolean
43 default: false
44
45 permissions:
46 contents: write
47
48 concurrency:
49 group: desktop-release-${{ github.ref }}
50 cancel-in-progress: false
51
52 env:
53 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
54
55 jobs:
56 version-check:
57 runs-on: ubuntu-22.04
58 outputs:
59 version: ${{ steps.align.outputs.version }}
60 publish: ${{ steps.flags.outputs.publish }}
61 allow_partial: ${{ steps.flags.outputs.allow_partial }}
62 steps:
63 - uses: actions/checkout@v4
64
65 - uses: actions/setup-python@v5
66 with:
67 python-version: "3.11"
68
69 - name: Resolve publish flags
70 id: flags
71 shell: bash
72 run: |
73 if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
74 echo "publish=${{ inputs.publish }}" >> "$GITHUB_OUTPUT"
75 echo "allow_partial=${{ inputs.allow_partial }}" >> "$GITHUB_OUTPUT"
76 else
77 echo "publish=true" >> "$GITHUB_OUTPUT"
78 echo "allow_partial=false" >> "$GITHUB_OUTPUT"
79 fi
80
81 - name: Version alignment (§QR.8)
82 id: align
83 shell: bash
84 run: |
85 set -euo pipefail
86 if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
87 TAG_ARG=""
88 DISPATCH_ARG="--dispatch-version ${{ inputs.version }}"
89 else
90 TAG_ARG="--tag ${{ github.ref_name }}"
91 DISPATCH_ARG=""
92 fi
93 python - <<'PY'
94 import os
95 import sys
96 from pathlib import Path
97 from tools.desktop_release.version_align import VersionAlignError, check_version_alignment
98
99 kit = Path(".").resolve()
100 tag = os.environ.get("TAG_NAME") or None
101 dispatch = os.environ.get("DISPATCH_VERSION") or None
102 try:
103 version = check_version_alignment(kit, tag=tag, dispatch_version=dispatch)
104 except VersionAlignError as exc:
105 print(f"version alignment failed: {exc}", file=sys.stderr)
106 sys.exit(1)
107 print(f"version={version}")
108 with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
109 fh.write(f"version={version}\n")
110 PY
111 env:
112 TAG_NAME: ${{ github.event_name != 'workflow_dispatch' && github.ref_name || '' }}
113 DISPATCH_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }}
114
115 build:
116 needs: version-check
117 strategy:
118 fail-fast: false
119 matrix:
120 include:
121 - os: macos-14
122 platform: macos
123 artifact_glob: "*.dmg"
124 - os: windows-latest
125 platform: windows
126 artifact_glob: "*.msi"
127 - os: ubuntu-22.04
128 platform: linux
129 artifact_glob: "*.AppImage"
130 runs-on: ${{ matrix.os }}
131 steps:
132 - uses: actions/checkout@v4
133
134 - uses: actions/setup-python@v5
135 with:
136 python-version: "3.11"
137
138 - uses: actions/setup-node@v4
139 with:
140 node-version: "20"
141 cache: npm
142 cache-dependency-path: desktop/package-lock.json
143
144 - name: Install Rust toolchain
145 uses: dtolnay/rust-toolchain@stable
146 with:
147 toolchain: "1.88.0"
148
149 - name: Linux AppImage deps
150 if: matrix.platform == 'linux'
151 run: |
152 sudo apt-get update
153 sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
154
155 - name: Require signing secrets when publishing (§QR.5 fail-closed)
156 shell: bash
157 env:
158 PUBLISH: ${{ needs.version-check.outputs.publish }}
159 PLATFORM: ${{ matrix.platform }}
160 APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
161 APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
162 APPLE_ID: ${{ secrets.APPLE_ID }}
163 APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
164 APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
165 APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
166 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
167 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
168 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
169 WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
170 WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
171 LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
172 LINUX_SIGNING_KEY_PASSWORD: ${{ secrets.LINUX_SIGNING_KEY_PASSWORD }}
173 run: |
174 set -euo pipefail
175 python - <<'PY'
176 import os
177 import sys
178 from tools.desktop_release.finalize import FinalizeError, require_signing_secrets
179
180 publish = os.environ.get("PUBLISH", "true").lower() == "true"
181 platform = os.environ["PLATFORM"]
182 secrets_present = {
183 name: bool(os.environ.get(name))
184 for name in (
185 "APPLE_CERTIFICATE",
186 "APPLE_CERTIFICATE_PASSWORD",
187 "APPLE_ID",
188 "APPLE_TEAM_ID",
189 "APPLE_APP_SPECIFIC_PASSWORD",
190 "APPLE_SIGNING_IDENTITY",
191 "APPLE_API_KEY",
192 "APPLE_API_KEY_ID",
193 "APPLE_API_ISSUER",
194 "WINDOWS_CERTIFICATE",
195 "WINDOWS_CERTIFICATE_PASSWORD",
196 "LINUX_SIGNING_KEY",
197 "LINUX_SIGNING_KEY_PASSWORD",
198 )
199 }
200 try:
201 require_signing_secrets(
202 publish=publish,
203 platform=platform,
204 secrets_present=secrets_present,
205 )
206 except FinalizeError as exc:
207 print(str(exc), file=sys.stderr)
208 sys.exit(1)
209 print(f"signing secret check ok for {platform} (publish={publish})")
210 PY
211
212 - name: Bundle kit into Tauri resources
213 run: ./scripts/bundle-desktop-kit.sh
214 shell: bash
215
216 - name: Install desktop npm deps
217 working-directory: desktop
218 run: npm ci
219
220 - name: Import Apple certificate
221 if: matrix.platform == 'macos' && needs.version-check.outputs.publish == 'true'
222 shell: bash
223 env:
224 APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
225 APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
226 run: |
227 set -euo pipefail
228 CERT_PATH="$RUNNER_TEMP/apple-cert.p12"
229 KEYCHAIN="$RUNNER_TEMP/overseer-signing.keychain-db"
230 echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
231 security create-keychain -p "" "$KEYCHAIN"
232 security set-keychain-settings -lut 21600 "$KEYCHAIN"
233 security unlock-keychain -p "" "$KEYCHAIN"
234 security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN"
235 security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed s/\"//g)
236 security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN"
237 rm -f "$CERT_PATH"
238
239 - name: Import Windows certificate
240 if: matrix.platform == 'windows' && needs.version-check.outputs.publish == 'true'
241 shell: pwsh
242 env:
243 WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
244 WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
245 run: |
246 $pfxPath = Join-Path $env:RUNNER_TEMP "windows-cert.pfx"
247 [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
248 echo "WINDOWS_PFX_PATH=$pfxPath" >> $env:GITHUB_ENV
249
250 - name: Tauri build
251 working-directory: desktop
252 shell: bash
253 env:
254 APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
255 APPLE_ID: ${{ secrets.APPLE_ID }}
256 APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
257 APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
258 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
259 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
260 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
261 WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
262 run: |
263 set -euo pipefail
264 npx tauri build
265
266 - name: Locate bundle + enforce allowlist
267 id: locate
268 shell: bash
269 env:
270 PLATFORM: ${{ matrix.platform }}
271 VERSION: ${{ needs.version-check.outputs.version }}
272 ARTIFACT_GLOB: ${{ matrix.artifact_glob }}
273 run: |
274 set -euo pipefail
275 python - <<'PY'
276 import glob
277 import os
278 import shutil
279 import sys
280 from pathlib import Path
281 from tools.desktop_release.allowlist import AllowlistError, refuse_disallowed_asset
282
283 version = os.environ["VERSION"]
284 pattern = os.environ["ARTIFACT_GLOB"]
285 root = Path("desktop/src-tauri/target/release/bundle")
286 matches = sorted(
287 Path(p)
288 for p in glob.glob(str(root / "**" / pattern), recursive=True)
289 if Path(p).is_file()
290 )
291 if not matches:
292 print(f"no artifacts matching {pattern} under {root}", file=sys.stderr)
293 sys.exit(1)
294 chosen = matches[0]
295 try:
296 refuse_disallowed_asset(chosen.name, version=version)
297 except AllowlistError as exc:
298 print(exc, file=sys.stderr)
299 sys.exit(1)
300 out = Path("release-artifacts")
301 out.mkdir(parents=True, exist_ok=True)
302 dest = out / chosen.name
303 shutil.copy2(chosen, dest)
304 with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
305 fh.write(f"path={dest.as_posix()}\n")
306 fh.write(f"filename={dest.name}\n")
307 PY
308
309 - name: Authenticode-sign Windows MSI
310 if: matrix.platform == 'windows' && needs.version-check.outputs.publish == 'true'
311 shell: pwsh
312 env:
313 WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
314 ARTIFACT_PATH: ${{ steps.locate.outputs.path }}
315 run: |
316 if (-not $env:WINDOWS_PFX_PATH) { throw "WINDOWS_PFX_PATH missing" }
317 & signtool sign /f $env:WINDOWS_PFX_PATH /p $env:WINDOWS_CERTIFICATE_PASSWORD /tr http://timestamp.digicert.com /td sha256 /fd sha256 $env:ARTIFACT_PATH
318 if ($LASTEXITCODE -ne 0) { throw "signtool failed" }
319
320 - name: Sign Linux AppImage (minisign detached)
321 if: matrix.platform == 'linux' && needs.version-check.outputs.publish == 'true'
322 shell: bash
323 env:
324 LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
325 LINUX_SIGNING_KEY_PASSWORD: ${{ secrets.LINUX_SIGNING_KEY_PASSWORD }}
326 ARTIFACT_PATH: ${{ steps.locate.outputs.path }}
327 run: |
328 set -euo pipefail
329 curl -fsSL -o "$RUNNER_TEMP/minisign.tar.gz" \
330 https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-linux.tar.gz
331 tar -xzf "$RUNNER_TEMP/minisign.tar.gz" -C "$RUNNER_TEMP"
332 MINISIGN="$(find "$RUNNER_TEMP" -type f -name minisign | head -n 1)"
333 test -x "$MINISIGN"
334 KEY_PATH="$RUNNER_TEMP/linux-signing.key"
335 printf '%s' "$LINUX_SIGNING_KEY" > "$KEY_PATH"
336 if [ -n "${LINUX_SIGNING_KEY_PASSWORD:-}" ]; then
337 "$MINISIGN" -S -s "$KEY_PATH" -m "$ARTIFACT_PATH" -x "${ARTIFACT_PATH}.minisig" <<< "$LINUX_SIGNING_KEY_PASSWORD"
338 else
339 "$MINISIGN" -S -s "$KEY_PATH" -m "$ARTIFACT_PATH" -x "${ARTIFACT_PATH}.minisig"
340 fi
341 SIG="${ARTIFACT_PATH}.minisig"
342 test -f "$SIG"
343 cp "$SIG" "release-artifacts/$(basename "$SIG")"
344 rm -f "$KEY_PATH"
345
346 - name: Hash artifact after signing
347 id: hash
348 shell: bash
349 env:
350 ARTIFACT_PATH: ${{ steps.locate.outputs.path }}
351 run: |
352 set -euo pipefail
353 python - <<'PY'
354 import os
355 from pathlib import Path
356 from tools.desktop_release.checksums import sha256_file
357
358 path = Path(os.environ["ARTIFACT_PATH"])
359 digest = sha256_file(path)
360 with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
361 fh.write(f"sha256={digest}\n")
362 print(f"sha256={digest}")
363 PY
364
365 - name: Write platform manifest fragment
366 shell: bash
367 env:
368 PLATFORM: ${{ matrix.platform }}
369 VERSION: ${{ needs.version-check.outputs.version }}
370 FILENAME: ${{ steps.locate.outputs.filename }}
371 SHA256: ${{ steps.hash.outputs.sha256 }}
372 PUBLISH: ${{ needs.version-check.outputs.publish }}
373 run: |
374 set -euo pipefail
375 python - <<'PY'
376 import json
377 import os
378 from pathlib import Path
379
380 platform = os.environ["PLATFORM"]
381 method = {
382 "macos": "developer_id_notarized",
383 "windows": "authenticode",
384 "linux": "minisign_detached",
385 }[platform]
386 status = "signed" if os.environ.get("PUBLISH", "true").lower() == "true" else "unsigned"
387 arch = {"macos": "aarch64", "windows": "x86_64", "linux": "x86_64"}[platform]
388 frag = {
389 "platform": platform,
390 "filename": os.environ["FILENAME"],
391 "sha256": os.environ["SHA256"],
392 "arch": arch,
393 "signing": {"status": status, "method": method if status == "signed" else "none"},
394 }
395 out = Path("release-artifacts") / f"fragment-{platform}.json"
396 out.write_text(json.dumps(frag, indent=2) + "\n", encoding="utf-8")
397 PY
398
399 - name: Upload platform workflow artifacts
400 uses: actions/upload-artifact@v4
401 with:
402 name: desktop-${{ matrix.platform }}-${{ needs.version-check.outputs.version }}
403 path: release-artifacts/
404 if-no-files-found: error
405 retention-days: 14
406
407 finalize:
408 needs: [version-check, build]
409 runs-on: ubuntu-22.04
410 if: always() && needs.version-check.result == 'success'
411 steps:
412 - uses: actions/checkout@v4
413
414 - uses: actions/setup-python@v5
415 with:
416 python-version: "3.11"
417
418 - name: Download platform artifacts
419 uses: actions/download-artifact@v4
420 with:
421 path: gathered
422 pattern: desktop-*-${{ needs.version-check.outputs.version }}
423 merge-multiple: false
424
425 - name: Assemble manifest + SHA256SUMS + allowlist gate
426 id: assemble
427 shell: bash
428 env:
429 VERSION: ${{ needs.version-check.outputs.version }}
430 PUBLISH: ${{ needs.version-check.outputs.publish }}
431 ALLOW_PARTIAL: ${{ needs.version-check.outputs.allow_partial }}
432 GIT_SHA: ${{ github.sha }}
433 run: |
434 set -euo pipefail
435 python - <<'PY'
436 import json
437 import os
438 import sys
439 from pathlib import Path
440 from tools.desktop_release.allowlist import AllowlistError, refuse_disallowed_asset
441 from tools.desktop_release.checksums import write_sha256sums
442 from tools.desktop_release.constants import MANIFEST_FILENAME_TEMPLATE, SHA256SUMS_FILENAME
443 from tools.desktop_release.manifest import ManifestError, build_manifest, canonical_manifest_bytes
444
445 version = os.environ["VERSION"]
446 publish = os.environ.get("PUBLISH", "true").lower() == "true"
447 allow_partial = os.environ.get("ALLOW_PARTIAL", "false").lower() == "true"
448 git_sha = os.environ["GIT_SHA"]
449 root = Path("gathered")
450 fragments = sorted(root.rglob("fragment-*.json"))
451 if not fragments and publish and not allow_partial:
452 print("no platform fragments found", file=sys.stderr)
453 sys.exit(1)
454
455 artifacts = []
456 for frag_path in fragments:
457 data = json.loads(frag_path.read_text(encoding="utf-8"))
458 artifacts.append(data)
459
460 platforms_present = {a["platform"] for a in artifacts}
461 required = {"macos", "windows", "linux"}
462 missing = sorted(required - platforms_present)
463 if missing and publish and not allow_partial:
464 print(f"missing platforms: {missing}", file=sys.stderr)
465 sys.exit(1)
466 for plat in missing:
467 artifacts.append(
468 {
469 "platform": plat,
470 "filename": f"UNAVAILABLE-{plat}",
471 "sha256": "0" * 64,
472 "signing": {"status": "unavailable", "method": "none"},
473 }
474 )
475
476 out = Path("release-dist")
477 out.mkdir(parents=True, exist_ok=True)
478
479 # Copy allowlisted installer + sidecar files into release-dist
480 for path in root.rglob("*"):
481 if not path.is_file():
482 continue
483 if path.name.startswith("fragment-"):
484 continue
485 try:
486 refuse_disallowed_asset(path.name, version=version)
487 except AllowlistError:
488 print(f"skipping non-allowlisted file: {path.name}", file=sys.stderr)
489 continue
490 dest = out / path.name
491 dest.write_bytes(path.read_bytes())
492
493 # Drop unavailable placeholder rows that are not real files
494 real_artifacts = [a for a in artifacts if a["signing"]["status"] != "unavailable"]
495 unavailable = [a for a in artifacts if a["signing"]["status"] == "unavailable"]
496
497 if publish and not allow_partial:
498 for a in real_artifacts:
499 if a["signing"]["status"] != "signed":
500 print(f"unsigned artifact on publish: {a['filename']}", file=sys.stderr)
501 sys.exit(1)
502
503 try:
504 manifest = build_manifest(
505 version=version,
506 git_sha=git_sha,
507 artifacts=real_artifacts + unavailable,
508 )
509 except ManifestError as exc:
510 print(exc, file=sys.stderr)
511 sys.exit(1)
512
513 # Manifest must not claim signed for unavailable placeholders with fake hashes
514 # when allow_partial — already status unavailable.
515
516 manifest_name = MANIFEST_FILENAME_TEMPLATE.format(version=version)
517 (out / manifest_name).write_bytes(canonical_manifest_bytes(manifest))
518 sums = [(a["sha256"], a["filename"]) for a in real_artifacts]
519 write_sha256sums(out / SHA256SUMS_FILENAME, sums)
520
521 notes = [
522 f"Overseer Kit desktop {version}",
523 f"Commit: {git_sha}",
524 "",
525 "Verify checksums with SHA256SUMS.txt and the release manifest.",
526 "Operator runbook: docs/TRACK-Q-DESKTOP-OPERATOR-RUNBOOK.md",
527 "Installers still require Python 3.11+ on the host.",
528 ]
529 if unavailable:
530 notes.append("")
531 notes.append("Unavailable platforms (not signed): " + ", ".join(a["platform"] for a in unavailable))
532 notes.append("Do not call this a complete signed release.")
533 Path("release-notes.md").write_text("\n".join(notes) + "\n", encoding="utf-8")
534 print(f"assembled {len(real_artifacts)} artifacts")
535 PY
536
537 - name: Publish GitHub Release assets
538 if: needs.version-check.outputs.publish == 'true'
539 uses: softprops/action-gh-release@v2
540 with:
541 tag_name: v${{ needs.version-check.outputs.version }}
542 name: Overseer Kit desktop v${{ needs.version-check.outputs.version }}
543 body_path: release-notes.md
544 files: release-dist/*
545 fail_on_unmatched_files: true
546 env:
547 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
File History 1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 10 hours ago