Repository navigation
1350 lines (1260 loc) · 63.9 KB
/
Copy pathrelease-artifacts.yml
File metadata and controls
1350 lines (1260 loc) · 63.9 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
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: Release Artifacts
# Builds per-platform tarballs of the standalone Next.js server
# (libredb-studio-standalone-<version>-<os>-<arch>.tar.gz) and attaches them,
# plus a SHA256SUMS file, to the GitHub release. These tarballs are the
# canonical artifact consumed by downstream channels (npx, homebrew, deb).
# The linux-packages job additionally turns the linux tarballs into .deb and
# .rpm packages (nfpm + bundled Node runtime, issue #112) and attaches them.
# The publish job also renders the Homebrew formula from SHA256SUMS and pushes
# it to libredb/homebrew-tap when TAP_GITHUB_TOKEN is configured (issue #111).
# The snap job builds the server snap from the linux tarballs and publishes it
# to the Snap Store when SNAPCRAFT_STORE_CREDENTIALS is configured (issue #113).
# The windows-package job builds the win32-x64 standalone zip (bundled Node
# runtime + Go launcher exe, issue #114); after the release is published the
# chocolatey job pushes the package to the Chocolatey community repository
# when CHOCO_API_KEY is configured, and the winget job submits an update PR
# to microsoft/winget-pkgs when WINGETCREATE_GITHUB_TOKEN is configured.
# The desktop-appimage job turns the linux tarballs into the two desktop
# artifacts (Tauri v2 shell + payload sidecar, issues #232 and #241): the
# AppImage, which users download straight from the release, and the GUI .deb the
# FlatPark descriptor in packaging/flatpark/ pins as extra-data. The Flatpak
# manifest in packaging/flatpak/ repacks the AppImage too, but only in
# flatpak-smoke.yml now that the Flathub submission was declined - see that
# directory's README.
# The sbom job (security programme control 2.2) generates one CycloneDX
# document from the repository's three lockfiles (bun.lock,
# packaging/windows/launcher/go.mod, desktop/src-tauri/Cargo.lock) and
# attaches it as libredb-studio-<version>.cdx.json. It covers the dependency
# closure of every artifact this workflow builds; it does NOT cover the
# container image, which docker-build-push.yml has not built yet at this
# point in the chain - security-scan.yml generates that SBOM separately.
#
# DRAFT-FIRST FLOW (issue #154): the repository uses immutable releases, which
# freeze a release's assets the moment it is published - post-publish uploads
# fail with HTTP 422 (the 0.9.45 incident). So this workflow triggers on the
# TAG push (not on release publish), attaches every asset to a DRAFT release,
# verifies the full asset set, and publishes the release as its LAST step.
# Because that publish runs under GITHUB_TOKEN it fires no `release:published`
# triggers; npm-publish and docker-build-push are chained explicitly by the
# dispatch-downstream job instead. A hand-written draft release for the tag
# is reused if one exists; otherwise a draft is created with generated notes.
on:
push:
tags:
# Product tags are bare semver (no v prefix). Chart tags
# (libredb-studio-<chart-version>) never match a leading digit.
- '[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., 0.9.41 - no v prefix)'
required: true
type: string
concurrency:
group: release-artifacts-${{ github.ref }}
# Never cancel an in-flight release publish (a manual dispatch retry must
# not kill a half-finished asset upload); non-release runs stay cancelable.
cancel-in-progress: ${{ github.event_name != 'release' }}
permissions:
contents: read
jobs:
guard:
# Product tags only: chart tags (libredb-studio-<chart-version>) are
# handled by helm-release.yml and cannot match the digit-anchored tag
# patterns above; this guard re-checks anyway for workflow_dispatch runs.
name: Validate release version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Validate version against package.json
id: version
# Event payload values go through env (never inline ${{ }} in run:) so a
# crafted tag name or dispatch input cannot inject into the shell.
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ github.event.inputs.version }}
PUSHED_TAG: ${{ github.ref_name }}
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
VERSION="$INPUT_VERSION"
else
VERSION="$PUSHED_TAG"
fi
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$'; then
echo "Version '$VERSION' is not a valid semver (no v prefix) - refusing to build release artifacts" >&2
exit 1
fi
# Tarball names always derive from package.json (see the build
# script), so a dispatched version must match it too.
if [ "$VERSION" != "$PKG_VERSION" ]; then
echo "Requested version '$VERSION' does not match package.json version '$PKG_VERSION' - refusing to publish mismatched artifacts" >&2
exit 1
fi
# workflow_dispatch can run from any ref, but the uploads below use
# --clobber: artifacts must be built from the exact tagged commit or
# a dispatch from a matching-version branch could silently replace a
# published release's assets with different code.
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
git fetch --depth 1 origin "refs/tags/${VERSION}"
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "FETCH_HEAD^{commit}")" ]; then
echo "Checked-out commit $(git rev-parse HEAD) is not release tag '$VERSION' - refusing to overwrite published release assets" >&2
exit 1
fi
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Version: ${VERSION}"
channels:
# Reads update.ci_enabled out of distribution/channels.yaml so the optional
# channels below can be switched off in one reviewable place, without
# deleting a secret or editing this workflow. A channel that is known to be
# unable to publish (a package in community moderation, a listing still
# under review) must not paint a good release run red.
#
# continue-on-error is the fail-safe: if this job cannot run, its outputs
# come out empty, every gate below reads "not true" and the optional
# channels skip - the release itself is never blocked by the switchboard.
# The weekly distribution:check drift table is what notices a channel that
# stopped publishing.
name: Resolve channel automation
runs-on: ubuntu-latest
continue-on-error: true
outputs:
docker_hub_mirror: ${{ steps.flags.outputs.docker_hub_mirror }}
homebrew: ${{ steps.flags.outputs.homebrew }}
snap: ${{ steps.flags.outputs.snap }}
winget: ${{ steps.flags.outputs.winget }}
chocolatey: ${{ steps.flags.outputs.chocolatey }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Install dependencies
uses: ./.github/actions/bun-install
- name: Read update.ci_enabled from distribution/channels.yaml
id: flags
# tee: the resolved switchboard is visible in the log, not just consumed.
run: node scripts/distribution-check.mjs --ci-outputs | tee -a "$GITHUB_OUTPUT"
draft:
# Immutable releases (issue #154): assets can only be attached while the
# release is a draft. Reuse a hand-written draft when one exists for the
# tag; refuse to proceed when the release is already published (uploads
# would 422 and the published asset set can never be amended).
name: Ensure draft release
needs: guard
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Create or reuse the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.guard.outputs.version }}
run: |
set -euo pipefail
if IS_DRAFT=$(gh release view "$TAG" --json isDraft --jq .isDraft 2>/dev/null); then
if [ "$IS_DRAFT" != "true" ]; then
echo "::error::Release '$TAG' is already published; immutable releases cannot receive assets. Delete it (if policy allows) or bump the version. See #154."
exit 1
fi
echo "Reusing existing draft release for '$TAG'."
else
gh release create "$TAG" --draft --title "$TAG" --generate-notes
echo "Created draft release for '$TAG'."
fi
build:
name: Build standalone tarball (${{ matrix.platform }})
needs: guard
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
platform: linux-x64
- runner: ubuntu-24.04-arm
platform: linux-arm64
# macos-13 (the previous Intel label) was retired from hosted
# runners; macos-15-intel is the free x64 image, macos-14 is arm64.
- runner: macos-15-intel
platform: darwin-x64
- runner: macos-14
platform: darwin-arm64
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Setup Node.js
# Node runs the standalone server (and the smoke test); pin the major
# to match the Dockerfile runner image and package.json engines.
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Install dependencies
uses: ./.github/actions/bun-install
- name: Build payload and run smoke test
run: bash scripts/build-standalone-payload.sh dist --smoke
- name: Upload tarball artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: standalone-${{ matrix.platform }}
path: dist/libredb-studio-standalone-*.tar.gz
if-no-files-found: error
retention-days: 7
windows-package:
name: Build win32-x64 standalone zip
# Builds the Windows release artifact (issue #114): the standalone
# payload (built natively on windows-latest), the pinned Node runtime
# (packaging/windows/fetch-node.sh), and the Go launcher
# (packaging/windows/launcher) assembled into ONE flat zip - the
# artifact winget and Chocolatey install and the npx launcher downloads
# on win32. Smoke: the final zip is extracted fresh and booted through
# libredb-studio.exe (the exact end-user path).
#
# DELIBERATELY a hard release gate: publish needs this job, exactly like
# the POSIX build matrix - the win32 zip is a core artifact on the
# publish-release required-assets list (#114), not an optional channel
# like snap. A failure here blocks the whole release train by design;
# a release without its Windows artifact would strand winget/Chocolatey
# (their manifests point at this exact asset) and npx on win32.
needs: guard
runs-on: windows-latest
defaults:
run:
# Git Bash: keeps the packaging scripts single-source with the POSIX
# jobs. Anything needing the System32 bsdtar addresses it absolutely
# (Git Bash's own tar is GNU tar, which cannot read zip).
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Setup Go (launcher toolchain)
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: packaging/windows/launcher/go.mod
# The launcher is dependency-free (no go.sum) - module caching
# would only emit restore warnings.
cache: false
- name: Vet and test the launcher
run: |
cd packaging/windows/launcher
gofmt -l . | tee /tmp/gofmt.out
test ! -s /tmp/gofmt.out
go vet ./...
go test ./...
- name: Install dependencies
uses: ./.github/actions/bun-install
- name: Build the standalone payload zip
run: bash scripts/build-standalone-payload.sh dist
- name: Bundle the Node runtime and launcher, re-pack the zip
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
ZIP="dist/libredb-studio-standalone-${VERSION}-win32-x64.zip"
STAGING="$RUNNER_TEMP/win-payload"
mkdir -p "$STAGING"
# Env-var casing differs between shells (SystemRoot vs SYSTEMROOT);
# cover both before falling back to the canonical location.
"${SYSTEMROOT:-${SystemRoot:-C:\\Windows}}/System32/tar.exe" -xf "$ZIP" -C "$STAGING"
bash packaging/windows/fetch-node.sh "$STAGING"
(cd packaging/windows/launcher && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o "$STAGING/libredb-studio.exe" .)
bash scripts/lib/pack-standalone-zip.sh "$STAGING" "$ZIP"
- name: Smoke test the final zip via libredb-studio.exe
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
SMOKE_DIR="$RUNNER_TEMP/win-smoke"
mkdir -p "$SMOKE_DIR"
"${SYSTEMROOT:-${SystemRoot:-C:\\Windows}}/System32/tar.exe" -xf "dist/libredb-studio-standalone-${VERSION}-win32-x64.zip" -C "$SMOKE_DIR"
test -f "$SMOKE_DIR/libredb-studio.exe"
test -f "$SMOKE_DIR/node/node.exe"
# Issue #114 constraint: the drivers must load on Windows with the
# BUNDLED runtime. better-sqlite3 is the ONLY native binding in
# the payload (the real Windows-prebuild risk) and the only one
# requireable from the payload root: on the win32 build Turbopack
# bundles the pure-JS drivers (oracledb thin mode, mssql/tedious)
# into the server chunks, so they carry no prebuild risk and load
# with the server itself (the 0.9.58 attempt proved oracledb is
# not in the traced node_modules here, unlike the linux build).
(cd "$SMOKE_DIR" && ./node/node.exe -e "require('better-sqlite3')(':memory:').close(); console.log('db driver probe ok')")
# Zero-config boot through the launcher: storage defaults under
# %LOCALAPPDATA%\LibreDB\Studio, bind defaults to loopback, and
# the first run must print generated admin credentials.
PORT=3900
# exec pins $! to the launcher itself (same pattern as the POSIX
# smoke in scripts/build-standalone-payload.sh), not a wrapper
# subshell.
(cd "$SMOKE_DIR" && PORT="$PORT" exec ./libredb-studio.exe) > /tmp/win-smoke.log 2>&1 &
LAUNCHER_PID=$!
HEALTHY=false
for _ in $(seq 1 60); do
if ! kill -0 "$LAUNCHER_PID" 2>/dev/null; then break; fi
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/db/health" || true)
if [ "$CODE" = "200" ]; then HEALTHY=true; break; fi
sleep 2
done
if [ "$HEALTHY" != "true" ]; then
echo "Smoke test FAILED: /api/db/health did not return 200" >&2
cat /tmp/win-smoke.log >&2 || true
exit 1
fi
grep -q "first run: generated admin credentials" /tmp/win-smoke.log
# The embedded SQLite sample seeds asynchronously into the storage
# directory (the launcher's %LOCALAPPDATA% default) - its absence
# means the zip shipped without seed-assets/.
SAMPLE_SEEDED=false
for _ in $(seq 1 30); do
if [ -f "${LOCALAPPDATA}/LibreDB/Studio/sample-employees.db" ]; then SAMPLE_SEEDED=true; break; fi
sleep 1
done
if [ "$SAMPLE_SEEDED" != "true" ]; then
echo "Smoke test FAILED: embedded SQLite sample was not seeded within 30s" >&2
cat /tmp/win-smoke.log >&2 || true
exit 1
fi
echo "Smoke: launcher booted, /api/db/health returned 200, first-run banner printed, sample seeded"
# Tear the whole tree down (launcher exe + its node.exe child) so
# nothing holds files open while the artifact uploads; the doubled
# slashes stop Git Bash's path mangling of taskkill's /flags.
taskkill //F //T //IM libredb-studio.exe 2>/dev/null || true
kill "$LAUNCHER_PID" 2>/dev/null || true
- name: Upload zip artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: standalone-win32-x64
path: dist/libredb-studio-standalone-*-win32-x64.zip
if-no-files-found: error
retention-days: 7
publish:
name: Attach artifacts to release
needs: [guard, build, windows-package, draft, channels]
runs-on: ubuntu-latest
permissions:
contents: write
# SLSA build provenance (issue #123): id-token mints the OIDC identity
# the attestation is signed with, attestations writes it to GitHub's
# attestation store - deliberately NOT the release, so the signature and
# the asset it covers no longer share a trust domain.
id-token: write
attestations: write
steps:
# Checkout provides the Homebrew formula template and its render script;
# it must run before download-artifact so it cannot clobber dist/.
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Download all standalone archives
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: standalone-*
merge-multiple: true
path: dist
- name: Generate SHA256SUMS
# Covers the POSIX tarballs AND the win32 zip: winget, Chocolatey,
# and the npx launcher all verify against this one checksum file.
run: |
cd dist
sha256sum libredb-studio-standalone-*.tar.gz libredb-studio-standalone-*.zip > SHA256SUMS
cat SHA256SUMS
# Sign before shipping: the action hashes the files itself, so the attested
# digest is the artifact's own - not whatever SHA256SUMS claims. Only the
# artifacts are subjects; attesting the checksum file would prove nothing
# about what it describes.
- name: Attest the standalone archives
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/libredb-studio-standalone-*.tar.gz
dist/libredb-studio-standalone-*.zip
- name: Upload artifacts to GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.guard.outputs.version }}
run: |
gh release upload "$TAG" dist/libredb-studio-standalone-*.tar.gz \
dist/libredb-studio-standalone-*.zip dist/SHA256SUMS \
--clobber --repo "$GITHUB_REPOSITORY"
# The Homebrew tap update is optional: it only runs when TAP_GITHUB_TOKEN
# (a token with write access to libredb/homebrew-tap) is configured, so
# forks without the secret still publish artifacts cleanly - mirroring
# the DOCKER_HUB_TOKEN gate in docker-build-push.yml.
- name: Check Homebrew tap availability
id: tap
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
# Both gates must pass: the secret says CI *can* publish, the
# inventory flag says it *should*. Secrets cannot be read in a
# job-level if, which is why this stays a step gate.
CI_ENABLED: ${{ needs.channels.outputs.homebrew }}
run: |
if [ "$CI_ENABLED" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::homebrew update.ci_enabled is '${CI_ENABLED:-<unresolved>}' in distribution/channels.yaml - skipping the Homebrew tap update"
elif [ -n "$TAP_GITHUB_TOKEN" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::TAP_GITHUB_TOKEN is not configured - skipping the Homebrew tap update"
fi
- name: Render Homebrew formula
if: steps.tap.outputs.enabled == 'true'
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
node scripts/render-homebrew-formula.mjs \
packaging/homebrew/libredb-studio.rb.tmpl \
dist/SHA256SUMS \
"$VERSION" \
dist/libredb-studio.rb
- name: Push formula to libredb/homebrew-tap
if: steps.tap.outputs.enabled == 'true'
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
VERSION: ${{ needs.guard.outputs.version }}
run: |
TAP_DIR="$RUNNER_TEMP/homebrew-tap"
# Header-based auth (the actions/checkout mechanism): a token in the
# clone URL would persist in plaintext in the clone's .git/config
# for the rest of the job; a per-command http.extraheader does not.
AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TAP_GITHUB_TOKEN" | base64 -w0)"
git -c http.extraheader="$AUTH_HEADER" clone --depth 1 \
"https://github.com/libredb/homebrew-tap.git" "$TAP_DIR"
mkdir -p "$TAP_DIR/Formula"
cp dist/libredb-studio.rb "$TAP_DIR/Formula/libredb-studio.rb"
cd "$TAP_DIR"
git add Formula/libredb-studio.rb
if git diff --cached --quiet; then
echo "::notice::Formula already up to date for ${VERSION} - nothing to push"
exit 0
fi
git -c user.name=cevheri -c user.email=cevheribozoglan@gmail.com \
commit -m "libredb-studio ${VERSION}"
git -c http.extraheader="$AUTH_HEADER" push origin HEAD
sbom:
name: Generate and attach the SBOM
# Its own job rather than a step inside `publish`: the SBOM needs the
# dependency tree installed, because Trivy reads licences out of
# node_modules/*/package.json and emits a licence-free document without them
# (verified: 0 of 755 components carry a licence with node_modules absent,
# 330 of 755 with it present). `publish` deliberately does not install
# anything. Isolating it also means a reviewer can reject the SBOM without
# rejecting the artifact upload.
#
# One SBOM covers everything this release ships: Trivy finds bun.lock,
# packaging/windows/launcher/go.mod and desktop/src-tauri/Cargo.lock, which
# between them are the closure of the npm package, the four standalone
# tarballs, the win32 zip, the .deb and .rpm packages, the snap, the AppImage
# and the desktop .deb - all built from these lockfiles at this commit.
#
# The container image is NOT covered here and cannot be: this workflow
# publishes the release before dispatching docker-build-push.yml, so the
# image does not exist while the release can still receive assets, and
# immutable releases freeze the set at publish time. security-scan.yml
# generates the image SBOM daily instead, and SECURITY.md documents the
# one-command regeneration from the immutable digest.
needs: [guard, draft]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Install dependencies
uses: ./.github/actions/bun-install
- name: Check Docker Hub availability
# This job now sits on the release path: `publish-release` needs it.
# An anonymous pull of aquasec/trivy is subject to Docker Hub's
# per-address rate limit, and hosted runners share addresses - the
# same failure mode security-scan.yml documents for its own pulls.
# Optional: falls back to an anonymous pull when the repository has
# not configured Docker Hub credentials, same guard shape as
# docker-build-push.yml's own Docker Hub mirror step.
id: dockerhub
env:
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
run: |
set -euo pipefail
if [ -n "$DOCKER_HUB_TOKEN" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::DOCKER_HUB_TOKEN is not configured - pulling aquasec/trivy anonymously, subject to Docker Hub's per-address rate limit."
fi
- name: Log in to Docker Hub
if: steps.dockerhub.outputs.enabled == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ vars.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate the CycloneDX SBOM
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
set -euo pipefail
mkdir -p sbom
# Trivy 0.73.0, pinned by digest. CycloneDX rather than SPDX: Trivy
# emits it natively for both a filesystem and an image, and it is what
# Dependency-Track and most procurement questionnaires consume.
#
# Not byte-reproducible across runs - the document carries a random
# serialNumber and a wall-clock timestamp - so it is generated exactly
# once, here, before the release is published. What is stable is the
# component set.
#
# Retried rather than run once: this is the most fragile CI surface
# in the repository (a failed release retries with a NEW patch
# version, never the same tag), so a transient Docker Hub rate limit
# or network blip must not be allowed to stall a release before
# publish.
#
# --user "$(id -u):$(id -g)": aquasec/trivy runs as root by default,
# so without this the bind-mounted output file lands on the host
# owned by root, mode 644. Reproduced 2026-08-09: the very next step
# patches this same file in place with Node's `fs.writeFileSync`,
# which for an EXISTING file opens it for write - permission this
# runner's own non-root user does not have on a root-owned file, so
# that step fails EACCES. This scanner needs no vulnerability-DB
# cache (`--scanners license` only), so running as the invoking user
# has no cache-ownership fallout to work around, unlike the
# `/root/.cache/trivy`-mounted scanners elsewhere in this
# repository, which stay root and are not touched by a host step
# afterward.
#
# Two other shapes were considered and rejected. `sudo chown` the
# file back after generation is reactive rather than preventing the
# wrong owner in the first place, and assumes passwordless sudo,
# which a self-hosted runner is not guaranteed to grant. Writing a
# sibling file and renaming over the original needs no privilege
# either (rename only needs write on the directory), but it moves
# the fix into the Node patch step where it looks like an
# unrelated stylistic choice - exactly the shape a future editor
# "simplifies" back into an in-place write without realising a
# permission fix depends on it. Fixing it here, on the line that
# creates the wrong owner, is the one shape where the fix and the
# defect it prevents stay next to each other.
attempt=0
until docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$GITHUB_WORKSPACE:/repo" \
-w /repo \
aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c fs \
--format cyclonedx \
--scanners license \
--skip-dirs .next \
--skip-dirs dist \
--skip-dirs coverage \
--skip-dirs sbom \
--output "/repo/sbom/libredb-studio-${VERSION}.cdx.json" \
.
do
attempt=$((attempt + 1))
if [ "$attempt" -ge 3 ]; then
echo "::error::Failed to pull or run aquasec/trivy after 3 attempts." >&2
exit 1
fi
echo "Trivy pull/run failed (attempt $attempt/3) - retrying in 10s..."
sleep 10
done
ls -la sbom
- name: Name and version the SBOM's root component
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
set -euo pipefail
# Trivy names the root component after its scan target - "." for a
# filesystem scan of the repository root - and leaves its version
# empty; neither is a flag `trivy fs` has. Leaving version unset
# matters beyond cosmetics: a CycloneDX consumer such as
# Dependency-Track keys a project by name+version, so successive
# releases' unpatched SBOMs would all collapse into one
# unversioned "libredb-studio" project, each overwriting the last.
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
if (doc.metadata && doc.metadata.component) {
doc.metadata.component.name = "libredb-studio";
doc.metadata.component.version = version;
}
fs.writeFileSync(file, JSON.stringify(doc, null, 2));
' "sbom/libredb-studio-${VERSION}.cdx.json" "$VERSION"
- name: Verify the SBOM describes something
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
set -euo pipefail
# A malformed or empty document uploads exactly as happily as a good
# one, and immutable releases mean the bad one is permanent. Assert the
# three properties that make it useful before it becomes unamendable:
# it parses, it is CycloneDX, and it found all three lockfiles.
node -e '
const fs = require("fs");
const file = process.argv[1];
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
if (doc.bomFormat !== "CycloneDX") throw new Error("not a CycloneDX document");
const components = doc.components || [];
if (components.length < 100) throw new Error("only " + components.length + " components");
const apps = components.filter((c) => c.type === "application").map((c) => c.name);
for (const lock of ["bun.lock", "packaging/windows/launcher/go.mod", "desktop/src-tauri/Cargo.lock"]) {
if (!apps.includes(lock)) throw new Error("missing ecosystem: " + lock);
}
const licensed = components.filter((c) => (c.licenses || []).length > 0).length;
if (licensed === 0) throw new Error("no licence data - were dependencies installed?");
console.log("SBOM ok: " + components.length + " components, " + licensed + " with licences");
' "sbom/libredb-studio-${VERSION}.cdx.json"
- name: Attest the SBOM
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: sbom/libredb-studio-*.cdx.json
- name: Upload the SBOM to the draft release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.guard.outputs.version }}
run: |
gh release upload "$TAG" sbom/libredb-studio-*.cdx.json \
--clobber --repo "$GITHUB_REPOSITORY"
linux-packages:
name: Build .deb and .rpm (${{ matrix.arch }})
# Turns the linux standalone tarballs into native packages (issue #112):
# unpack the payload, bundle the pinned Node runtime (fetch-node.sh),
# run nfpm (one config -> both formats), smoke test the .deb on the
# amd64 runner, and attach the packages to the release.
needs: [guard, build, draft]
runs-on: ubuntu-latest
permissions:
contents: write
# SLSA build provenance (issue #123) - one attestation per matrix arch.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64 # nfpm/GOARCH label
tarball_arch: x64 # release tarball + nodejs.org label
rpm_arch: x86_64
- arch: arm64
tarball_arch: arm64
rpm_arch: aarch64
env:
NFPM_VERSION: 2.47.0
# sha256 of nfpm_2.47.0_Linux_x86_64.tar.gz from the release checksums.txt
NFPM_SHA256: 0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Download linux tarball
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: standalone-linux-${{ matrix.tarball_arch }}
path: dist
- name: Install nfpm (pinned, checksum-verified)
run: |
curl -fsSL --retry 3 -o /tmp/nfpm.tar.gz \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz"
echo "${NFPM_SHA256} /tmp/nfpm.tar.gz" | sha256sum -c -
mkdir -p /tmp/nfpm-dist
tar -xzf /tmp/nfpm.tar.gz -C /tmp/nfpm-dist nfpm
sudo install -m 0755 /tmp/nfpm-dist/nfpm /usr/local/bin/nfpm
nfpm --version
- name: Unpack payload and bundle Node runtime
env:
VERSION: ${{ needs.guard.outputs.version }}
TARBALL_ARCH: ${{ matrix.tarball_arch }}
run: |
PAYLOAD_DIR="$RUNNER_TEMP/payload"
mkdir -p "$PAYLOAD_DIR"
tar -xzf "dist/libredb-studio-standalone-${VERSION}-linux-${TARBALL_ARCH}.tar.gz" -C "$PAYLOAD_DIR" --strip-components=1
bash packaging/linux/fetch-node.sh "$PAYLOAD_DIR" "$TARBALL_ARCH"
# The amd64 container smoke below cannot run arm64 binaries, so at
# minimum assert the bundled node matches the target architecture.
NODE_INFO="$(file -b "$PAYLOAD_DIR/node/bin/node")"
case "$TARBALL_ARCH" in
x64) echo "$NODE_INFO" | grep -q "x86-64" ;;
arm64) echo "$NODE_INFO" | grep -q "aarch64" ;;
esac || { echo "Bundled node does not match ${TARBALL_ARCH}: $NODE_INFO" >&2; exit 1; }
echo "PAYLOAD_DIR=$PAYLOAD_DIR" >> "$GITHUB_ENV"
- name: Build .deb and .rpm with nfpm
env:
VERSION: ${{ needs.guard.outputs.version }}
ARCH: ${{ matrix.arch }}
RPM_ARCH: ${{ matrix.rpm_arch }}
run: |
mkdir -p pkgs
nfpm package -f packaging/linux/nfpm.yaml -p deb -t "pkgs/libredb-studio_${VERSION}_${ARCH}.deb"
nfpm package -f packaging/linux/nfpm.yaml -p rpm -t "pkgs/libredb-studio-${VERSION}.${RPM_ARCH}.rpm"
# Per-file checksum sidecars (SHA256SUMS covers only the tarballs
# and is written by a different job, so packages ship their own).
(cd pkgs && for f in *.deb *.rpm; do sha256sum "$f" > "$f.sha256"; done)
ls -l pkgs
- name: Smoke test the .deb (native arch only)
if: matrix.arch == 'amd64'
env:
VERSION: ${{ needs.guard.outputs.version }}
ARCH: ${{ matrix.arch }}
run: |
sudo dpkg -i "pkgs/libredb-studio_${VERSION}_${ARCH}.deb"
# No systemd smoke on shared runners: run the packaged wrapper
# directly; zero-config bootstrap generates the missing secrets.
# HOSTNAME pins the bind address (an inherited HOSTNAME env var
# would make the server bind the machine hostname instead).
sudo env STORAGE_SQLITE_PATH=/var/lib/libredb-studio-smoke/storage.db \
HOSTNAME=127.0.0.1 \
libredb-studio > /tmp/studio-smoke.log 2>&1 &
HEALTHY=false
for _ in $(seq 1 30); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/db/health || true)
if [ "$CODE" = "200" ]; then HEALTHY=true; break; fi
sleep 1
done
if [ "$HEALTHY" != "true" ]; then
echo "Smoke test FAILED: /api/db/health did not return 200 within 30s" >&2
cat /tmp/studio-smoke.log >&2 || true
exit 1
fi
grep -q "first run: generated admin credentials" /tmp/studio-smoke.log
echo "Smoke: /api/db/health returned 200 and first-run banner printed"
# Browser E2E of the embedded samples through the packaged launchers
# (scripts/channel-embedded-sample-e2e.sh). Kept IN ADDITION to the
# zero-config smoke above: the smoke proves first-run credential
# generation, the channel E2E proves a user can log in and query the
# samples from the installed package. Native arch only - the amd64
# runner cannot exec arm64 binaries.
- name: Setup Bun (channel E2E)
if: matrix.arch == 'amd64'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Install dependencies (channel E2E)
if: matrix.arch == 'amd64'
uses: ./.github/actions/bun-install
- name: Install Playwright browsers (channel E2E)
if: matrix.arch == 'amd64'
# chromium only, no --with-deps - see docker-build-push.yml's channel
# E2E for the reasoning. These channels install a .deb/.rpm/.snap and
# need dpkg/rpm/snapd plus sudo on the host, so Playwright's container
# image is not an option here; dropping the apt half is.
run: bunx playwright install chromium
- name: Channel E2E - deb (native arch only)
if: matrix.arch == 'amd64'
env:
VERSION: ${{ needs.guard.outputs.version }}
ARCH: ${{ matrix.arch }}
run: bash scripts/channel-embedded-sample-e2e.sh deb "pkgs/libredb-studio_${VERSION}_${ARCH}.deb"
- name: Channel E2E - rpm (native arch only)
if: matrix.arch == 'amd64'
env:
VERSION: ${{ needs.guard.outputs.version }}
RPM_ARCH: ${{ matrix.rpm_arch }}
run: bash scripts/channel-embedded-sample-e2e.sh rpm "pkgs/libredb-studio-${VERSION}.${RPM_ARCH}.rpm"
# The packages only - pkgs/ also holds the .sha256 sidecars, and a
# checksum file is not a subject worth a signature.
- name: Attest the .deb and .rpm
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
pkgs/*.deb
pkgs/*.rpm
- name: Upload packages to GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.guard.outputs.version }}
run: gh release upload "$TAG" pkgs/* --clobber --repo "$GITHUB_REPOSITORY"
desktop-appimage:
name: Build desktop AppImage and .deb (${{ matrix.arch }})
# Turns the linux standalone tarball into the two desktop artifacts (issues
# #232, #241): the Tauri v2 shell in desktop/ bundles the payload plus the
# pinned Node runtime as a sidecar, and one bundler run emits both the
# AppImage and the GUI .deb. Built natively per architecture - the shell is a
# Rust binary linking the runner's WebKitGTK, so there is no cross-compile
# path.
#
# A hard release gate (like windows-package, unlike snap): neither artifact
# needs credentials, and both are consumed by exact asset name after the
# release - the AppImage is the Linux desktop download itself, and the .deb
# is what FlatPark's update checker (packaging/flatpark/) resolves on every
# release - so a release missing either would strand that path.
needs: [guard, build, draft]
runs-on: ${{ matrix.runner }}
permissions:
contents: write
# SLSA build provenance (issue #123). Users run the AppImage directly and
# FlatPark repacks the .deb, so a verifiable origin matters more here than
# anywhere else in the release: those users never see this repo.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Setup Rust toolchain
# The runner images ship rustup; the Tauri 2.11 dependency tree needs
# rustc >= 1.88, which a stale image toolchain may predate.
run: |
rustup toolchain install stable --profile minimal --no-self-update
rustup default stable
rustc --version
- name: Install Tauri Linux build dependencies
# patchelf is required by linuxdeploy (the AppImage bundler Tauri drives)
# and is NOT preinstalled on the runner images.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf file
- name: Download linux tarball
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: standalone-linux-${{ matrix.arch }}
path: dist
- name: Build the AppImage and GUI .deb, and smoke test the bundled server
env:
VERSION: ${{ needs.guard.outputs.version }}
ARCH: ${{ matrix.arch }}
# dist-desktop, never desktop: the latter is the checked-out Tauri source
# tree, and the upload glob below would then hand gh the repo's own
# README.md and src/ directories.
run: |
bash scripts/build-desktop-appimage.sh dist-desktop \
--payload "dist/libredb-studio-standalone-${VERSION}-linux-${ARCH}.tar.gz" \
--smoke
- name: Attest the AppImage and GUI .deb
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist-desktop/*.AppImage
dist-desktop/*.deb
- name: Upload the desktop artifacts to the GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.guard.outputs.version }}
# Both artifacts plus their .sha256 sidecars, and nothing else, land in
# dist-desktop/ - the script writes only those four files there.
run: gh release upload "$TAG" dist-desktop/* --clobber --repo "$GITHUB_REPOSITORY"
snap:
name: Build and publish Snap (${{ matrix.arch }})
# Builds the server snap (issue #113): unpack the linux standalone
# tarball into snap-payload/ (the directory snap/snapcraft.yaml stages;
# the recipe also bundles the pinned Node runtime via fetch-node.sh),
# build with snapcraft, publish to the Snap Store stable channel, and
# attach the .snap to the GitHub release. The whole job is gated on
# SNAPCRAFT_STORE_CREDENTIALS with the same pattern as Docker Hub in
# docker-build-push.yml: without the secret every step is skipped and
# the job succeeds cleanly (secrets are unavailable in job-level if).
needs: [guard, build, draft, channels]
permissions:
contents: write
# SLSA build provenance (issue #123). The .snap is the one release asset
# that ships no checksum sidecar at all - a `--dangerous` local install
# skips the Snap Store's own signature check, so until now nothing about
# the GitHub-release copy was verifiable.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64 # snap arch label
runner: ubuntu-latest
tarball_arch: x64 # release tarball + nodejs.org label
- arch: arm64
runner: ubuntu-24.04-arm
tarball_arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- name: Check Snap Store availability
id: snapstore
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
CI_ENABLED: ${{ needs.channels.outputs.snap }}
run: |
if [ "$CI_ENABLED" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::snap update.ci_enabled is '${CI_ENABLED:-<unresolved>}' in distribution/channels.yaml - skipping the Snap build and publish"
elif [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::SNAPCRAFT_STORE_CREDENTIALS is not configured - skipping the Snap build and publish"
fi
- name: Checkout code
if: steps.snapstore.outputs.enabled == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Download linux tarball
if: steps.snapstore.outputs.enabled == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: standalone-linux-${{ matrix.tarball_arch }}
path: dist