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