Repository navigation
404 lines (374 loc) · 21.1 KB
/
Copy pathrelease.yml
File metadata and controls
404 lines (374 loc) · 21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# Linux release pipeline — builds the self-updating AppImage and stages it as a
# DRAFT GitHub release. Signing is done LOCALLY by the maintainer (the release key
# never touches GitHub) — see docs/implementation/linux_release_runbook.md.
# Phase P2 of docs/implementation/linux_update_impl.md.
#
# The published Release IS the in-app updater's feed: UpdateController polls
# api.github.com/.../releases and AppImageUpdate resolves the gh-releases-zsync
# update-info embedded in the AppImage against these assets. A release is only
# discoverable by the updater once it is PUBLISHED (non-draft) AND carries the
# detached *.AppImage.sig (the gate refuses an unsigned release) — both happen in
# the local signing step, not here.
#
# ┌─ STATUS ────────────────────────────────────────────────────────────────────┐
# │ Scaffolding — authored, not yet run. Validate by pushing a test tag. GPU │
# │ note: GitHub-hosted runners have no NVIDIA GPU, so this produces a CPU-capable │
# │ AppImage; for a CUDA-bundled build use a self-hosted runner with CUDA 12 + │
# │ cuDNN 9 and set CUDA_LIB_DIR / CUDNN_LIB_DIR. │
# └──────────────────────────────────────────────────────────────────────────────┘
#
# Secrets: NONE for signing — the private key stays offline (local signing). Only
# the automatic GITHUB_TOKEN is used, to create the draft release.
#
# Path A vs Path B: this workflow is the CI-draft path (Path A). The maintainer can
# instead publish a locally-built+signed release directly (Path B) — that also fires
# this workflow via the v* tag, so the `guard` job below SKIPS the build jobs when a
# PUBLISHED (non-draft) release already exists for the tag, to avoid clobbering it.
name: release
on:
push:
tags:
- 'v*'
permissions:
contents: write # create the draft GitHub Release + upload assets
jobs:
# ── Guard: don't clobber a manually-published (Path B) release ────────────────
# The maintainer can publish a locally-built, locally-SIGNED release directly
# (runbook Path B) — `gh release create` makes the v* tag, which fires this
# workflow. If we built + uploaded here, we'd re-draft that published release and
# overwrite the signed installer with an unsigned CI build. So: if a PUBLISHED
# (non-draft) release already exists for this tag, skip the build jobs entirely.
# Path A (CI builds the draft) still works: there's no release yet, or only a
# draft, so the jobs proceed.
guard:
runs-on: ubuntu-latest
outputs:
proceed: ${{ steps.check.outputs.proceed }}
steps:
- id: check
env:
GH_TOKEN: ${{ github.token }}
run: |
draft=$(gh release view "${GITHUB_REF_NAME}" -R "${GITHUB_REPOSITORY}" \
--json isDraft --jq '.isDraft' 2>/dev/null || echo "none")
if [ "$draft" = "false" ]; then
echo "Published release already exists for ${GITHUB_REF_NAME} — skipping CI build (Path B)."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
echo "No published release for ${GITHUB_REF_NAME} (state: $draft) — proceeding (Path A)."
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
# ── Create the draft release ONCE, before anything uploads to it ──────────────
# Every build job stages its assets with softprops/action-gh-release, which CREATES
# the release if absent. With four legs starting simultaneously (appimage, windows,
# macOS ×2) they race to create the same draft, and the losers can 422 or produce a
# duplicate. Serialising creation here — and only creation — makes the uploads
# idempotent; they still run fully in parallel afterwards.
draft:
needs: guard
if: needs.guard.outputs.proceed == 'true'
runs-on: ubuntu-latest
steps:
- name: Ensure the draft release exists
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if gh release view "$GITHUB_REF_NAME" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "release $GITHUB_REF_NAME already exists — leaving it as-is"
else
echo "creating draft release $GITHUB_REF_NAME"
gh release create "$GITHUB_REF_NAME" -R "$GITHUB_REPOSITORY" \
--draft --title "$GITHUB_REF_NAME" --generate-notes
fi
appimage:
needs: [guard, draft]
if: needs.guard.outputs.proceed == 'true'
runs-on: ubuntu-22.04 # oldest supported glibc → widest AppImage compatibility
steps:
- uses: actions/checkout@v4
# ── Version comes from version.h → the AppImage asset filename (NOT the git
# tag, whose format is constrained by the release process). The updater reads
# the version from the asset name, so the tag here is free-form. Log both. ───
- name: Report version
run: |
maj=$(sed -n 's/^#define PINPOINT_VERSION_MAJOR[[:space:]]*\([0-9]*\).*/\1/p' src/Core/version.h)
min=$(sed -n 's/^#define PINPOINT_VERSION_MINOR[[:space:]]*\([0-9]*\).*/\1/p' src/Core/version.h)
pfx=$(sed -n 's/^#define PINPOINT_VERSION_POSTFIX[[:space:]]*"\([^"]*\)".*/\1/p' src/Core/version.h)
echo "version.h → v${maj}.${min}${pfx} (embedded in the AppImage asset name)"
echo "git tag → ${GITHUB_REF_NAME} (free-form; not parsed by the updater)"
# ── System dependencies (mirrors BUILDING.md → Linux) ───────────────────────
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential cmake pkg-config git \
libglib2.0-dev libaravis-dev libudev-dev libopencv-dev \
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev \
libespeak-ng-dev libvulkan-dev glslc \
fuse libfuse2 zsync
- name: Install Qt 6.11
uses: jurplel/install-qt-action@v4
with:
version: '6.11.1'
modules: 'qtmultimedia qtwebsockets qtquick3d qtshadertools qtserialport qtconnectivity'
# ── AppImage tooling (linuxdeploy + Qt plugin, appimagetool, appimageupdatetool) ─
- name: Install AppImage tooling
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL -o "$HOME/.local/bin/linuxdeploy" \
https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
curl -fsSL -o "$HOME/.local/bin/linuxdeploy-plugin-qt" \
https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage
curl -fsSL -o "$HOME/.local/bin/appimagetool" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
curl -fsSL -o "$HOME/.local/bin/appimageupdatetool" \
https://github.com/AppImageCommunity/AppImageUpdate/releases/download/continuous/appimageupdatetool-x86_64.AppImage
chmod +x "$HOME/.local/bin/"*
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
# ── Build + bundle + zsync, UNSIGNED (resolves version from version.h) ──────
- name: Package AppImage (unsigned)
env:
CMAKE_PREFIX: ${{ env.QT_ROOT_DIR }}
JOBS: '2' # GitHub-hosted runners are small; keep memory in check
run: tools/package_appimage.sh --no-sign
# ── Stage as a DRAFT release. The maintainer signs locally, uploads the .sig,
# and publishes (un-drafts) per the runbook — only then is it live to users. ─
- name: Create draft release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
draft: true # NOT live until locally signed + published
prerelease: false # at publish time: non-prerelease so `latest` resolves (design §7)
generate_release_notes: true
files: |
dist/PinPointStudio-*-x86_64.AppImage
dist/PinPointStudio-*-x86_64.AppImage.zsync
# ── Windows: build the UNSIGNED -core installer (the WinSparkle enclosure) and
# stage it on the same DRAFT release. The maintainer signs locally with the offline
# EdDSA key, generates appcast-win.xml (packaging/make_appcast.ps1), uploads both,
# and publishes — only then is it live to users (docs/implementation/
# windows_release_runbook.md, design docs/design/windows_update.md). No signing
# secrets here. The hosted runner has no NVIDIA GPU, so it builds the hardware-
# agnostic -core only; the -cuda runtime is built/managed separately (design §4.4).
#
# STATUS: scaffolding — authored, not yet run. Heavy FetchContent (whisper/ViTPose/
# ORT) makes the first run slow; validate by pushing a test tag.
windows:
needs: [guard, draft]
if: needs.guard.outputs.proceed == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Read version (version.h → asset name)
shell: bash
run: |
maj=$(sed -n 's/^#define PINPOINT_VERSION_MAJOR[[:space:]]*\([0-9]*\).*/\1/p' src/Core/version.h)
min=$(sed -n 's/^#define PINPOINT_VERSION_MINOR[[:space:]]*\([0-9]*\).*/\1/p' src/Core/version.h)
pfx=$(sed -n 's/^#define PINPOINT_VERSION_POSTFIX[[:space:]]*"\([^"]*\)".*/\1/p' src/Core/version.h)
# Asset name reflects the version.h string / tag (e.g. v0.1-alpha3); the internal
# Inno AppVersion stays numeric (CMake derives it from the same version.h).
echo "PP_VERSION=v${maj}.${min}${pfx}" >> "$GITHUB_ENV"
echo "asset version → v${maj}.${min}${pfx} (tag ${GITHUB_REF_NAME})"
- name: Set up MSVC (x64)
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Install Qt 6.11
uses: jurplel/install-qt-action@v4
with:
version: '6.11.1'
modules: 'qtmultimedia qtwebsockets qtquick3d qtshadertools qtserialport qtconnectivity'
- name: Install Ninja + Inno Setup + OpenSSL
run: choco install ninja innosetup openssl --no-progress -y
# ⚠ THE PPCP TRANSPORT NEEDS OpenSSL *HEADERS*, NOT THE RUNTIME Qt ALREADY LOADS.
# cmake/PinPointOpenSSL.cmake has no Windows auto-probe at all — only a macOS Homebrew
# one — so on this runner CMake finds OpenSSL if and only if it is handed a root.
#
# This step exists because v0.1-beta1 SHIPPED WITH NO PPCP AND NO PAIRING: packaging
# configured without a root, the "PPCP transport NOT built" warning scrolled past in the
# log, the installer was published, and it had to be pulled. packaging/build_installer.ps1
# was fixed on 2026-09-03; this workflow was not, and still had no OpenSSL at all.
#
# The root is DISCOVERED, not hardcoded: Chocolatey's openssl package has moved between
# "C:\Program Files\OpenSSL" and "...\OpenSSL-Win64" across versions, and a path that is
# merely plausible is how this failed the first time. Failing here is also now safe rather
# than silent — the app refuses to configure a PP_SHIPPING_BUILD with no transport unless
# PP_ALLOW_NO_PPCP is set — so a wrong guess costs a red job, never a gutted installer.
- name: Locate OpenSSL development headers
shell: pwsh
run: |
$root = Get-ChildItem 'C:\Program Files\OpenSSL*' -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'include\openssl\ssl.h') } |
Select-Object -First 1 -ExpandProperty FullName
if (-not $root) {
Write-Host '::error::choco installed openssl but no include\openssl\ssl.h was found under C:\Program Files\OpenSSL*.'
Write-Host 'Candidates present:'
Get-ChildItem 'C:\Program Files\OpenSSL*' -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Write-Host " $($_.FullName)" }
exit 1
}
"OPENSSL_ROOT_DIR=$root" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
Write-Host "OpenSSL root: $root"
- name: Configure + build (Release, core only)
shell: cmd
run: |
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPP_SHIPPING_BUILD=ON -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" -DOPENSSL_ROOT_DIR="%OPENSSL_ROOT_DIR%" || exit /b 1
cmake --build build --target PinPointStudio || exit /b 1
- name: Package -core installer (UNSIGNED)
shell: cmd
run: |
cd build
cpack -G INNOSETUP -D CPACK_COMPONENTS_ALL=core -D CPACK_PACKAGE_FILE_NAME=PinPointStudioSetup-%PP_VERSION%-core || exit /b 1
- name: Stage on draft release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
draft: true # maintainer signs + adds appcast-win.xml + publishes
prerelease: false
files: |
build/PinPointStudioSetup-*-core.exe
# ── macOS: build the UNSIGNED .dmg (the Sparkle enclosure) and stage it on the same
# DRAFT release. The maintainer Developer ID-signs + notarizes the EXACT DMG locally,
# generates appcast-mac.xml (packaging/make_appcast_mac.sh), uploads both, and
# publishes — only then is it live to users (docs/implementation/macos_release_runbook.md,
# design docs/design/macos_update.md). No signing/notarization/EdDSA secrets here.
#
# TWO LEGS, one per architecture — they differ only in which runner they land on, so
# this is a matrix rather than a copied job. Each produces its OWN DMG and its own
# Sparkle feed; the two are never mixed (design §4.1 — each architecture's binary only
# ever sees its own feed).
#
# x86_64 → macos-15-intel arm64 → macos-15
#
# Runner choice is time-sensitive, so state the reasoning rather than just the labels:
# • macos-13 (the previous pin) was RETIRED in December 2025. It is not a valid label
# any more — leaving it here would fail to allocate a runner, not build an Intel DMG.
# • macos-15-intel is the LAST x86_64 image GitHub will offer, available until
# August 2027. Apple has dropped Intel; there is no successor. When that date lands,
# the x86_64 leg stops being buildable here and the appcast-mac.xml feed goes frozen
# rather than broken — existing Intel installs keep polling a feed that simply stops
# gaining entries.
# • macos-14 is NOT used even though it is older: it entered deprecation on 6 July 2026
# and is unsupported from 2 November 2026. macos-15 is the oldest SUPPORTED arm64.
# Re-check these against actions/runner-images before relying on them.
#
# The runner's OS also sets the shipped floor: Homebrew bottles are built for the host's
# macOS, so the DMG's real minimum is roughly the runner's version (~15.0 on both legs),
# NOT the CMAKE_OSX_DEPLOYMENT_TARGET we pin. package_macos.sh computes that floor from
# the built closure and stamps it into Info.plist, and make_appcast_mac.sh reads it back
# out of the signed bytes — so the feed cannot promise a floor the enclosure fails to meet.
# This is also why a DMG built on the maintainer's own (newer) Mac must not be shipped.
#
# STATUS: scaffolding — authored, not yet run. Heavy FetchContent (whisper/ViTPose/ORT)
# + a full Release build make the first run slow; validate by pushing a test tag.
macos:
needs: [guard, draft]
if: needs.guard.outputs.proceed == 'true'
strategy:
# Load-bearing: a flaky Intel leg must NOT cancel a good arm64 DMG (and vice versa).
# Either DMG is independently useful, and the publish gate below is what insists on
# having both before anything goes live.
fail-fast: false
matrix:
include:
- arch: x86_64
runner: macos-15-intel
- arch: arm64
runner: macos-15
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Read version (version.h → asset name)
run: |
maj=$(sed -n 's/^#define PINPOINT_VERSION_MAJOR[[:space:]]*\([0-9]*\).*/\1/p' src/Core/version.h)
min=$(sed -n 's/^#define PINPOINT_VERSION_MINOR[[:space:]]*\([0-9]*\).*/\1/p' src/Core/version.h)
pfx=$(sed -n 's/^#define PINPOINT_VERSION_POSTFIX[[:space:]]*"\([^"]*\)".*/\1/p' src/Core/version.h)
echo "asset version → v${maj}.${min}${pfx} (tag ${GITHUB_REF_NAME}, ${{ matrix.arch }})"
# Fail EARLY if the runner is not the architecture this leg is meant to build. The
# packaging script derives the DMG name from the binary, so a wrong runner would
# otherwise produce a correctly-named DMG for the wrong leg and upload it happily.
- name: Assert runner architecture
run: |
set -euo pipefail
got="$(uname -m)"
echo "runner ${{ matrix.runner }} reports: $got"
[ "$got" = "${{ matrix.arch }}" ] || {
echo "::error::expected ${{ matrix.arch }} but runner is $got — the ${{ matrix.runner }} label has changed architecture"
exit 1
}
# CMake probes Homebrew for these; absent on a fresh runner.
# opencv@4, NOT opencv: the plain formula is 5.x now, which this project does not
# build against (CMakeLists rejects it explicitly with the same rationale).
- name: Install Homebrew deps (OpenCV 4 / FFmpeg / Aravis)
run: brew install opencv@4 ffmpeg aravis
- name: Install Qt 6.11
uses: jurplel/install-qt-action@v4
with:
version: '6.11.1'
modules: 'qtmultimedia qtwebsockets qtquick3d qtshadertools qtserialport qtconnectivity'
# package_macos.sh builds Release (PINPOINT_INSTALLED=ON), runs macdeployqt,
# relocates the Homebrew closure, VERIFIES self-containment and that every @rpath
# dependency resolves (fails the job if not), derives the DMG name from the built
# binary, and cross-checks the baked Sparkle feed against that arch.
# --no-sign: no cert in CI, by design.
- name: Build + package UNSIGNED DMG
env:
CMAKE_PREFIX: ${{ env.QT_ROOT_DIR }}
JOBS: '3' # hosted macOS runners are small
run: tools/package_macos.sh --no-sign
# The DMG name comes from the BUILT BINARY, so a file carrying this leg's arch can
# only exist if the binary really was that arch. Insisting on exactly one also
# catches a dist/ left dirty by a previous step.
- name: Assert exactly one ${{ matrix.arch }} DMG was produced
run: |
set -euo pipefail
shopt -s nullglob
dmgs=(dist/PinPointStudio-*-${{ matrix.arch }}.dmg)
if [ "${#dmgs[@]}" -ne 1 ]; then
echo "::error::expected exactly one ${{ matrix.arch }} DMG, found ${#dmgs[@]}"
ls -la dist/ 2>/dev/null || true
exit 1
fi
echo "OK: ${dmgs[0]}"
- name: Stage on draft release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
draft: true # maintainer signs + notarizes + adds the appcasts + publishes
prerelease: false
files: |
dist/PinPointStudio-*-${{ matrix.arch }}.dmg
# ── Both macOS DMGs must be present before the draft is fit to publish ─────────
# `fail-fast: false` means one leg can fail while the other succeeds, and a draft
# holding a single DMG looks perfectly healthy in the GitHub UI. Publishing it would
# leave one architecture's feed pointing at a release with no enclosure for it — and
# because the maintainer signs and publishes by hand later, this is the only automated
# place that can notice. It does NOT publish anything; it just refuses to go green,
# so a half-built draft is visibly not ready.
#
# `if: always()` is required — without it this is skipped (not failed) the moment a
# matrix leg fails, which is exactly the case it exists to catch.
macos-assets-complete:
needs: [guard, macos]
if: always() && needs.guard.outputs.proceed == 'true'
runs-on: ubuntu-latest
steps:
- name: Require both an x86_64 and an arm64 DMG on the draft
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
assets="$(gh release view "$GITHUB_REF_NAME" -R "$GITHUB_REPOSITORY" \
--json assets --jq '.assets[].name' 2>/dev/null || true)"
echo "assets on ${GITHUB_REF_NAME}:"
echo "${assets:- (none)}"
missing=0
for arch in x86_64 arm64; do
if ! echo "$assets" | grep -q -- "-${arch}\.dmg$"; then
echo "::error::no ${arch} DMG on the draft release — do NOT publish; that architecture would have a feed with no enclosure"
missing=1
fi
done
[ "$missing" -eq 0 ] || exit 1
echo "both macOS DMGs present — draft is ready for local signing + notarization"