Repository navigation
684 lines (625 loc) · 32.6 KB
/
Copy pathrelease.yml
File metadata and controls
684 lines (625 loc) · 32.6 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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
# Release: build and package the Tauri desktop app on all three platforms
# (per-arch where the toolchain allows), then publish a GitHub release with
# CHANGES.md-derived notes on a v* tag push.
#
# Bundle targets (dmg, app, deb, appimage, nsis, msi) are declared in
# apps/desktop/src-tauri/tauri.conf.json. This workflow drives `tauri build`
# via tauri-apps/tauri-action; target configuration lives in tauri.conf.json.
#
# Signing secrets expected in the repository / environment:
# macOS (tauri-action variable names):
# APPLE_CERTIFICATE - base64-encoded .p12 signing certificate
# APPLE_CERTIFICATE_PASSWORD - password for the .p12
# APPLE_SIGNING_IDENTITY - e.g. "Developer ID Application: Lorem Dev (TEAMID)"
# APPLE_ID - Apple account email (notarization)
# APPLE_PASSWORD - app-specific password (notarization)
# APPLE_TEAM_ID - Apple Developer team id (notarization)
# Windows (Authenticode, consumed by the Tauri bundler / import step):
# WINDOWS_CERTIFICATE - base64-encoded .pfx
# WINDOWS_CERTIFICATE_PASSWORD - password for the .pfx
#
# When a signing secret is absent GitHub passes an empty string; tauri-action
# then builds unsigned rather than failing, so the workflow stays green-able.
name: Release
on:
push:
tags:
- 'v*'
# Nothing by default: this workflow signs and publishes release artifacts, so
# a job that has not stated what it needs must not inherit a token that can
# write to the repository. Each job below grants itself the minimum, and
# `publish` is the only one that can write anything.
permissions: {}
jobs:
# ---------------------------------------------------------------------------
# Guard: the pushed tag must match every package.json, tauri.conf.json, and
# Cargo workspace version. Fails the whole release early on a mismatch.
# ---------------------------------------------------------------------------
verify-version:
name: Verify tag provenance and version
runs-on: ubuntu-latest
# Reads the tag, the branch refs, and the version manifests. Nothing else.
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# check-tag-branch.mjs asks whether the tag is reachable from main or
# develop, which needs the full history and those branch refs -- a
# shallow, tag-only checkout cannot answer it.
fetch-depth: 0
- name: Set up Node.js 22
uses: actions/setup-node@v5
with:
node-version: '24'
# This job only runs node scripts/check-version.mjs and never installs
# deps. Disable setup-node's package-manager cache: it otherwise
# auto-detects the pnpm packageManager field and fails locating pnpm
# before corepack is enabled.
package-manager-cache: false
# Cheapest possible gate, and it runs before anything is built: a final
# release tag must be on main, a release candidate on develop.
- name: Check the tag was cut from the right branch
run: |
git fetch --no-tags origin main develop
node scripts/check-tag-branch.mjs
- name: Check the tag matches every package, tauri, and crate version
run: node scripts/check-version.mjs
# ---------------------------------------------------------------------------
# Build: one matrix leg per (os, target), covering x64 and arm64 on all three
# platforms. macOS and Windows reach their second arch by cross-compiling on
# the x64/arm64 runner they already use; Linux gets a native arm64 runner,
# because Tauri links the system WebKitGTK stack and cross-compiling it would
# mean carrying an arm64 sysroot.
# ---------------------------------------------------------------------------
build:
name: Build ${{ matrix.target }}
needs: verify-version
runs-on: ${{ matrix.os }}
# Checks out and bundles; it never touches the release. Uploading the build
# artifacts goes through the runner's own artifact token, not this one, so
# read access is genuinely all this job needs even though it is the job
# holding the signing secrets.
permissions:
contents: read
# A full leg takes 6 to 12 minutes; a hang (as on v0.3.0-rc.3, where apt sat
# on the dpkg lock for 19 minutes) should end the job, not the six-hour
# default.
timeout-minutes: 45
env:
# Sign only when a certificate secret is actually configured; GitHub
# passes "" for a missing secret. These flags gate the signing steps so
# the build stays green-able and simply produces unsigned artifacts.
HAS_WIN_CERT: ${{ secrets.WINDOWS_CERTIFICATE != '' }}
HAS_APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE != '' }}
strategy:
fail-fast: false
matrix:
include:
# `musl_target` is the extra, statically linked CLI-only target this
# leg also builds. It is empty everywhere but Linux: the desktop
# bundle links the system WebKitGTK stack and cannot be static, and
# only glibc's symbol versioning creates the problem musl solves.
# See the "Build the CLI (static)" step for the full reasoning.
#
# macOS Apple Silicon
- os: macos-latest
target: aarch64-apple-darwin
args: '--target aarch64-apple-darwin'
musl_target: ''
# macOS Intel (cross-compiled on the arm64 runner)
- os: macos-latest
target: x86_64-apple-darwin
args: '--target x86_64-apple-darwin'
musl_target: ''
# Linux x64. ubuntu-22.04 keeps the AppImage's glibc floor low for
# broader distro compatibility.
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
args: ''
musl_target: x86_64-unknown-linux-musl
# Linux arm64, built natively on the arm64 runner (free for public
# repositories). Same 22.04 image as the x64 leg, so both AppImages
# share a glibc floor.
- os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
args: ''
musl_target: aarch64-unknown-linux-musl
# Windows x64
- os: windows-latest
target: x86_64-pc-windows-msvc
args: ''
musl_target: ''
# Windows arm64 (cross-compiled on the x64 runner, as macOS Intel is).
# Only the Rust target changes; Node, pnpm and the frontend build stay
# on the x64 image.
- os: windows-latest
target: aarch64-pc-windows-msvc
args: '--target aarch64-pc-windows-msvc'
musl_target: ''
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# The desktop build needs real binary assets (LFS-tracked fonts and
# icon source PNGs), not LFS pointer files.
lfs: true
# Tauri v2 links the system WebKitGTK/libsoup stack on Linux; these
# packages are required for `tauri build` to compile the app crate.
- name: Install Tauri Linux system dependencies
if: startsWith(matrix.os, 'ubuntu')
env:
# This step hung for 19 minutes on v0.3.0-rc.3 and had to be cancelled.
# `apt` waits on the dpkg lock indefinitely by default, and the
# runner's unattended-upgrades holds it at boot; a bounded wait fails
# with a reason instead. `noninteractive` rules out a prompt nobody
# can answer.
DEBIAN_FRONTEND: noninteractive
run: |
sudo apt-get update -o DPkg::Lock::Timeout=300
# patchelf is installed explicitly: the AppImage bundler needs it,
# and while the x64 runner image ships it, the arm64 images carry a
# documented subset. A missing patchelf fails the arm64 leg, and
# `publish` needs every leg -- so the whole release is blocked to save
# one small download.
# Deliberately no musl-tools. rustc links the musl targets
# self-contained (it ships musl's libc.a and crt objects with
# rust-std) and drives the link through the plain host `cc`, so the
# pure-Rust CLI never invokes musl-gcc. Nor would the package buy
# future-proofing: if a crate in that tree ever grew a C dependency,
# `cc` looks for `aarch64-linux-musl-gcc` on the arm64 leg, which
# musl-tools does not ship -- that case needs a real per-arch musl
# cross toolchain, not this.
sudo apt-get install -y -o DPkg::Lock::Timeout=300 \
patchelf \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@4716b85f2fac3e324e64fa2810f6b5c3905760a5 # 1.97.1
with:
# Both this leg's target and, on Linux, its static musl companion.
targets: ${{ matrix.musl_target != '' && format('{0},{1}', matrix.target, matrix.musl_target) || matrix.target }}
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ${{ matrix.target }}
# Restore only, never save. A run reads caches from its own ref, the
# default branch, or (for a pull request) the base branch -- and this
# job only ever runs on a tag, so a cache saved here can never be read
# by anything: not by the next tag, not by a branch. Saving them cost
# 7.7 GB of the repository's 10 GB budget across a handful of release
# candidates, which evicted the CI caches that do get reused.
save-if: false
workspaces: '. -> target'
- name: Enable pnpm via corepack
run: corepack enable
- name: Set up Node.js 22 with pnpm cache
uses: actions/setup-node@v5
with:
node-version: '24'
cache: pnpm
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
# The Tauri frontend (dist-tauri) imports the workspace libs; build them
# before `tauri build` runs beforeBuildCommand.
- name: Build libraries
run: pnpm build:libs
# The git-ignored icon set is generated from assets/icons/. build.rs
# regenerates it when missing, but a warm rust-cache skips build.rs while
# the icons (not cached, not committed) are absent -- so generate them
# explicitly here before `tauri build` compiles/bundles the app.
- name: Generate the desktop icon set
run: pnpm run icons
# Import the Windows Authenticode certificate so the Tauri bundler can
# sign the nsis/msi when bundle.windows.certificateThumbprint is set in
# tauri.conf.json. Gated on HAS_WIN_CERT so it no-ops (unsigned) when the
# secret is unset, keeping the build green-able.
- name: Import Windows signing certificate
if: matrix.os == 'windows-latest' && env.HAS_WIN_CERT == 'true'
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
$pfxPath = Join-Path $env:RUNNER_TEMP "windows-cert.pfx"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
$pwd = ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $pwd
# The msi bundle target requires a numeric ProductVersion, but the app
# version carries a non-numeric pre-release (e.g. 0.1.0-rc.2). Derive an
# MSI-only version into bundle.windows.wix.version so the msi target
# bundles; every other target keeps the app version. Runs only on the
# Windows leg and only mutates the ephemeral runner's checkout.
- name: Set MSI (wix) version
if: matrix.os == 'windows-latest'
shell: bash
run: node scripts/set-wix-version.mjs
# tauri-bundler attempts a keychain certificate import whenever
# APPLE_CERTIFICATE is present in the environment -- even when empty --
# which fails the macOS bundle. Export the Apple signing variables only
# when a certificate secret is actually configured, so an unset secret
# yields an unsigned (green-able) build instead of a failed import.
- name: Configure Apple code signing
if: startsWith(matrix.os, 'macos') && env.HAS_APPLE_CERT == 'true'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
{
echo "APPLE_CERTIFICATE=$APPLE_CERTIFICATE"
echo "APPLE_CERTIFICATE_PASSWORD=$APPLE_CERTIFICATE_PASSWORD"
echo "APPLE_SIGNING_IDENTITY=$APPLE_SIGNING_IDENTITY"
echo "APPLE_ID=$APPLE_ID"
echo "APPLE_PASSWORD=$APPLE_PASSWORD"
echo "APPLE_TEAM_ID=$APPLE_TEAM_ID"
} >> "$GITHUB_ENV"
- name: Build the app (Tauri)
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
# macOS signing is injected via $GITHUB_ENV by "Configure Apple code
# signing" above, and only when a certificate secret is set. The
# Windows cert password is harmless when empty (no thumbprint set in
# tauri.conf.json means the bundler does not sign).
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
with:
# The Tauri project lives under apps/desktop (src-tauri inside it).
projectPath: apps/desktop
# Build only; the dedicated publish job creates the GitHub release
# with the CHANGES.md-derived notes. Per-arch selection via --target.
args: ${{ matrix.args }}
# -----------------------------------------------------------------------
# Windows MSIX (MakeAppx): package the built exe + tile assets into an
# MSIX using the appx identity dev.lorem.SkillKeeper / CN=Lorem Dev.
# Best-effort: authored manifest + MakeAppx pack; unsigned (Store signs
# on ingest). Does not fail the build if MakeAppx/exe are unavailable.
#
# Runs BEFORE the CLI build, and must stay there: the CLI crate's binary
# is also named skillkeeper.exe and lands in target/<triple>/release, the
# very path `tauri build --target` wrote the desktop app to. Build the CLI
# first and the MSIX would ship the CLI instead of the app.
# -----------------------------------------------------------------------
- name: Package Windows MSIX (MakeAppx)
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# MSIX requires a 4-part numeric version. Match the MSI (wix) scheme:
# X.Y.Z-rc.N -> X.Y.Z.N (rc number in the 4th field)
# X.Y.Z -> X.Y.Z.0
# Note: the Microsoft Store reserves the 4th field (must be 0) and
# forbids a 0 first field, so these best-effort rc packages are for
# local distribution, not direct Store submission.
$conf = Get-Content "apps/desktop/src-tauri/tauri.conf.json" | ConvertFrom-Json
if ($conf.version -match '^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$') {
$rc = if ($matches[4]) { $matches[4] } else { "0" }
$msixVersion = "{0}.{1}.{2}.{3}" -f $matches[1], $matches[2], $matches[3], $rc
} else {
throw "Unexpected app version '$($conf.version)'; expected X.Y.Z or X.Y.Z-rc.N"
}
Write-Host "MSIX version: $msixVersion"
# Both Windows legs package an MSIX, so the architecture has to reach
# the manifest AND the file name -- the publish job refuses two assets
# sharing a basename, and an arm64 package declaring x64 would install
# on neither.
$arch = if ("${{ matrix.target }}".StartsWith("aarch64")) { "arm64" } else { "x64" }
Write-Host "MSIX architecture: $arch"
# The desktop app crate and the CLI crate BOTH produce a binary named
# skillkeeper.exe, so a recursive search would be a coin toss between
# them. Take the exact path this leg's `tauri build` wrote to:
# target/<triple>/release when --target was passed, target/release
# otherwise.
$releaseDir = if ("${{ matrix.args }}" -match "--target") { "target/${{ matrix.target }}/release" } else { "target/release" }
$exePath = Join-Path $releaseDir "skillkeeper.exe"
if (-not (Test-Path $exePath)) {
Write-Warning "skillkeeper.exe not found at $exePath; skipping MSIX packaging."
exit 0
}
$stage = Join-Path $env:RUNNER_TEMP "msix"
$assets = Join-Path $stage "Assets"
New-Item -ItemType Directory -Force -Path $assets | Out-Null
Copy-Item $exePath (Join-Path $stage "skillkeeper.exe") -Force
# Tile assets from the Tauri icons dir (Square* + StoreLogo).
$iconDir = "apps/desktop/src-tauri/icons"
foreach ($name in @(
"StoreLogo.png",
"Square30x30Logo.png", "Square44x44Logo.png", "Square71x71Logo.png",
"Square89x89Logo.png", "Square107x107Logo.png", "Square142x142Logo.png",
"Square150x150Logo.png", "Square284x284Logo.png", "Square310x310Logo.png"
)) {
$src = Join-Path $iconDir $name
if (Test-Path $src) { Copy-Item $src (Join-Path $assets $name) -Force }
}
# AppxManifest.xml. Identity dev.lorem.SkillKeeper / CN=Lorem Dev,
# tile background #1e40af, full-trust desktop app. Built as an array
# (avoids here-string terminator conflicts inside YAML).
$manifest = @(
'<?xml version="1.0" encoding="utf-8"?>',
'<Package',
' xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"',
' xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"',
' xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"',
' IgnorableNamespaces="uap rescap">',
' <Identity Name="dev.lorem.SkillKeeper"',
' Publisher="CN=Lorem Dev"',
(' Version="' + $msixVersion + '"'),
(' ProcessorArchitecture="' + $arch + '" />'),
' <Properties>',
' <DisplayName>SkillKeeper</DisplayName>',
' <PublisherDisplayName>Lorem Dev</PublisherDisplayName>',
' <Logo>Assets\StoreLogo.png</Logo>',
' </Properties>',
' <Dependencies>',
' <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />',
' </Dependencies>',
' <Resources>',
' <Resource Language="en-us" />',
' </Resources>',
' <Applications>',
' <Application Id="SkillKeeper" Executable="skillkeeper.exe" EntryPoint="Windows.FullTrustApplication">',
' <uap:VisualElements',
' DisplayName="SkillKeeper"',
' Description="Install and manage skills and hooks for AI coding agents"',
' BackgroundColor="#1e40af"',
' Square150x150Logo="Assets\Square150x150Logo.png"',
' Square44x44Logo="Assets\Square44x44Logo.png">',
' <uap:DefaultTile',
' Square71x71Logo="Assets\Square71x71Logo.png">',
' <uap:ShowNameOnTiles>',
' <uap:ShowOn Tile="square150x150Logo" />',
' </uap:ShowNameOnTiles>',
' </uap:DefaultTile>',
' </uap:VisualElements>',
' </Application>',
' </Applications>',
' <Capabilities>',
' <rescap:Capability Name="runFullTrust" />',
' </Capabilities>',
'</Package>'
) -join "`r`n"
Set-Content -Path (Join-Path $stage "AppxManifest.xml") -Value $manifest -Encoding UTF8
# Locate MakeAppx.exe from the installed Windows SDK. The x64 filter
# picks the HOST tool, which stays x64 on both legs -- the package's
# own architecture is the manifest's ProcessorArchitecture, above.
$makeappx = Get-ChildItem -Path "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter "makeappx.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "x64" } |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $makeappx) {
Write-Warning "makeappx.exe not found; skipping MSIX packaging."
exit 0
}
$outDir = "apps/desktop/dist-msix"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$msix = Join-Path $outDir "SkillKeeper-$msixVersion-$arch.msix"
& $makeappx.FullName pack /d $stage /p $msix /o
# Native tools do not trip $ErrorActionPreference; check the exit code
# explicitly so a failed pack is a real (visible) step failure instead
# of a false "Built MSIX". The step stays continue-on-error, so a
# broken MSIX is surfaced but does not block the msi/nsis upload.
if ($LASTEXITCODE -ne 0) {
Write-Error "MakeAppx failed with exit code $LASTEXITCODE; MSIX not produced."
exit 1
}
Write-Host "Built MSIX: $msix"
# -----------------------------------------------------------------------
# Standalone CLI (skillkeeper): build and archive it for this target so it
# can be installed with the one-line install script (scripts/install.*),
# independent of the desktop bundle. The archive holds just the binary at
# its root and is named by target triple, so the script builds the
# download URL straight from the detected platform.
# -----------------------------------------------------------------------
- name: Build the CLI
shell: bash
run: cargo build --release -p skillkeeper-cli --target ${{ matrix.target }}
- name: Archive the CLI (tar.gz)
if: matrix.os != 'windows-latest'
shell: bash
run: |
mkdir -p dist-cli
tar -czf "dist-cli/skillkeeper-cli-${{ matrix.target }}.tar.gz" \
-C "target/${{ matrix.target }}/release" skillkeeper
# -----------------------------------------------------------------------
# Second Linux CLI: statically linked against musl.
#
# The gnu binary above is linked on this runner's glibc (2.35 on the
# 22.04 images). glibc versions its symbols and is only forward
# compatible, so that binary will not start on an older host -- it dies
# with "libc.so.6: version `GLIBC_2.34' not found" before main() runs.
# A musl build carries its own libc and depends on nothing, which puts
# the whole distro question to rest for the CLI.
#
# This costs one extra `cargo build` and no new tooling: every crate in
# the CLI's tree is pure Rust (git is a subprocess, there is no TLS or
# OpenSSL), so the static link needs no C sysroot. It is published
# ALONGSIDE the gnu archive, not instead of it -- install.sh picks
# between them from the host's glibc, and existing pinned installs keep
# resolving the asset name they already know.
#
# Keep the glibc floor in scripts/install.sh and docs/ in step with the
# runner image above; the `check-glibc-floor` skill verifies that.
# -----------------------------------------------------------------------
- name: Build the CLI (static, musl)
if: matrix.musl_target != ''
shell: bash
run: cargo build --release -p skillkeeper-cli --target ${{ matrix.musl_target }}
- name: Archive the CLI (tar.gz, musl)
if: matrix.musl_target != ''
shell: bash
run: |
mkdir -p dist-cli
tar -czf "dist-cli/skillkeeper-cli-${{ matrix.musl_target }}.tar.gz" \
-C "target/${{ matrix.musl_target }}/release" skillkeeper
- name: Archive the CLI (zip)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist-cli | Out-Null
Compress-Archive -Path "target/${{ matrix.target }}/release/skillkeeper.exe" -DestinationPath "dist-cli/skillkeeper-cli-${{ matrix.target }}.zip" -Force
# -----------------------------------------------------------------------
# Upload per-target bundles + the MSIX. Covers both the default target
# dir (linux/windows) and the cross-target dir (macOS --target).
# -----------------------------------------------------------------------
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: skillkeeper-${{ github.ref_name }}-${{ matrix.target }}
path: |
target/release/bundle/**/*
target/*/release/bundle/**/*
apps/desktop/dist-msix/*.msix
dist-cli/*
if-no-files-found: warn
# ---------------------------------------------------------------------------
# Publish: after all matrix builds succeed, create the GitHub release with
# notes extracted from CHANGES.md and attach every built artifact.
# ---------------------------------------------------------------------------
publish:
name: Publish release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
# Creating a GitHub release requires write access to repo contents; the
# default GITHUB_TOKEN is read-only, which fails with a 403.
permissions:
contents: write
# Sign checksums.txt only when the release GPG key is configured; GitHub
# passes "" for a missing secret. Gating keeps the release green-able
# (unsigned) before the secret is set, matching how the macOS/Windows
# signing steps degrade to unsigned artifacts.
env:
HAS_GPG: ${{ secrets.SKILLKEEPER_RELEASE_GPG_KEY != '' }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js 22
uses: actions/setup-node@v5
with:
node-version: '24'
# This job runs only node scripts (release notes) plus gh/gpg and
# never installs deps. Disable the package-manager cache so setup-node
# does not try to locate pnpm before corepack is enabled.
package-manager-cache: false
- name: Generate release notes from CHANGES.md
run: node scripts/release-notes.mjs
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: dist-all
# Flatten every built artifact into a single dist/ directory. The upload
# action names each release asset by basename, so a duplicate basename
# across targets would silently clobber an asset -- fail loudly instead.
- name: Stage release assets
run: |
mkdir -p dist
while IFS= read -r -d '' f; do
base="$(basename "$f")"
if [ -e "dist/$base" ]; then
echo "::error::Duplicate release asset basename: $base"
exit 1
fi
cp "$f" "dist/$base"
done < <(find dist-all -type f \( \
-name '*.dmg' -o -name '*.deb' -o -name '*.rpm' \
-o -name '*.AppImage' -o -name '*.msi' -o -name '*.exe' \
-o -name '*.msix' \
-o -name 'skillkeeper-cli-*.tar.gz' -o -name 'skillkeeper-cli-*.zip' \
\) -print0)
echo "Staged $(find dist -type f | wc -l) files:"
ls -1 dist
# The staging step above only rejects DUPLICATE basenames, and
# upload-artifact is set to `warn` on no files found -- so a matrix or
# `if:` regression that silently skips one archive step would publish a
# release missing that asset, and nothing would fail. That is not a
# cosmetic gap: scripts/install.sh reacts to a missing musl archive by
# falling back to the glibc one with a warning, so an old-distribution
# host would install a binary that cannot start. Name every CLI archive
# the two installers can ask for and fail the release if one is absent.
- name: Verify every CLI archive was built
run: |
missing=''
for a in \
skillkeeper-cli-aarch64-apple-darwin.tar.gz \
skillkeeper-cli-x86_64-apple-darwin.tar.gz \
skillkeeper-cli-x86_64-unknown-linux-gnu.tar.gz \
skillkeeper-cli-aarch64-unknown-linux-gnu.tar.gz \
skillkeeper-cli-x86_64-unknown-linux-musl.tar.gz \
skillkeeper-cli-aarch64-unknown-linux-musl.tar.gz \
skillkeeper-cli-x86_64-pc-windows-msvc.zip \
skillkeeper-cli-aarch64-pc-windows-msvc.zip; do
[ -f "dist/$a" ] || missing="$missing $a"
done
if [ -n "$missing" ]; then
for a in $missing; do echo "::error::Missing release asset: $a"; done
exit 1
fi
echo "All 8 CLI archives present."
# The mcp.yml JSON Schema is generated from the Rust types by `cargo test`
# and committed, so it ships straight from the checkout -- no build leg
# produces it. It must be attached to EVERY release: the docs tell editors
# to read it from `releases/latest/download/mcp.schema.json`, and that URL
# resolves against whichever release is newest.
- name: Stage the mcp.yml JSON Schema
run: |
test -f schemas/mcp.schema.json
cp schemas/mcp.schema.json dist/mcp.schema.json
# The self-updater reads this manifest to decide whether a newer release
# exists and which asset to fetch. Generated on EVERY tag, RC included:
# `releases/latest` resolves only to non-prereleases, so an RC's manifest
# is never served to stable clients, while the RC still exercises this
# whole path. Needs GH_TOKEN to ask which releases still exist, so a
# deleted one stops being advertised.
- name: Generate the version manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/gen-versions-json.mjs "$GITHUB_REF_NAME" dist
# Checksum every staged asset. Written outside dist/ first so the glob
# cannot include the (empty) checksums.txt the redirection would create.
- name: Generate SHA-256 checksums
run: |
( cd dist && sha256sum * ) > checksums.txt
mv checksums.txt dist/checksums.txt
cat dist/checksums.txt
- name: Import GPG release key
if: env.HAS_GPG == 'true'
env:
GPG_PRIVATE_KEY: ${{ secrets.SKILLKEEPER_RELEASE_GPG_KEY }}
run: |
mkdir -p ~/.gnupg
chmod 700 ~/.gnupg
echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
- name: Sign checksums
if: env.HAS_GPG == 'true'
env:
GPG_PASSPHRASE: ${{ secrets.SKILLKEEPER_RELEASE_GPG_PASSPHRASE }}
working-directory: dist
run: |
gpg --batch --yes --pinentry-mode loopback \
--passphrase "$GPG_PASSPHRASE" \
--detach-sign --armor \
--output checksums.txt.asc \
checksums.txt
- name: Warn when the release is unsigned
if: env.HAS_GPG != 'true'
run: echo "::warning::SKILLKEEPER_RELEASE_GPG_KEY is not set; publishing checksums.txt without a GPG signature."
# Append the download sections LAST: it lists whatever is actually staged in
# dist/, so it must run after checksums.txt and its signature exist. The
# script only reads file names -- it never renames an asset, so the
# one-line installers (which build their URL from a fixed asset-name
# pattern) are unaffected by anything it emits.
- name: Append the downloads section to the release notes
run: node scripts/release-downloads.mjs "$GITHUB_REF_NAME" dist
- name: Create GitHub release and attach artifacts
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: dist/*
body_path: RELEASE_NOTES.md
prerelease: ${{ contains(github.ref_name, '-') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}