-
Notifications
You must be signed in to change notification settings - Fork 0
664 lines (617 loc) · 34.5 KB
/
Copy pathrelease.yml
File metadata and controls
664 lines (617 loc) · 34.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
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
# Lockstep release: one git tag (vX.Y.Z) publishes, with the SAME version,
# 1. the multi-arch studio image → ghcr.io/exepad/exepad-app-builder:X.Y.Z (+ :latest)
# 2. the npm launcher → exepad-app-builder@X.Y.Z (npx exepad-app-builder up)
# 3. a GitHub Release carrying install.sh/install.ps1 pinned to X.Y.Z plus the
# one-click bundles (Exepad-Installer-{Windows,macOS}.zip / -Linux.tar.gz)
#
# Ordering is deliberate: the image is pushed BEFORE the npm launcher goes live,
# so `npx exepad-app-builder@X.Y.Z up` can never resolve to a launcher whose image tag does
# not exist yet. See RELEASING.md.
#
# No publish secrets. npm uses TRUSTED PUBLISHING (OIDC) — configured at
# npmjs.com on the `exepad-app-builder` package, trusting this repo + workflow —
# and GHCR uses the built-in GITHUB_TOKEN. Nothing long-lived to rotate.
name: release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
env:
IMAGE_REPO: ghcr.io/exepad/exepad-app-builder
permissions:
contents: read
jobs:
# ── Parse + validate the tag; everything downstream consumes these outputs ──
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.parse.outputs.version }}
prerelease: ${{ steps.parse.outputs.prerelease }}
public: ${{ steps.parse.outputs.public }}
steps:
- name: Parse tag
id: parse
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \
|| { echo "::error::tag $TAG is not vX.Y.Z[-pre]"; exit 1; }
case "$VERSION" in
*-*) PRERELEASE=true ;;
*) PRERELEASE=false ;;
esac
# PRIVATE-mode detection: only the canonical repo releases publicly.
# Any other repo (a fork or a private mirror) releases PRIVATELY: the
# GHCR package stays private (auth to pull), npm publish is skipped
# (npm is inherently public), and cosign keyless signing is skipped —
# it writes to the PUBLIC Rekor transparency log, which would publish
# a private image's digests + repo identity.
if [ "${GITHUB_REPOSITORY}" = "Exepad/exepad-app-builder" ]; then
PUBLIC=true
else
PUBLIC=false
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "public=$PUBLIC" >> "$GITHUB_OUTPUT"
echo "Releasing $VERSION (prerelease: $PRERELEASE, public: $PUBLIC)"
# ── 1. Multi-arch studio image → GHCR ───────────────────────────────────────
image:
needs: version
# Exposed so the post-build smoke can skip cleanly on an amd64-only
# (private/fork) build instead of failing on an arm64 tag that was never
# produced.
outputs:
platforms: ${{ steps.tags.outputs.platforms }}
runs-on: ubuntu-latest
# arm64 builds under QEMU emulation — slow by design (packaging-plan A.3).
timeout-minutes: 300
permissions:
contents: read
packages: write
# cosign keyless signing: the OIDC token proves this workflow built the image.
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute image tags + platforms
id: tags
run: |
set -euo pipefail
TAGS="${IMAGE_REPO}:${{ needs.version.outputs.version }}"
LITE_TAGS="${IMAGE_REPO}:${{ needs.version.outputs.version }}-lite"
# :latest / :latest-lite only track stable releases; prereleases stay tag-only.
if [ "${{ needs.version.outputs.prerelease }}" = "false" ]; then
TAGS="$TAGS,${IMAGE_REPO}:latest"
LITE_TAGS="$LITE_TAGS,${IMAGE_REPO}:latest-lite"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
echo "lite_tags=$LITE_TAGS" >> "$GITHUB_OUTPUT"
# Private releases build amd64-only by default: the arm64 leg runs
# under QEMU (hours) and private-repo Actions minutes are billed.
# Opt back in with the repo variable EXEPAD_PRIVATE_MULTIARCH=1.
if [ "${{ needs.version.outputs.public }}" = "true" ] || [ "${{ vars.EXEPAD_PRIVATE_MULTIARCH }}" = "1" ]; then
echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT"
else
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
fi
- name: Build + push
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: ${{ steps.tags.outputs.platforms }}
push: true
tags: ${{ steps.tags.outputs.tags }}
# The version/commit/source triple feeds the AGPL section 13 source
# offer (the studio About page + the server's /source route), so it is
# derived from THIS repo — a fork releasing from its own repo ships its
# own Corresponding Source URL without editing any code.
build-args: |
EXEPAD_VERSION=${{ needs.version.outputs.version }}
EXEPAD_COMMIT=${{ github.sha }}
EXEPAD_SOURCE_URL=https://github.com/${{ github.repository }}
labels: |
org.opencontainers.image.title=Exepad
org.opencontainers.image.description=Self-hosted AI app builder (single container)
org.opencontainers.image.version=${{ needs.version.outputs.version }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.licenses=AGPL-3.0-only
cache-from: type=gha
cache-to: type=gha,mode=max
# server-lite variant: same Dockerfile, EXEPAD_LITE=1 skips the Chromium
# layers (~1 GB smaller; dashboard thumbnails auto-disable at boot). Shares
# every other layer with the build above via the gha cache, so this is a
# cheap re-assembly, not a second full build.
- name: Build + push server-lite
id: build_lite
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: ${{ steps.tags.outputs.platforms }}
push: true
build-args: |
EXEPAD_LITE=1
EXEPAD_VERSION=${{ needs.version.outputs.version }}
EXEPAD_COMMIT=${{ github.sha }}
EXEPAD_SOURCE_URL=https://github.com/${{ github.repository }}
tags: ${{ steps.tags.outputs.lite_tags }}
labels: |
org.opencontainers.image.title=Exepad (server-lite)
org.opencontainers.image.description=Self-hosted AI app builder (single container, no bundled Chromium)
org.opencontainers.image.version=${{ needs.version.outputs.version }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.licenses=AGPL-3.0-only
cache-from: type=gha
# Keyless (OIDC) signatures for both variants: operators verify provenance with
# cosign verify ghcr.io/exepad/exepad-app-builder:<tag> \
# --certificate-identity-regexp 'github.com/${{ github.repository }}' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
# PUBLIC releases only: keyless signing records the signature in the PUBLIC
# Rekor transparency log — signing a private release would leak the image
# digests and repo identity of a not-yet-announced product.
- name: Install cosign
if: needs.version.outputs.public == 'true'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign images (cosign keyless)
if: needs.version.outputs.public == 'true'
env:
FULL_DIGEST: ${{ steps.build.outputs.digest }}
LITE_DIGEST: ${{ steps.build_lite.outputs.digest }}
run: |
set -euo pipefail
cosign sign --yes "${IMAGE_REPO}@${FULL_DIGEST}"
cosign sign --yes "${IMAGE_REPO}@${LITE_DIGEST}"
# ── 2. npm launcher (lockstep: package version == image tag) ────────────────
# PUBLIC releases only — an npm publish is inherently public. Private-phase
# operators run the launcher from the repo: `node packages/exepad-cli/dist/cli.js`.
npm:
needs: [version, image]
if: needs.version.outputs.public == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
# Trusted publishing (OIDC): npm verifies this workflow's identity instead
# of a long-lived token. Configured at npmjs.com → exepad-app-builder →
# Settings → Trusted Publisher (Exepad / exepad-app-builder / release.yml).
id-token: write
defaults:
run:
working-directory: packages/exepad-cli
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
# NO registry-url on purpose. It makes setup-node write an empty
# `//registry.npmjs.org/:_authToken=` line into .npmrc, and npm prefers
# that empty credential over the OIDC exchange — the publish then fails
# with a 401 that says nothing about OIDC (actions/setup-node#1551).
# The registry default is npmjs.org anyway.
- name: Install (standalone — the launcher has no workspace deps)
run: pnpm install --ignore-workspace
- name: Stamp lockstep version from the tag
run: npm version "${{ needs.version.outputs.version }}" --no-git-tag-version
- name: Typecheck + test + build
run: |
pnpm run typecheck
pnpm run test
pnpm run build
- name: Smoke-test the built launcher
run: |
set -euo pipefail
GOT="$(node dist/cli.js --version)"
[ "$GOT" = "${{ needs.version.outputs.version }}" ] \
|| { echo "::error::launcher reports $GOT, expected ${{ needs.version.outputs.version }}"; exit 1; }
node dist/cli.js --help >/dev/null
# Enforce the thin-launcher contract: npm ships KBs, Docker ships the image.
SIZE=$(npm pack --dry-run --json | node -p 'JSON.parse(require("fs").readFileSync(0,"utf8"))[0].size')
[ "$SIZE" -lt 262144 ] || { echo "::error::npm tarball is ${SIZE}B (>256KB) — launcher must stay thin"; exit 1; }
# The launcher's entire job is to `docker pull` the image tag matching its
# own version. Publishing a launcher whose image does not exist is the
# worst state this pipeline can reach, because npm's `latest` dist-tag
# moves on publish: `npx exepad-app-builder up` — the command the README
# leads with for anyone who has Node — then fails for EVERY user, not just
# the one who asked for that version. It has happened once already (a
# hand-published 1.0.0-rc.3 that was never git-tagged, so no image was ever
# built for it), and it is invisible until someone runs a real install:
# `--dry-run` prints the pull instead of performing it.
- name: Refuse to publish a launcher with no matching image
run: |
set -euo pipefail
VERSION="${{ needs.version.outputs.version }}"
REF="${IMAGE_REPO}:${VERSION}"
docker manifest inspect "$REF" >/dev/null 2>&1 \
|| { echo "::error::$REF does not exist — refusing to publish a launcher that cannot pull its own image"; exit 1; }
echo "$REF exists — safe to publish the launcher."
- name: Publish to npm
env:
# Fallback credential only — see the two-step publish below. Empty when
# the secret is unset, which is the supported state once a trusted
# publisher exists.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ needs.version.outputs.version }}"
# Already on the registry? Then this job's work is done. npm publish is
# not idempotent — it fails with EPUBLISHCONFLICT on a re-run — and a
# conflict used to take the whole release down with it. It also makes a
# maintainer's manual publish (the documented fallback when neither
# credential is available) recoverable by re-running this workflow.
if npm view "exepad-app-builder@${VERSION}" version >/dev/null 2>&1; then
echo "exepad-app-builder@${VERSION} is already published — nothing to do."
exit 0
fi
npm install -g npm@latest
npm --version
# prepublishOnly re-runs typecheck+test+build as a final gate.
# Extra flags are forwarded, which is how --provenance is applied to the
# OIDC attempt only: provenance is generated from the Actions OIDC
# token, so a token-based publish cannot produce it and passing the flag
# there would fail the publish outright.
publish() {
if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then
npm publish --tag next "$@"
else
npm publish "$@"
fi
}
# 1. TRUSTED PUBLISHING (OIDC) — preferred, and tried first precisely so
# that configuring it at npmjs.com silently retires the token below.
# npm >=11.5.1 exchanges the Actions OIDC token for a short-lived
# credential: nothing long-lived to leak or rotate. Needs a trusted
# publisher on the package (Exepad / exepad-app-builder / release.yml);
# without one npm exits ENEEDAUTH without attempting the exchange.
# --provenance attaches a signed attestation binding this tarball to
# THIS workflow run and commit, verifiable with `npm audit signatures`
# and shown as a "Provenance" badge on npmjs.com. It costs nothing
# here (id-token: write is already requested for the OIDC exchange)
# and it is the npm-side counterpart to the cosign signatures the
# image already carries — a launcher that pulls and runs a container
# is exactly the artefact worth attesting.
if publish --provenance; then
echo "published via trusted publishing (OIDC), with provenance."
exit 0
fi
# 2. NPM_TOKEN fallback. Deliberately NOT written to .npmrc before the
# OIDC attempt: any //registry.npmjs.org/:_authToken= line makes npm
# prefer that credential and skip the exchange entirely (the same
# trap as setup-node's registry-url, actions/setup-node#1551).
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::error::npm publish failed and no NPM_TOKEN fallback is set."
echo "::error::Configure a trusted publisher at npmjs.com → exepad-app-builder → Settings → Trusted Publisher (Exepad / exepad-app-builder / release.yml), or set the NPM_TOKEN secret to a granular token with 'bypass 2FA' enabled."
exit 1
fi
# The OIDC attempt runs prepublishOnly and can fail at the very last
# HTTP call, so re-check before retrying or this turns into a conflict.
if npm view "exepad-app-builder@${VERSION}" version >/dev/null 2>&1; then
echo "exepad-app-builder@${VERSION} landed on the OIDC attempt after all."
exit 0
fi
echo "OIDC publish did not succeed — retrying with NPM_TOKEN."
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > "$HOME/.npmrc"
publish
echo "published via NPM_TOKEN."
# ── 2b. Does the arm64 half of the manifest actually BOOT? ───────────────────
# A multi-arch manifest proves a build emitted arm64 layers, not that they run.
# Until this job existed, every Apple Silicon and ARM-server user was the first
# person to execute that image. It runs BEFORE the GitHub Release so a broken
# architecture is never advertised — the same principle as the npm job refusing
# to publish a launcher whose image does not exist.
#
# Underscore in the job id on purpose: GitHub expressions parse
# `needs.smoke-arm64.result` as a subtraction, so a hyphen here would need
# index syntax at every reference.
smoke_arm64:
needs: [version, image]
# Skips cleanly on an amd64-only private/fork build.
if: ${{ contains(needs.image.outputs.platforms, 'arm64') }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
# NOT `IMG: ${{ env.IMAGE_REPO }}` — the `env` context is not available in
# a job-level env block (only github/needs/strategy/matrix/vars/inputs), so
# that resolves to empty and yields ":1.0.2" as the image ref. The
# workflow-level IMAGE_REPO is already exported into every step's shell, so
# the steps below just use $IMAGE_REPO directly.
VER: ${{ needs.version.outputs.version }}
steps:
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with:
platforms: arm64
- name: Really aarch64, not a silent amd64 fallback
run: |
set -euo pipefail
# --entrypoint is REQUIRED: the image's ENTRYPOINT supervises Node +
# the agent and IGNORES its arguments, so a plain `docker run IMG uname
# -m` boots the whole studio and never exits.
ARCH=$(timeout 900 docker run --rm --platform linux/arm64 \
--entrypoint uname "${IMAGE_REPO}:${VER}" -m)
[ "$ARCH" = "aarch64" ] \
|| { echo "::error::expected aarch64, got $ARCH — emulation not in effect; this check would prove nothing"; exit 1; }
echo "running genuinely on aarch64"
- name: Boot, serve, and complete first-run setup on arm64
run: |
set -euo pipefail
docker run -d --name exepad-arm64 --platform linux/arm64 \
-p 8097:8080 -e EXEPAD_HTTPS_DISABLE=1 -v exepad-arm64-data:/data \
"${IMAGE_REPO}:${VER}"
code=""
for _ in $(seq 1 120); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:8097/auth/status || true)
[ "$code" = "200" ] && break
# Under QEMU a crashloop and a slow boot look identical for minutes.
if [ "$(docker inspect -f '{{.State.Running}}' exepad-arm64)" != "true" ]; then
echo "::error::arm64 container EXITED during start-up"; docker logs exepad-arm64 2>&1 | tail -60; exit 1
fi
sleep 5
done
[ "$code" = "200" ] \
|| { echo "::error::arm64 studio never answered (last: ${code:-none})"; docker logs exepad-arm64 2>&1 | tail -60; exit 1; }
SRC=$(curl -s -o /dev/null -w '%{redirect_url}' --max-time 15 http://localhost:8097/source || true)
case "$SRC" in *exepad-app-builder*) : ;; *) echo "::error::/source did not redirect to the repo on arm64 (got: ${SRC:-none})"; exit 1;; esac
TOK=$(docker logs exepad-arm64 2>&1 | sed -n '/FIRST-RUN SETUP TOKEN/,$p' | grep -Eo '[0-9a-f]{64}' | head -1 || true)
[ -n "$TOK" ] || { echo "::error::no first-run setup token minted on arm64"; docker logs exepad-arm64 2>&1 | tail -40; exit 1; }
# Accepting the token exercises the auth + SQLite paths, not just that a
# string was printed.
OUT=$(curl -s --max-time 60 -X POST http://localhost:8097/auth/setup \
-H 'Content-Type: application/json' \
-d "{\"email\":\"smoke@exepad.local\",\"password\":\"Sup3rSecret!pw\",\"setupToken\":\"$TOK\"}" || true)
case "$OUT" in *'"success":true'*) : ;; *) echo "::error::/auth/setup failed on arm64: $OUT"; exit 1;; esac
echo "arm64: boots, serves 200, /source redirects, setup token accepted"
- name: Teardown
if: always()
run: |
docker rm -f exepad-arm64 >/dev/null 2>&1 || true
docker volume rm exepad-arm64-data >/dev/null 2>&1 || true
# ── 3. GitHub Release with the version-pinned install.sh ────────────────────
# Runs on BOTH release modes: when npm is skipped (private release) the
# release still publishes — on a private repo it is only visible to the team.
#
# Gated on the IMAGE, not on npm. Every artifact this job produces —
# install.sh, install.ps1, the one-click bundles, and the MSI downstream —
# installs from the image and needs npm for nothing (install.sh's npx
# delegation falls back to its embedded bootstrap, by design). Gating the
# release on npm meant an npm-side outage or a credential problem withheld
# EVERY install path for a version whose image was already published and
# public — which is exactly what v1.0.0 did. The npm section of the release
# notes is emitted only when npm actually published; see below.
release:
needs: [version, image, npm, smoke_arm64]
# Gated on the image AND on arm64 actually booting — never advertise a
# release whose published manifest contains an architecture that does not
# run. 'skipped' is accepted because amd64-only private builds have no arm64
# to smoke. Still NOT gated on npm: see the comment above.
if: ${{ !cancelled() && needs.image.result == 'success'
&& (needs.smoke_arm64.result == 'success' || needs.smoke_arm64.result == 'skipped') }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Pin install.sh + install.ps1 to this release
run: |
set -euo pipefail
VERSION="${{ needs.version.outputs.version }}"
# Channel gates the npx delegation: the npm launcher only exists for
# public releases (see EXEPAD_RELEASE_CHANNEL in install.sh).
CHANNEL="private"
[ "${{ needs.version.outputs.public }}" = "true" ] && CHANNEL="public"
sed -i "s/^EXEPAD_DEFAULT_VERSION=.*/EXEPAD_DEFAULT_VERSION=\"$VERSION\"/" install.sh
grep -q "EXEPAD_DEFAULT_VERSION=\"$VERSION\"" install.sh
sed -i "s/^EXEPAD_RELEASE_CHANNEL=.*/EXEPAD_RELEASE_CHANNEL=\"$CHANNEL\"/" install.sh
grep -q "EXEPAD_RELEASE_CHANNEL=\"$CHANNEL\"" install.sh
bash -n install.sh
sha256sum install.sh > install.sh.sha256
# Windows front door: pin the param default + channel the same way.
sed -i "s/\[string\]\$Version = 'latest'/[string]\$Version = '$VERSION'/" install.ps1
grep -q "\$Version = '$VERSION'" install.ps1
sed -i "s/^\$ReleaseChannel = .*/\$ReleaseChannel = '$CHANNEL'/" install.ps1
grep -q "^\$ReleaseChannel = '$CHANNEL'" install.ps1
sha256sum install.ps1 > install.ps1.sha256
# One-click bundles (Windows/macOS/Linux) from the freshly pinned
# installers. Same script packaging-ci rehearses — assembly can't drift.
- name: Build one-click installer bundles
run: bash packaging/one-click/build-bundles.sh dist/installers
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ needs.version.outputs.version }}"
EXTRA=""
[ "${{ needs.version.outputs.prerelease }}" = "true" ] && EXTRA="--prerelease"
# Only advertise npm when the launcher actually reached the registry.
# A release that tells people to run `npx exepad-app-builder@X up` for
# a version npm does not have sends them at a 404 as their first
# experience of the project.
NPM_OK="${{ needs.npm.result }}"
if [ "${{ needs.version.outputs.public }}" = "true" ]; then
{
echo "## Install"
echo
echo "macOS or Linux (installs Docker for you on Linux if missing):"
echo '```bash'
echo "curl -fsSL https://github.com/${{ github.repository }}/releases/download/v$VERSION/install.sh | bash"
echo '```'
echo
echo "Windows (guides you through Docker Desktop if needed):"
echo '```powershell'
echo "powershell -ExecutionPolicy Bypass -c \"irm https://github.com/${{ github.repository }}/releases/download/v$VERSION/install.ps1 | iex\""
echo '```'
echo
if [ "$NPM_OK" = "success" ]; then
echo "Or via npm (Node 18+):"
echo '```bash'
echo "npx exepad-app-builder@$VERSION up"
echo '```'
echo
fi
echo "### Prefer a download?"
echo
echo "| Platform | Package | Then |"
echo "|---|---|---|"
echo "| Windows | [Exepad-Installer-Windows.msi](https://github.com/${{ github.repository }}/releases/download/v$VERSION/Exepad-Installer-Windows.msi) | double-click — setup runs itself |"
echo "| macOS | [Exepad-Installer-macOS.zip](https://github.com/${{ github.repository }}/releases/download/v$VERSION/Exepad-Installer-macOS.zip) | extract → open \`Install Exepad.command\` |"
echo "| Linux | [Exepad-Installer-Linux.tar.gz](https://github.com/${{ github.repository }}/releases/download/v$VERSION/Exepad-Installer-Linux.tar.gz) | \`bash install.sh\` |"
echo
echo "Step-by-step (SmartScreen/Gatekeeper notes included): [INSTALL.md](https://github.com/${{ github.repository }}/blob/v$VERSION/INSTALL.md)"
echo
echo "Images (linux/amd64 + linux/arm64, cosign-signed):"
echo "- \`ghcr.io/exepad/exepad-app-builder:$VERSION\`"
echo "- \`ghcr.io/exepad/exepad-app-builder:$VERSION-lite\` — no bundled Chromium (~1.4 GB smaller uncompressed); dashboard thumbnails auto-disable"
echo
if [ "$NPM_OK" = "success" ]; then
echo "Upgrading an existing install: \`npx exepad-app-builder update --to $VERSION\` — it snapshots a backup of your data volume first automatically (skip with \`--no-backup\`)."
else
echo "Upgrading an existing install: re-run the install command above with this version — it reuses your existing \`exepad-data\` volume."
fi
echo
echo "See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/v$VERSION/CHANGELOG.md) for what changed."
} > release-notes.md
else
{
echo "## Private release (fork / private mirror)"
echo
echo "One-click installer bundles (download from THIS page while logged in):"
echo "- **Exepad-Installer-Windows.msi** — double-click; setup runs itself (uninstall via Add/Remove Programs — never touches your data)"
echo "- **Exepad-Installer-Windows.zip** — the same installer as extract → double-click \`Install Exepad.bat\`"
echo "- **Exepad-Installer-macOS.zip** — extract → open \`Install Exepad.command\` (one-time unsigned-app approval — steps in INSTALL.md)"
echo "- **Exepad-Installer-Linux.tar.gz** — \`bash install.sh\`"
echo
echo "Step-by-step: [INSTALL.md](https://github.com/${{ github.repository }}/blob/v$VERSION/INSTALL.md)"
echo
echo "This repo's GHCR package is private — authenticate BEFORE running an installer:"
echo '```bash'
echo "echo \$GH_PAT | docker login ghcr.io -u <github-user> --password-stdin # PAT with read:packages"
echo "docker pull ghcr.io/exepad/exepad-app-builder:$VERSION"
echo '```'
echo
echo "Team install (npm launcher is NOT published for private releases):"
echo '```bash'
echo "git clone git@github.com:${{ github.repository }}.git && cd \$(basename ${{ github.repository }})"
echo "cd packages/exepad-cli && pnpm install --ignore-workspace && pnpm build"
echo "node dist/cli.js up --to $VERSION"
echo "# or: gh release download v$VERSION -p install.sh -R ${{ github.repository }} && bash install.sh --version $VERSION"
echo '```'
echo
echo "Images (amd64 by default — set repo var EXEPAD_PRIVATE_MULTIARCH=1 for arm64;"
echo "not cosign-signed: keyless signing would leak to the public Rekor log):"
echo "- \`ghcr.io/exepad/exepad-app-builder:$VERSION\` and \`:$VERSION-lite\`"
echo
echo "See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/v$VERSION/CHANGELOG.md) for what changed."
} > release-notes.md
fi
ASSETS=(
install.sh install.sh.sha256 install.ps1 install.ps1.sha256
dist/installers/Exepad-Installer-Windows.zip
dist/installers/Exepad-Installer-Windows.zip.sha256
dist/installers/Exepad-Installer-macOS.zip
dist/installers/Exepad-Installer-macOS.zip.sha256
dist/installers/Exepad-Installer-Linux.tar.gz
dist/installers/Exepad-Installer-Linux.tar.gz.sha256
)
# Idempotent, for the same reason the npm job is. Re-running this
# workflow is the DOCUMENTED recovery when a later job fails, but
# `gh release create` hard-fails with "a release with the same tag name
# already exists" — so the re-run that was meant to fix npm instead
# took the release job down and skipped the MSI with it, for a release
# that was already complete and correct. Observed on v1.0.1.
#
# Updating also matters on its own: the notes are conditional on
# whether npm published, so a re-run after a manual publish is exactly
# how the npm section gets added to a release that shipped without it.
if gh release view "v$VERSION" >/dev/null 2>&1; then
echo "release v$VERSION already exists — updating notes and re-uploading assets."
PRE_FLAG="--prerelease=false"
[ "${{ needs.version.outputs.prerelease }}" = "true" ] && PRE_FLAG="--prerelease"
gh release edit "v$VERSION" \
--title "Exepad $VERSION" \
--notes-file release-notes.md \
$PRE_FLAG
gh release upload "v$VERSION" "${ASSETS[@]}" --clobber
else
gh release create "v$VERSION" "${ASSETS[@]}" \
--title "Exepad $VERSION" \
--notes-file release-notes.md \
$EXTRA
fi
# ── 4. Windows MSI (built + smoke-tested on a real Windows runner) ──────────
# Runs after the release exists and uploads into it. WiX pinned to 5.0.2
# (v6+ requires the Open Source Maintenance Fee EULA; 5.x is plain OSS).
msi:
needs: [version, release]
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Pin install.ps1 + assemble payload (same pin as the release job)
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.version.outputs.version }}"
CHANNEL="private"
[ "${{ needs.version.outputs.public }}" = "true" ] && CHANNEL="public"
sed -i "s/\[string\]\$Version = 'latest'/[string]\$Version = '$VERSION'/" install.ps1
grep -q "\$Version = '$VERSION'" install.ps1
sed -i "s/^\$ReleaseChannel = .*/\$ReleaseChannel = '$CHANNEL'/" install.ps1
grep -q "^\$ReleaseChannel = '$CHANNEL'" install.ps1
mkdir -p dist/msipayload
cp install.ps1 'packaging/one-click/windows/Install Exepad.bat' \
'packaging/msi/Exepad Studio.url' dist/msipayload/
- name: Install WiX 5.0.2
run: dotnet tool install --global wix --version 5.0.2
- name: Build MSI
shell: bash
run: |
set -euo pipefail
# MSI ProductVersion must be numeric x.y.z - strip any prerelease part.
MSIVER="${{ needs.version.outputs.version }}"
MSIVER="${MSIVER%%-*}"
wix build packaging/msi/exepad.wxs \
-d ExepadVersion="$MSIVER" -d PayloadDir=dist/msipayload \
-o dist/Exepad-Installer-Windows.msi
# Authenticode-sign the MSI via Azure Trusted Signing (Exepad LLC, US).
# Inert until the repo variable EXEPAD_CODESIGN=1 is set alongside:
# secrets: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET
# vars: EXEPAD_SIGNING_ENDPOINT (e.g. https://eus.codesigning.azure.net/),
# EXEPAD_SIGNING_ACCOUNT, EXEPAD_SIGNING_PROFILE
# Runs BEFORE the smoke so the test installs exactly the bytes we ship.
- name: Sign MSI (Azure Trusted Signing)
if: ${{ vars.EXEPAD_CODESIGN == '1' }}
uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: ${{ vars.EXEPAD_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.EXEPAD_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ vars.EXEPAD_SIGNING_PROFILE }}
files: ${{ github.workspace }}\dist\Exepad-Installer-Windows.msi
file-digest: SHA256
timestamp-digest: SHA256
- name: Install/uninstall smoke (per-user, silent)
shell: pwsh
run: ./packaging/msi/smoke.ps1 -MsiPath dist/Exepad-Installer-Windows.msi
- name: Upload MSI to the release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
cd dist
sha256sum Exepad-Installer-Windows.msi > Exepad-Installer-Windows.msi.sha256
gh release upload "v${{ needs.version.outputs.version }}" \
Exepad-Installer-Windows.msi Exepad-Installer-Windows.msi.sha256 \
--clobber -R "${{ github.repository }}"