{{ message }}
Repository navigation
463 lines (428 loc) · 21.5 KB
/
Copy pathrelease.yml
File metadata and controls
463 lines (428 loc) · 21.5 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
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
name: Release
# Builds iLEAPP on each platform and architecture with packaging/build.py and attaches the
# results to a GitHub Release. Pushing a tag like v2026.5.0 is the trigger; the release is
# created as a DRAFT, so the assets can be checked before anyone can download them.
#
# The build and smoke steps are the ones test_builds.yml runs weekly on main, in the same
# order. Keep the two in step: this workflow only runs when a tag is pushed, so it is the
# weekly job that keeps the recipe honest between releases. workflow_dispatch builds every
# platform and uploads the assets without creating a release, which is the way to
# rehearse a release without publishing one.
#
# macOS is signed with a Developer ID and notarised when the MACOS_* secrets are set: the
# .app is codesigned between phase 1 and phase 2, smoke-tested again as signed, then the
# .dmg is signed, notarised and stapled. A tag refuses to publish an unsigned Mac build;
# a workflow_dispatch rehearsal without the secrets still builds, unsigned.
#
# Windows ships one download, a portable zip holding the program as a single ileapp.exe.
# No installer: it would be a second file to sign, as well as the folder build inside it.
# The executable is signed by SignPath when SIGNPATH_API_TOKEN is set, then verified and
# smoke-tested again. SignPath's test certificate is for rehearsals, so a tag signs only
# under the release-signing policy and is otherwise built unsigned, as before. The
# artifact configuration SignPath applies is kept in packaging/signpath/.
on:
push:
tags:
- 'v*'
workflow_dispatch:
# One release at a time. A second tag pushed while this is running waits rather than
# racing it to the same release page.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
# `os` is the runner and `name` what the job is called, as in test_builds.yml.
# `platform` goes into the asset names; the architecture there comes from the
# runner itself, never from these labels.
include:
- os: windows-latest
name: windows-x64
platform: windows
- os: windows-11-arm
name: windows-11-arm
platform: windows
- os: macos-latest
name: macos-apple_silicon
platform: macos
# Intel Macs. macos-latest is Apple silicon, so without this leg an Intel Mac
# gets nothing. GitHub drops x86_64 macOS entirely after macos-15-intel retires
# in 2027, at which point this leg has to go.
- os: macos-15-intel
name: macos-15-intel
platform: macos
# 22.04 for its glibc 2.35; see test_builds.yml.
- os: ubuntu-22.04
name: ubuntu-22.04-x64
platform: linux
- os: ubuntu-22.04-arm
name: ubuntu-22.04-arm
platform: linux
name: build (${{ matrix.name }})
runs-on: ${{ matrix.os }}
# Room for a SignPath request on Windows to wait for an approval.
timeout-minutes: 90
# SignPath downloads what a Windows leg uploaded for signing, from this run, with the
# job's own token, and needs actions: read for that.
permissions:
contents: read
actions: read
# A step's `if` cannot read `secrets`, so whether a platform can be signed is decided
# here.
env:
MACOS_SIGN: ${{ secrets.MACOS_CERT_P12 != '' && 'yes' || '' }}
SIGNPATH_TOKEN_SET: ${{ secrets.SIGNPATH_API_TOKEN != '' && 'yes' || '' }}
SIGNPATH_POLICY: ${{ vars.SIGNPATH_SIGNING_POLICY }}
steps:
# A tag must not ship an unsigned Mac build by accident: Gatekeeper refuses its first
# launch, and an examiner is left clearing a warning on a forensic tool. Checked
# first, so a tag that cannot be signed stops before a build nobody can publish. A
# rehearsal (workflow_dispatch) without the secrets still builds, unsigned.
- name: macOS, refuse an unsigned release
if: runner.os == 'macOS' && github.ref_type == 'tag' && env.MACOS_SIGN != 'yes'
shell: bash
run: |
echo "::error::MACOS_CERT_P12 is not set; a tagged release must be signed and notarised on macOS"
exit 1
# Decided once, for every Windows step after it. A binary signed with SignPath's test
# certificate is trusted by no Windows, so a tag is never signed with it: under any
# policy but release-signing a tag builds unsigned, as releases did before signing
# was wired, and says so. A rehearsal signs under whichever policy is set.
- name: Windows, decide whether to sign
if: runner.os == 'Windows'
shell: bash
run: |
if [ "$SIGNPATH_TOKEN_SET" != "yes" ]; then
echo "::notice::SIGNPATH_API_TOKEN is not set; the Windows build is not signed"
elif [ "$GITHUB_REF_TYPE" = "tag" ] && [ "$SIGNPATH_POLICY" != "release-signing" ]; then
echo "::warning::signing policy '$SIGNPATH_POLICY' is not release-signing, and a tag is never signed with a test certificate; this release is unsigned on Windows"
else
echo "WINDOWS_SIGN=yes" >> "$GITHUB_ENV"
fi
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Provide OpenSSL for the cryptography source build (Windows arm64 only)
# See test_builds.yml: cryptography publishes no win_arm64 wheels.
if: matrix.os == 'windows-11-arm'
shell: pwsh
run: |
$vcpkg = 'vcpkg'
if (-not (Get-Command vcpkg -ErrorAction SilentlyContinue)) {
git clone --depth 1 https://github.com/microsoft/vcpkg C:\vcpkg-local
& C:\vcpkg-local\bootstrap-vcpkg.bat
$vcpkg = 'C:\vcpkg-local\vcpkg.exe'
}
& $vcpkg install openssl:arm64-windows-static-md
if ($LASTEXITCODE -ne 0) { exit 1 }
$roots = @('C:\vcpkg-local', $env:VCPKG_INSTALLATION_ROOT) | Where-Object { $_ }
$installed = $roots | ForEach-Object { Join-Path $_ 'installed\arm64-windows-static-md' } | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $installed) { Write-Error 'vcpkg OpenSSL install dir not found'; exit 1 }
"OPENSSL_DIR=$installed" | Out-File -FilePath $env:GITHUB_ENV -Append
"OPENSSL_STATIC=1" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Linux, the window's toolkit and a display for its self-check
if: runner.os == 'Linux'
run: |
sudo apt-get update -q
sudo apt-get install -y -q libtcl8.6 libtk8.6 xvfb
python -c "import tkinter" || { echo "tkinter unavailable in this Python"; exit 1; }
# The tag names the version the assets will be published under, and
# scripts/version_info.py names the version the app reports and writes into every
# report. A release where those disagree ships a binary that tells the examiner one
# thing while the download page says another, so the mismatch stops the build here
# rather than being found in the report of a case.
- name: Resolve the version, and check it against the tag
shell: bash
run: |
VERSION="$(python -c "import sys; sys.path.insert(0, 'packaging'); import build; print(build.read_version())")"
echo "scripts/version_info.py leapp_version = $VERSION"
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
echo "tag = $GITHUB_REF_NAME"
if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match leapp_version $VERSION (expected tag v$VERSION)"
exit 1
fi
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Fetch the Unified Log parser (digest-verified)
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install certifi
python admin/scripts/fetch_unifiedlog_iterator.py
- name: Check the third-party notices cover the fetched parser
shell: bash
run: |
if [ "$RUNNER_OS" = "Windows" ]; then PARSER=bin/unifiedlog_iterator.exe; else PARSER=bin/unifiedlog_iterator; fi
python admin/scripts/make_unifiedlog_notices.py --verify "$PARSER"
# macOS and Linux ship the one-folder build, inside the .app and the AppImage.
- name: Phase 1, the ileapp executable (macOS, Linux)
if: runner.os != 'Windows'
shell: bash
run: python packaging/build.py exe
# The same checks test_builds.yml runs. An asset that fails any of them never
# reaches the release.
- name: Smoke test the build (macOS, Linux)
if: runner.os != 'Windows'
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
xvfb-run -a python packaging/build.py smoke
else
python packaging/build.py smoke
fi
# Windows ships the program as a single file, with no _internal folder beside it for a
# user to wonder about. It unpacks itself to %TEMP% on every start, so it does not run
# where AppLocker or WDAC forbid executing from %TEMP%; the footer sends those users
# to the source.
- name: Phase 1, the ileapp executable (Windows, a single file)
if: runner.os == 'Windows'
shell: bash
run: python packaging/build.py exe --onefile
- name: Smoke test the build (Windows)
if: runner.os == 'Windows'
shell: bash
run: python packaging/build.py smoke --onefile
# Staged under the name packaging/signpath/portable.xml expects.
- name: Windows, stage the executable for signing
if: runner.os == 'Windows' && env.WINDOWS_SIGN == 'yes'
shell: bash
run: |
mkdir -p signing/unsigned-portable
cp dist/ileapp.exe signing/unsigned-portable/
# SignPath signs only what a workflow stored as an artifact of its run: that is how it
# checks the binary was built here, from this repository, on GitHub's runners.
- name: Windows, upload the executable for SignPath
id: unsigned-portable
if: runner.os == 'Windows' && env.WINDOWS_SIGN == 'yes'
uses: actions/upload-artifact@v7
with:
name: unsigned-portable-${{ matrix.name }}
path: signing/unsigned-portable/
retention-days: 1
if-no-files-found: error
# Pinned to a commit rather than a tag: this step holds the signing token, and a tag
# can be moved to other code.
- name: Windows, sign the executable (SignPath)
if: runner.os == 'Windows' && env.WINDOWS_SIGN == 'yes'
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY }}
artifact-configuration-slug: portable
github-artifact-id: ${{ steps.unsigned-portable.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 1200
output-artifact-directory: signing/signed-portable
- name: Windows, put the signed executable in place
if: runner.os == 'Windows' && env.WINDOWS_SIGN == 'yes'
shell: bash
run: cp signing/signed-portable/ileapp.exe dist/ileapp.exe
# A test-signed binary chains to SignPath's test certificate, which no Windows
# trusts, and `verify` would fail it. This runner, and nothing else, is told to trust
# it, so `verify` still checks a whole chain during rehearsals.
# SIGNPATH_TEST_CERT_B64 is the root of that chain (the test certificate itself
# when it is self-signed), public, as a base64-encoded .cer.
- name: Windows, trust the SignPath test certificate (test-signing only)
if: runner.os == 'Windows' && env.WINDOWS_SIGN == 'yes' && env.SIGNPATH_POLICY == 'test-signing'
shell: pwsh
env:
TEST_CERT: ${{ vars.SIGNPATH_TEST_CERT_B64 }}
run: |
if (-not $env:TEST_CERT) {
Write-Error 'SIGNPATH_TEST_CERT_B64 is not set; the test-signed executable cannot be verified'
exit 1
}
$cer = Join-Path $env:RUNNER_TEMP 'signpath-test.cer'
[IO.File]::WriteAllBytes($cer, [Convert]::FromBase64String($env:TEST_CERT))
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\Root |
Format-List Subject, Thumbprint, NotAfter
# Signing appends to the executable, and the single file finds its own archive by
# reading back from its end, so it is run again as signed before anything ships.
- name: Windows, verify and smoke test the signed executable
if: runner.os == 'Windows' && env.WINDOWS_SIGN == 'yes'
shell: bash
env:
SUBJECT: ${{ vars.SIGNPATH_CERT_SUBJECT }}
run: |
args=()
if [ -n "$SUBJECT" ]; then args=(--subject "$SUBJECT"); fi
python packaging/build.py verify dist/ileapp.exe "${args[@]}"
python packaging/build.py smoke --onefile
# A throwaway keychain holding only the Developer ID certificate, deleted by the
# last macOS step whatever happens.
- name: macOS, import the Developer ID certificate
if: runner.os == 'macOS' && env.MACOS_SIGN == 'yes'
shell: bash
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PW="$(openssl rand -base64 24)"
echo "$MACOS_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PW" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
rm -f "$RUNNER_TEMP/cert.p12"
security find-identity -v -p codesigning "$KEYCHAIN"
# Between the phases: PyInstaller signs the bundle ad hoc while it builds, and the
# disk image must carry the Developer ID signature. Inside out, every Mach-O in the
# bundle first, the bundled Unified Log parser included, then the bundle itself with
# the entitlements the hardened runtime needs; --deep is not used to sign, as Apple
# advises. find does not follow symlinks, so the Frameworks/Resources cross-links of
# a PyInstaller bundle are signed once.
- name: macOS, sign the app
if: runner.os == 'macOS' && env.MACOS_SIGN == 'yes'
shell: bash
env:
ID: ${{ secrets.MACOS_SIGN_IDENTITY }}
TEAM: ${{ secrets.MACOS_TEAM_ID }}
run: |
APP=dist/iLEAPP.app
find "$APP/Contents" -type f ! -path "$APP/Contents/MacOS/ileapp" -print0 |
while IFS= read -r -d '' f; do
if file -b "$f" | grep -q 'Mach-O'; then
codesign --force --timestamp --options runtime --sign "$ID" "$f"
fi
done
codesign --force --timestamp --options runtime \
--entitlements packaging/entitlements.plist --sign "$ID" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
python packaging/build.py verify "$APP" --subject "$TEAM"
# The hardened runtime is what breaks a frozen app, and the smoke test above ran the
# bundle before it had it. On macOS `smoke` runs the bundle's own executable.
- name: macOS, smoke test the signed app
if: runner.os == 'macOS' && env.MACOS_SIGN == 'yes'
shell: bash
run: python packaging/build.py smoke
- name: Phase 2, the disk image (macOS) or the AppImage (Linux)
if: runner.os != 'Windows'
shell: bash
run: python packaging/build.py installer
# Notarising the .dmg covers the signed .app inside it, and the stapled ticket lets
# Gatekeeper accept it offline. A rejection prints Apple's log, which names the file.
- name: macOS, sign, notarise and staple the disk image
if: runner.os == 'macOS' && env.MACOS_SIGN == 'yes'
shell: bash
env:
ID: ${{ secrets.MACOS_SIGN_IDENTITY }}
NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
run: |
DMG="dist/iLEAPP-$VERSION.dmg"
codesign --force --timestamp --sign "$ID" "$DMG"
printf '%s\n' "$NOTARY_KEY" > "$RUNNER_TEMP/notary.p8"
xcrun notarytool submit "$DMG" --key "$RUNNER_TEMP/notary.p8" \
--key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" \
--wait --output-format json | tee notary.json
SUB_ID="$(python -c 'import json; print(json.load(open("notary.json"))["id"])')"
STATUS="$(python -c 'import json; print(json.load(open("notary.json"))["status"])')"
if [ "$STATUS" != "Accepted" ]; then
xcrun notarytool log "$SUB_ID" --key "$RUNNER_TEMP/notary.p8" \
--key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER"
echo "::error::notarisation $STATUS"
exit 1
fi
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG"
- name: macOS, remove the signing keychain and key
if: always() && runner.os == 'macOS'
shell: bash
run: |
security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/notary.p8" "$RUNNER_TEMP/cert.p12"
# Release assets are single files: a disk image on macOS, the AppImage on Linux, and
# on Windows a portable zip holding the single-file ileapp.exe, under the name the
# docs and the tools that run it use. The names
# carry the platform and the architecture, because a reader cannot tell from
# "iLEAPP-2026.5.0.dmg" whether it runs on their Mac. They are gathered
# in release-assets/, not assets/ as in GLEAPP: iLEAPP's assets/ holds the window's
# images, and a rehearsal published them with the release and listed them in
# SHA256SUMS.txt.
- name: Name the assets
shell: bash
run: |
mkdir -p release-assets
ARCH="$(echo "$RUNNER_ARCH" | tr '[:upper:]' '[:lower:]')"
STEM="iLEAPP-$VERSION-${{ matrix.platform }}-$ARCH"
case "$RUNNER_OS" in
Windows)
powershell -NoProfile -Command "Compress-Archive -Path 'dist/ileapp.exe' -DestinationPath 'release-assets/$STEM-portable.zip'"
;;
macOS)
mv "dist/iLEAPP-$VERSION.dmg" "release-assets/$STEM.dmg"
;;
Linux)
mv "dist/iLEAPP-$VERSION.AppImage" "release-assets/$STEM.AppImage"
;;
esac
ls -l release-assets/
- name: Upload the assets
uses: actions/upload-artifact@v7
with:
name: assets-${{ matrix.name }}
path: release-assets/*
if-no-files-found: error
release:
needs: build
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Collect every platform's assets
uses: actions/download-artifact@v8
with:
path: release-assets
pattern: assets-*
merge-multiple: true
# An examiner who downloads a forensic tool should be able to check that what they
# got is what was built. The file is generated here, over the assets as they will be
# attached, rather than per platform, so one file covers them all.
- name: Checksums
shell: bash
run: |
cd release-assets
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
# A draft, so the assets and the generated notes can be read before the release is
# visible. Publishing it is a deliberate click, not a consequence of pushing a tag.
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
run: |
# Which file is for which platform, what the first launch does, and how to check
# a download against SHA256SUMS.txt live in .github/release-footer.md, so editing
# them needs no workflow change. gh's --generate-notes replaces notes rather than
# appending to them, so the changelog is generated through the API and the two
# are joined here.
gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="$GITHUB_REF_NAME" --jq .body > notes.md
cat .github/release-footer.md >> notes.md
test -s notes.md || { echo "::error::composed release notes are empty"; exit 1; }
gh release create "$GITHUB_REF_NAME" \
--draft \
--title "iLEAPP $GITHUB_REF_NAME" \
--notes-file notes.md \
release-assets/*
echo "Draft release created. Review the assets, then publish it."
You can’t perform that action at this time.
