Repository navigation
369 lines (335 loc) · 16.1 KB
/
Copy pathrelease.yml
File metadata and controls
369 lines (335 loc) · 16.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
name: Release
# Builds standalone Track2Data binaries for Windows/macOS/Linux (issues
# #42-47) and, on a real version-tag push, publishes them to a GitHub
# Release with SHA-256 sums. workflow_dispatch lets the whole build +
# determinism matrix be validated on demand without cutting a release --
# the publish job is gated to actual `v*` tag pushes (see its `if:`).
on:
push:
tags: ["v*"]
workflow_dispatch:
# This repository's default GITHUB_TOKEN permission is read-only, so the
# publish job has to ask for write access explicitly -- without it,
# `gh release create` fails with "HTTP 403: Resource not accessible by
# integration". Declared here rather than by flipping the repository-wide
# default to read/write, which would hand every job in every workflow more
# access than it needs. Build jobs only ever read.
permissions:
contents: read
jobs:
build:
name: Build (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-2022, macos-26, ubuntu-22.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Install Qt runtime libraries (Linux only)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libgl1
- name: Install package + build deps
run: pip install -e ".[ui,build,export,zones,video]"
- name: Build with PyInstaller
run: pyinstaller packaging/track2data.spec --noconfirm
- name: Smoke-test the built executable
run: python packaging/smoke_test.py
# ── code signing ────────────────────────────────────────────────────
#
# Every signing step below is opt-in: it runs only when the relevant
# secrets are configured, and the whole release still builds and
# publishes unsigned when they aren't. That is the state Track2Data
# ships in today -- see docs/CODE_SIGNING.md for why (the free OSS
# signing route requires an already-published release, so the first
# release is necessarily unsigned) and for how to obtain and install
# the credentials.
#
# Order matters and is not arbitrary:
# * the .app is signed BEFORE the .dmg wraps it, and the .exe
# BEFORE Inno Setup wraps it -- signing afterwards would leave
# the wrapped payload unsigned;
# * notarisation staples a ticket INTO the .dmg, so it must run
# after the .dmg exists;
# * SHA-256 sums are computed last, because signing changes the
# bytes and a checksum taken earlier would not match what a user
# downloads.
- name: Detect available signing credentials
id: signing
shell: bash
# Secrets reach the script via env only, never interpolated into its
# body, so nothing can echo them into the log. The checker prints
# names, never values, and fails on a partially configured platform.
env:
MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: python packaging/check_signing_readiness.py --github-output "$GITHUB_OUTPUT"
- name: Sign the macOS .app bundle
if: runner.os == 'macOS' && steps.signing.outputs.macos == 'true'
env:
CERT_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
CERT_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
set -euo pipefail
# A throwaway keychain, unlocked only for this job: importing into
# the login keychain on a shared runner would leave the key
# readable by later steps.
KEYCHAIN_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
echo "$CERT_P12" | base64 --decode > /tmp/certificate.p12
security import /tmp/certificate.p12 -k build.keychain \
-P "$CERT_PASSWORD" -T /usr/bin/codesign
rm -f /tmp/certificate.p12
security set-key-partition-list -S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain > /dev/null
# --options runtime (hardened runtime) is mandatory for
# notarisation. PyInstaller bundles unsigned .so/.dylib files that
# the default policy rejects, hence the entitlements file.
codesign --deep --force --verbose \
--options runtime \
--entitlements packaging/entitlements.plist \
--sign "$SIGNING_IDENTITY" \
dist/Track2Data.app
codesign --verify --deep --strict --verbose=2 dist/Track2Data.app
- name: Sign the Windows executable
if: runner.os == 'Windows' && steps.signing.outputs.windows == 'true'
shell: pwsh
env:
CERT_PFX: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
run: |
./packaging/sign_windows.ps1 -Path "dist\Track2Data.exe"
# ── Windows: wrap the .exe in an Inno Setup installer (issue #44) ──
- name: Build Windows installer
if: runner.os == 'Windows'
shell: pwsh
# Inno Setup can't read track2data/_version.py itself, so pass the
# version in explicitly -- without this the installer falls back to
# its "0.0.0-local" placeholder. See packaging/inno_setup.iss.
run: |
$version = python -c "import re,pathlib; print(re.search(r'__version__\s*=\s*[\x27\x22]([^\x27\x22]+)', pathlib.Path('track2data/_version.py').read_text()).group(1))"
Write-Host "Building installer for version $version"
iscc /DMyAppVersion=$version packaging\inno_setup.iss
# ── macOS: pack the .app bundle into a .dmg (issue #45) ────────────
- name: Build macOS .dmg
if: runner.os == 'macOS'
run: |
hdiutil create -volname "Track2Data" -srcfolder dist/Track2Data.app \
-ov -format UDZO dist/Track2Data.dmg
# ── Linux: pack the onefile build into an .AppImage (issue #46) ────
- name: Build Linux .AppImage
if: runner.os == 'Linux'
run: |
chmod +x packaging/build_appimage.sh
packaging/build_appimage.sh
- name: Remove non-artifact build output
shell: bash
run: |
# Keep only the final, user-facing artifacts -- not the raw
# PyInstaller onefile exe once an installer/bundle wraps it, and
# not the intermediate .app/.AppDir directories.
if [ "${{ runner.os }}" = "Windows" ]; then
find dist -maxdepth 1 -type f ! -name 'Track2Data-setup.exe' -delete
elif [ "${{ runner.os }}" = "macOS" ]; then
find dist -maxdepth 1 ! -name 'dist' ! -name 'Track2Data.dmg' -exec rm -rf {} +
else
find dist -maxdepth 1 -type f ! -name 'Track2Data-x86_64.AppImage' -delete
rm -rf dist/Track2Data.AppDir
fi
# ── signing, part 2: the packaged artifacts ─────────────────────────
# These run after the cleanup step above, so nothing they produce
# (notably the .asc signature) gets swept away by it.
- name: Sign the Windows installer
if: runner.os == 'Windows' && steps.signing.outputs.windows == 'true'
shell: pwsh
env:
CERT_PFX: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
run: |
./packaging/sign_windows.ps1 -Path "dist\Track2Data-setup.exe"
- name: Notarise and staple the macOS .dmg
if: runner.os == 'macOS' && steps.signing.outputs.macos == 'true'
env:
NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
run: |
set -euo pipefail
# --wait blocks until Apple's service returns a verdict; without
# it the job would "succeed" while the submission was still
# queued, and stapling below would then fail confusingly.
xcrun notarytool submit dist/Track2Data.dmg \
--apple-id "$NOTARY_APPLE_ID" \
--password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" \
--wait
# Staple the ticket into the .dmg so Gatekeeper accepts it
# offline, rather than only on a machine that can reach Apple.
xcrun stapler staple dist/Track2Data.dmg
xcrun stapler validate dist/Track2Data.dmg
- name: GPG-sign the Linux .AppImage
if: runner.os == 'Linux' && steps.signing.outputs.gpg == 'true'
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
set -euo pipefail
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
# --detach-sign, so the .AppImage itself stays byte-identical to
# the one the SHA-256 sum below covers; the signature travels as
# a sibling .asc file.
echo "$GPG_PASSPHRASE" | gpg --batch --yes --pinentry-mode loopback \
--passphrase-fd 0 --armor --detach-sign \
dist/Track2Data-x86_64.AppImage
gpg --verify dist/Track2Data-x86_64.AppImage.asc \
dist/Track2Data-x86_64.AppImage
- name: Compute SHA-256 sums
# Python's hashlib, not a shell checksum tool: sha256sum is
# Linux/GNU-only, and shasum -- the usual fallback -- isn't on
# windows-2022's Git Bash either (confirmed by a real CI failure
# here). Python is guaranteed present in every job via
# actions/setup-python above.
run: python packaging/compute_sha256.py dist "${{ runner.os }}-SHA256SUMS.txt"
- uses: actions/upload-artifact@v6
with:
name: track2data-${{ runner.os }}
path: |
dist/*
${{ runner.os }}-SHA256SUMS.txt
if-no-files-found: error
determinism:
name: Determinism gate
runs-on: ubuntu-22.04
env:
QT_QPA_PLATFORM: offscreen
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Install Qt runtime libraries
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libgl1
- name: Install package + dev deps
run: pip install -e ".[dev]"
- name: Run the same project twice and byte-diff the output
run: python packaging/check_determinism.py
pypi:
name: Publish engine to PyPI
needs: [build, determinism]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
# The desktop binaries serve someone who wants to click through a
# wizard. A wheel serves the headless and HPC cases a binary cannot:
# `pip install track2data` in a batch script, on a cluster node, or
# inside someone else's reproducible environment. The engine is pure
# Python with modest dependencies, so this costs one job.
environment: pypi
permissions:
# Trusted publishing (OIDC). No API token is stored anywhere -- PyPI
# verifies the workflow identity directly. Requires a one-time
# publisher configured at pypi.org for this repo + workflow +
# environment; see docs/dev/RELEASING.md.
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Build sdist and wheel
run: |
pip install build
python -m build
- name: Check the built metadata renders
# A wheel that uploads but shows a broken description on PyPI is
# not fixable after the fact -- a version can never be re-uploaded.
run: |
pip install twine
twine check dist/*
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
publish:
name: Publish GitHub Release
needs: [build, determinism]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
# Creating a release writes to the repository; this is the only job
# that needs more than the workflow-level read above.
permissions:
contents: write
steps:
- uses: actions/download-artifact@v7
with:
path: artifacts
- name: Collect SHA-256 sums into one file
run: |
cat artifacts/*/*-SHA256SUMS.txt > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# actions/upload-artifact preserves the source path, so each
# binary landed under <artifact-name>/dist/... on download, not
# at the artifact's top level -- only the *-SHA256SUMS.txt files
# were uploaded from the workspace root and sit untended.
run: |
set -euo pipefail
assets=(
SHA256SUMS.txt
artifacts/track2data-Windows/dist/Track2Data-setup.exe
artifacts/track2data-macOS/dist/Track2Data.dmg
artifacts/track2data-Linux/dist/Track2Data-x86_64.AppImage
)
# The GPG signature only exists when GPG_PRIVATE_KEY is
# configured, so attach it conditionally rather than failing the
# release when the build is unsigned.
asc="artifacts/track2data-Linux/dist/Track2Data-x86_64.AppImage.asc"
if [ -f "$asc" ]; then
assets+=("$asc")
notes="Signed binaries. Verify with the attached SHA-256 sums; the Linux .AppImage additionally has a detached GPG signature (.asc). See docs/CODE_SIGNING.md."
else
notes="Unsigned binaries -- see the README for the trust path on each OS, and verify your download against the attached SHA-256 sums."
fi
# --repo is required: this job only downloads artifacts and never
# checks the repository out, so gh has no git remote to infer the
# target from and fails with "not a git repository". Adding a
# checkout would also work but pulls the whole tree just to name
# a repo that github.repository already holds.
# A release for this tag can already exist: publishing one from
# the web UI is how a tag gets created when the tag cannot be
# pushed from a clone. `gh release create` then fails with
# "release already exists" after every build has passed, and the
# binaries never get attached. Attach to it instead, keeping the
# notes it already has.
if gh release view "${{ github.ref_name }}" \
--repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release ${{ github.ref_name }} already exists; attaching the assets to it."
gh release upload "${{ github.ref_name }}" \
--repo "${{ github.repository }}" \
--clobber \
"${assets[@]}"
else
gh release create "${{ github.ref_name }}" \
--repo "${{ github.repository }}" \
--title "Track2Data ${{ github.ref_name }}" \
--notes "$notes" \
"${assets[@]}"
fi