release candidates: every push to main runs the full CI, the acceptance matrix and the Linux / Windows installer builds in parallel (ci.yml) and records the green result per commit as the draft rc-<sha> (green.json last); a version-only bump commit (proven file by file with the version writers) reuses its parent's CI and matrix and rebuilds only the installers; a v* tag only promotes the candidate (release.yml), fails fast on a red or missing commit; acceptance runs carry a CLI key; tools/release.ts does the whole release on the Mac (bump, CHANGELOG cut, fast gate, push, tag, Mac build + notarize in parallel with Forgejo, cached macOS acceptance, add, verify, publish with one upload of the Mac files, /Applications update, npm commands) and --rehearsal; DMG notarization runs while the app and updater bundle are checked; rehearsal channel on the VDS; docs

This commit is contained in:
CRTkafa 2026-10-15 00:42:39 +08:00
parent 89d94d6993
commit c3b1e8c370
14 changed files with 1438 additions and 526 deletions

View file

@ -1,15 +1,23 @@
# bun run ci on every push to main and every pull request into main: adapters, desktop version, tests, typecheck,
# desktop i18n / typecheck / build, desktop boot check (headless Chromium), desktop Rust tests and clippy, scrub. Runs on the VDS runner (docs/desktop-release.md).
# tools/ci.ts runs the steps as a task graph and the tests as parallel shards (CPU cores - 1, max 8) on the one
# self-hosted runner, so a job matrix would only queue; CI=full makes it run the Rust steps on every push.
# Publishes nothing: site/ is still published by Jenkins; releases come from release.yml (a v* tag).
# acceptance-quick: the quick subset of the acceptance matrix (tools/acceptance.ts) on the Linux and Windows runners.
# release.yml's ci job runs the same steps (the setup is shared: .forgejo/actions/linux-setup).
# Every push to main is a release candidate (docs/desktop-release.md, "Release candidates"). In parallel:
# ci bun run ci (adapters, desktop version, tests, typecheck, desktop i18n / typecheck / build, the boot check
# in headless Chromium, desktop Rust tests and clippy, scrub); tools/ci.ts runs it as a task graph with
# parallel test shards on the one Linux runner. CI=full runs the Rust steps on every push.
# acceptance the release acceptance matrix (tools/acceptance.ts): every user-facing command from the packed npm
# tarball with real piped stdin, Linux and Windows x Node 20 / Node 22 / Bun.
# linux AppImage + deb (path-free build), self-tests, both started under Xvfb, privacy scan.
# windows NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
# record when all are green: the draft release rc-<sha> with the installers, the acceptance runs and matrix and
# green.json (tools/release-candidate.ts record). A v* tag on this commit only promotes it (release.yml).
# gate decides first: a commit that differs from its green parent only in version copies + CHANGELOG (a release's
# version bump, proven file by file: tools/release-candidate.ts gate) reuses the parent's ci and acceptance results
# and builds only the installers, whose embedded version changed.
# Pull requests run ci + acceptance (no installers, no record). rehearsal/* branches are release rehearsals
# (tools/release.ts --rehearsal). site/ is still published by Jenkins.
name: ci
on:
push:
branches: [main]
branches: [main, 'rehearsal/**']
pull_request:
branches: [main]
workflow_dispatch:
@ -21,11 +29,37 @@ env:
CI: full
jobs:
gate:
runs-on: ubuntu-22.04
# the self-hosted runners only run code from this repo: pull requests from forks are skipped
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 45
outputs:
reuse: ${{ steps.gate.outputs.reuse }}
from: ${{ steps.gate.outputs.from }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Version-only commit of a green parent?
id: gate
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
EVENT: ${{ github.event_name }}
SHA: ${{ github.sha }}
run: |
if [ "$EVENT" = pull_request ]; then echo "reuse=false" >> "$GITHUB_OUTPUT"; echo "pull request: everything runs"; exit 0; fi
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
export PATH="$HOME/.bun/bin:$PATH"
bun tools/release-candidate.ts gate --sha "$SHA" --wait 40
ci:
needs: gate
if: needs.gate.outputs.reuse != 'true'
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04
runs-on: ubuntu-22.04
# the self-hosted runner only runs code from this repo: pull requests from forks are skipped
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
@ -35,43 +69,255 @@ jobs:
rust-components: clippy
- uses: ./.forgejo/actions/ci-run
# The quick subset of the release acceptance matrix (tools/acceptance.ts --quick: --version, init, check, scan, the
# desktop [Y/n] prompt answered n, mcp, a console plan, store --dry writes and confirm prompts answered n) from the
# packed npm tarball with real piped stdin, on Linux and Windows with Node 20. The full matrix gates the release.
acceptance-quick:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
# The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed
# npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22
# (npx --package=<tgz>) / Bun (bun x). One red cell and the commit is no candidate. macOS runs on the owner's Mac
# (tools/release.ts, cached per CLI key).
acceptance:
needs: gate
if: needs.gate.outputs.reuse != 'true'
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-22.04, platform: Linux }
- { os: windows, platform: Windows }
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
runs-on: ${{ matrix.os }}
timeout-minutes: 20
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: ${{ matrix.node }}
- name: Bun (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- name: Acceptance quick (Linux, Node 20)
- name: Acceptance matrix (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: bun tools/acceptance.ts run --runtime node --platform Linux --quick --jobs 4 --out acceptance/Linux-node20-quick.json
- name: Acceptance quick (Windows, Node 20)
run: |
node --version && bun --version
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
- name: Acceptance matrix (Windows)
if: ${{ matrix.platform == 'Windows' }}
shell: pwsh
run: |
bun tools/acceptance.ts run --runtime node --platform Windows --quick --jobs 3 --out acceptance/Windows-node20-quick.json
if ($LASTEXITCODE -ne 0) { throw "acceptance quick: red cells" }
node --version; bun --version
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: acceptance-quick-${{ matrix.platform }}
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
path: acceptance/
retention-days: 14
linux:
needs: gate
if: github.event_name != 'pull_request'
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
# deb then run on Ubuntu 22.04 and newer, Debian 12)
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
# the path-free build keeps the target folder outside every home folder
CARGO_TARGET_DIR: /tmp/storebound-target
# linuxdeploy (AppImage) runs without FUSE inside the container
APPIMAGE_EXTRACT_AND_RUN: '1'
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
NO_STRIP: '1'
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
steps:
- uses: actions/checkout@v4
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: desktop
packages: patchelf file squashfs-tools p7zip-full xz-utils
# the release target folder (dependencies compiled once; a version bump recompiles only the app crate)
- uses: actions/cache@v4
with:
path: /tmp/storebound-target
key: desktop-target-${{ runner.os }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: desktop-target-${{ runner.os }}-
- name: Install and check
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-linux-x64
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
- name: Build AppImage and deb (path-free)
working-directory: apps/desktop
run: |
rm -rf "$CARGO_TARGET_DIR/release/bundle"
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
- name: Self-test the exact artifacts
run: |
V=$(bun tools/desktop-version.ts --print)
B="$CARGO_TARGET_DIR/release/bundle"
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
chmod +x out/*.AppImage
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
- name: Start the app under Xvfb (AppImage and deb)
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
run: |
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
V=$(bun tools/desktop-version.ts --print)
smoke() { # <label> <executable>
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
local pid=$! ok=0
for i in $(seq 1 90); do
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
echo "$1: window started, UI reached the sidecar"
}
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
smoke deb /tmp/deb/usr/bin/storebound-desktop
- name: Privacy scan
run: bun tools/privacy-scan.ts out/
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-linux
path: out/
retention-days: 7
windows:
needs: gate
if: github.event_name != 'pull_request'
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
runs-on: windows
timeout-minutes: 90
env:
CARGO_TARGET_DIR: C:\storebound-target
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install and check
shell: pwsh
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-windows-x64
- name: Node bundle on Windows (npx storebound) - stdin prompts read with node, clean exit
shell: pwsh
run: |
bun tools/build-node.ts
node --version
bun tools/node-smoke.ts --only prompt
if ($LASTEXITCODE -ne 0) { throw "node prompt smoke failed" }
"n" | node dist/storebound.mjs --version
if ($LASTEXITCODE -ne 0) { throw "node dist/storebound.mjs --version failed" }
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
shell: pwsh
working-directory: apps/desktop
run: |
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
- name: Install per user, self-test, uninstall
shell: pwsh
run: |
$V = bun tools/desktop-version.ts --print
New-Item -ItemType Directory -Force out | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
& $exe --self-test | Tee-Object -Variable st
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
- name: Privacy scan (no .pdb, no paths)
shell: pwsh
run: bun tools/privacy-scan.ts out
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-windows
path: out/
retention-days: 7
record:
needs: [gate, ci, acceptance, linux, windows]
if: ${{ always() && github.event_name != 'pull_request' && needs.gate.result == 'success' && needs.linux.result == 'success' && needs.windows.result == 'success' && ((needs.gate.outputs.reuse == 'true' && needs.ci.result == 'skipped' && needs.acceptance.result == 'skipped') || (needs.ci.result == 'success' && needs.acceptance.result == 'success')) }}
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
SHA: ${{ github.sha }}
REUSE: ${{ needs.gate.outputs.reuse }}
FROM: ${{ needs.gate.outputs.from }}
steps:
- uses: actions/checkout@v4
- name: Tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends squashfs-tools p7zip-full xz-utils binutils unzip file >/dev/null
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-linux
path: cand
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-windows
path: cand
- uses: actions/download-artifact@v3
if: ${{ needs.gate.outputs.reuse != 'true' }}
with:
path: runs
- name: Acceptance runs (this run's, or the version-only parent's)
run: |
mkdir -p cand/acceptance
if [ "$REUSE" = true ]; then
bun tools/release-candidate.ts fetch --sha "$FROM" --dir cand/acceptance --only '^acceptance-.*\.json$'
else
find runs -path '*acceptance-*' -name '*.json' -exec cp {} cand/acceptance/ \;
fi
ls cand/acceptance
V=$(bun tools/desktop-version.ts --print)
bun tools/acceptance.ts report cand/acceptance --md "cand/storebound_${V}_acceptance.md" --expect 6 --strict
- name: Privacy scan (private terms) of the installers
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
[ -n "$FORBIDDEN_TERMS" ] || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
rm -f cand/SHA256SUMS
STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts cand/*.AppImage cand/*.deb cand/*.exe
rm -f "$RUNNER_TEMP/terms.txt"
- name: Record the candidate (draft rc-<sha>, green.json last)
run: |
bun tools/release-candidate.ts record --sha "$SHA" --run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_NUMBER" --dir cand ${FROM:+--reused-from "$FROM"} --yes
bun tools/release-candidate.ts prune --keep 12 --yes

View file

@ -1,20 +1,21 @@
# The release of storebound: one version tag (v0.8.0) -> one Forgejo release with the Linux and Windows installers.
# The release of storebound: a version tag (v0.8.5) promotes the release candidate of its commit. Nothing is rebuilt
# or retested here: ci.yml ran the full CI, the acceptance matrix (Linux + Windows x Node 20 / 22 / Bun) and built,
# self-tested and privacy-scanned the Linux and Windows installers for that very commit (for a version-only bump
# commit: the installers, with the parent's CI and matrix, proven version-only file by file), and stored them as the
# draft rc-<sha> with green.json (tools/release-candidate.ts).
#
# push of a tag v* checks (tag = package.json version, every version copy equal), the full CI, the
# AppImage + deb (Linux runner, Docker) and the NSIS installer (Windows runner, VM), the
# privacy scan of every artifact, then ONE release "storebound <version>" with short notes
# (tools/forgejo-release.ts notes: the CHANGELOG summary, install line, links), the
# installers and SHA256SUMS; older release entries are deleted afterwards (tags stay).
# workflow_dispatch dry (default): the same jobs on this commit (or on `tag`), a DRAFT release test-<sha>
# with the real files, read back, then deleted. dry off + tag: (re)make that tag's release.
# push of a tag v* checks (tag = package.json version, every version copy equal, the CHANGELOG section), waits
# for the commit's candidate (heartbeat; fails at once when the commit is red or has no ci run,
# with the run's link; times out after 40 min), then ONE release "storebound <version>" with the
# candidate's installers, the acceptance matrix, SHA256SUMS and short notes. Stable versions
# then delete the older release entries (tags stay). A version with a "-" (0.0.0-rehearsal.1)
# is a prerelease: never pruning, never the stable release.
# workflow_dispatch tag: the same for an existing tag; dry (default): only the plan.
#
# Not here: macOS. The DMG is built, signed and notarized on the owner's Mac and added to this release with
# `bun tools/desktop-release.ts upload-forgejo --out <dir> --yes`; then `publish` (storebound.dev) and `npm publish`
# (the owner's step). Order and details: docs/desktop-release.md. The updater signing key never reaches a runner.
# Secrets (repo Settings > Actions > Secrets): RELEASE_TOKEN (token, scope write:repository: releases and assets),
# FORBIDDEN_TERMS (the private terms list of the privacy scan; never printed), FORGEJO_ORIGIN (the server's IP: asset
# uploads over 100 MB bypass Cloudflare).
# Runners on the VDS (labels ubuntu-22.04 and windows): docs/desktop-release.md.
# Not here: macOS. tools/release.ts on the owner's Mac builds, signs and notarizes the DMG in parallel and adds it to
# this release, then publishes storebound.dev (docs/desktop-release.md). The updater signing key never reaches a runner.
# Secrets: RELEASE_TOKEN (scope write:repository), FORGEJO_ORIGIN (the server's IP: uploads over 100 MB bypass
# Cloudflare). The private terms scan of the installers ran in ci.yml's record job (FORBIDDEN_TERMS).
name: release
on:
@ -23,401 +24,55 @@ on:
workflow_dispatch:
inputs:
tag:
description: Version tag to build (v0.8.0); empty = the selected branch's commit
description: Version tag to release (v0.8.5)
type: string
default: ''
dry:
description: Dry run (a draft release test-<sha> with the real files, deleted at the end)
type: boolean
default: true
windows:
description: Build Windows too (always on for a tag push)
description: Dry run (only the plan)
type: boolean
default: true
env:
BUN_VERSION: '1.4.0'
CARGO_TERM_COLOR: never
CARGO_INCREMENTAL: '0'
jobs:
prepare:
promote:
runs-on: ubuntu-22.04
timeout-minutes: 10
outputs:
ref: ${{ steps.mode.outputs.ref }}
tag: ${{ steps.check.outputs.tag }}
version: ${{ steps.check.outputs.version }}
dry: ${{ steps.mode.outputs.dry }}
timeout-minutes: 50
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
steps:
- name: Mode
id: mode
env:
EVENT: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
IN_TAG: ${{ inputs.tag }}
IN_DRY: ${{ inputs.dry }}
run: |
# a boolean input arrives as true / false (API: also "on" / "1"); only an explicit no is a real release
case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) want_dry=false;; *) want_dry=true;; esac
echo "inputs: tag='$IN_TAG' dry='$IN_DRY'"
if [ "$EVENT" = push ]; then ref="refs/tags/$REF_NAME"; dry=false
elif [ -n "$IN_TAG" ]; then ref="refs/tags/$IN_TAG"; dry=$want_dry
else ref="$SHA"; dry=true
[ "$want_dry" = true ] || { echo "a real release needs a tag (input tag), or push the tag" >&2; exit 1; }
fi
echo "ref=$ref" >> "$GITHUB_OUTPUT"; echo "dry=$dry" >> "$GITHUB_OUTPUT"
echo "event=$EVENT ref=$ref dry=$dry"
[ -n "$TAG" ] || { echo "input tag is required" >&2; exit 1; }
case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) dry=false;; *) dry=true;; esac
[ "$EVENT" = push ] && dry=false
echo "dry=$dry" >> "$GITHUB_OUTPUT"; echo "tag=$TAG dry=$dry"
- uses: actions/checkout@v4
with:
ref: ${{ steps.mode.outputs.ref }}
- name: Tag, package.json version and every version copy agree
ref: refs/tags/${{ env.TAG }}
- name: Tag, package.json version, every version copy and the CHANGELOG agree
id: check
env:
TAG_IN: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
DRY: ${{ steps.mode.outputs.dry }}
run: |
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
export PATH="$HOME/.bun/bin:$PATH"
bun tools/desktop-version.ts
V=$(bun tools/desktop-version.ts --print)
if [ -n "$TAG_IN" ] && [ "$TAG_IN" != "v$V" ]; then echo "tag $TAG_IN is not v$V (package.json): bun tools/desktop-version.ts --set <version>, commit, tag that commit" >&2; exit 1; fi
grep -q "^## $V" CHANGELOG.md || { [ "$DRY" = true ] && echo "note: no CHANGELOG section for $V (the notes use Unreleased)"; } || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; }
if [ "$DRY" = true ]; then tag="test-$(git rev-parse --short=10 HEAD)"; else tag="$TAG_IN"; fi
echo "version=$V" >> "$GITHUB_OUTPUT"; echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "release $tag (version $V, dry=$DRY)"
ci:
needs: prepare
runs-on: ubuntu-22.04
timeout-minutes: 60
env:
CI: full
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: ci
rust-components: clippy
- uses: ./.forgejo/actions/ci-run
linux:
needs: prepare
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
# deb then run on Ubuntu 22.04 and newer, Debian 12)
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
# the path-free build keeps the target folder outside every home folder
CARGO_TARGET_DIR: /tmp/storebound-target
# linuxdeploy (AppImage) runs without FUSE inside the container
APPIMAGE_EXTRACT_AND_RUN: '1'
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
NO_STRIP: '1'
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: desktop
packages: patchelf file squashfs-tools p7zip-full xz-utils
- name: Install and check
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-linux-x64
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
- name: Build AppImage and deb (path-free)
working-directory: apps/desktop
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
- name: Self-test the exact artifacts
run: |
V=$(bun tools/desktop-version.ts --print)
B="$CARGO_TARGET_DIR/release/bundle"
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
chmod +x out/*.AppImage
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
- name: Start the app under Xvfb (AppImage and deb)
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
run: |
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
V=$(bun tools/desktop-version.ts --print)
smoke() { # <label> <executable>
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
local pid=$! ok=0
for i in $(seq 1 90); do
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
echo "$1: window started, UI reached the sidecar"
}
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
smoke deb /tmp/deb/usr/bin/storebound-desktop
- name: Privacy scan
run: bun tools/privacy-scan.ts out/
- name: Checksums
run: (cd out && sha256sum * | tee SHA256SUMS)
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-linux
path: out/
retention-days: 14
# The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed
# npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22
# (npx --package=<tgz>) / Bun (bun x). One red cell and there is no release. macOS runs on the owner's Mac
# (bun tools/acceptance.ts run ...) before the DMG upload; its result files live in docs/acceptance/.
acceptance:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Bun (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- name: Acceptance matrix (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
node --version && bun --version
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
- name: Acceptance matrix (Windows)
if: ${{ matrix.platform == 'Windows' }}
shell: pwsh
run: |
node --version; bun --version
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
path: acceptance/
retention-days: 30
acceptance-report:
needs: [prepare, acceptance]
if: ${{ always() && needs.prepare.result == 'success' }}
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Linux-node20
path: acceptance-runs/Linux-node20
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Linux-node22
path: acceptance-runs/Linux-node22
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Linux-bun
path: acceptance-runs/Linux-bun
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Windows-node20
path: acceptance-runs/Windows-node20
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Windows-node22
path: acceptance-runs/Windows-node22
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Windows-bun
path: acceptance-runs/Windows-bun
- name: Matrix (markdown), red cells fail the job
run: |
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
export PATH="$HOME/.bun/bin:$PATH"
V="${{ needs.prepare.outputs.version }}"
mkdir -p out
bun tools/acceptance.ts report acceptance-runs docs/acceptance --md "out/storebound_${V}_acceptance.md" --expect 9 --strict
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: storebound-acceptance
path: out/
retention-days: 30
windows:
needs: prepare
# a tag push always builds Windows; a manual run unless "windows" is unticked
if: ${{ github.event_name == 'push' || inputs.windows }}
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
runs-on: windows
timeout-minutes: 90
env:
CARGO_TARGET_DIR: C:\storebound-target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- name: Install and check
shell: pwsh
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-windows-x64
- name: Node bundle on Windows (npx storebound) - stdin prompts read with node, clean exit
shell: pwsh
run: |
bun tools/build-node.ts
node --version
bun tools/node-smoke.ts --only prompt
if ($LASTEXITCODE -ne 0) { throw "node prompt smoke failed" }
"n" | node dist/storebound.mjs --version
if ($LASTEXITCODE -ne 0) { throw "node dist/storebound.mjs --version failed" }
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
shell: pwsh
working-directory: apps/desktop
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
- name: Install per user, self-test, uninstall
shell: pwsh
run: |
$V = bun tools/desktop-version.ts --print
New-Item -ItemType Directory -Force out | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
& $exe --self-test | Tee-Object -Variable st
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
- name: Privacy scan (no .pdb, no paths)
shell: pwsh
run: bun tools/privacy-scan.ts out
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-windows
path: out/
retention-days: 14
release:
needs: [prepare, ci, acceptance, acceptance-report, linux, windows]
# a real release needs every platform and a green acceptance matrix; a dry run may skip Windows
if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.acceptance.result == 'success' && needs.acceptance-report.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }}
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
DRY: ${{ needs.prepare.outputs.dry }}
SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- name: Tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends squashfs-tools p7zip-full xz-utils binutils unzip file >/dev/null
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
V=$(bun tools/desktop-version.ts --print)
[ "$TAG" = "v$V" ] || { echo "tag $TAG is not v$V (package.json): bun tools/release.ts <version> makes the bump commit and the tag" >&2; exit 1; }
case "$V" in *-*) echo "prerelease $V: the CHANGELOG section is optional";; *) grep -q "^## $V" CHANGELOG.md || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; };; esac
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-linux
path: dist/linux
- uses: actions/download-artifact@v3
if: ${{ needs.windows.result == 'success' }}
with:
name: storebound-desktop-windows
path: dist/windows
# the acceptance matrix goes with the release (storebound_<version>_acceptance.md)
- uses: actions/download-artifact@v3
with:
name: storebound-acceptance
path: dist/acceptance
- name: Collect, privacy scan (private terms), checksums
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
mkdir -p out
find dist -type f ! -name SHA256SUMS -exec cp {} out/ \;
for f in out/*; do case "$f" in *"_${VERSION}_"*) ;; *) echo "$f is not version $VERSION" >&2; exit 1;; esac; done
[ -n "$FORBIDDEN_TERMS" ] || { [ "$DRY" = true ] && echo "note: secret FORBIDDEN_TERMS is missing: generic checks only"; } || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out
rm -f "$RUNNER_TEMP/terms.txt"
(cd out && sha256sum * | tee ../SHA256SUMS.local)
ls -la out
- name: Notes
run: |
bun tools/forgejo-release.ts notes "$VERSION" --tag "$([ "$DRY" = true ] && echo "v$VERSION" || echo "$TAG")" | tee notes.md
[ "$(wc -l < notes.md)" -le 8 ] || { echo "notes are longer than 8 lines" >&2; exit 1; }
- name: Release (create or update, attach, read back)
run: |
if [ "$DRY" = true ]; then
bun tools/forgejo-release.ts upsert --tag "$TAG" --target "$SHA" --title "$TAG (dry run, deleted at the end)" --notes-file notes.md --draft --yes out/*
else
bun tools/forgejo-release.ts upsert --tag "$TAG" --title "storebound $VERSION" --notes-file notes.md --yes out/*
fi
- name: Keep only the newest release (tags stay)
if: ${{ needs.prepare.outputs.dry != 'true' }}
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Promote the commit's green candidate
run: bun tools/release-candidate.ts promote --tag "$TAG" --sha "${{ steps.check.outputs.sha }}" --wait 40 ${{ steps.mode.outputs.dry == 'false' && '--yes' || '' }}
- name: Keep only the newest release (stable versions; tags stay)
if: ${{ steps.mode.outputs.dry == 'false' && !contains(steps.check.outputs.version, '-') }}
run: bun tools/forgejo-release.ts prune --keep "$TAG" --yes
- name: Dry run - what prune would delete
if: ${{ needs.prepare.outputs.dry == 'true' }}
run: |
KEEP=$(curl -fsS -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_URL/api/v1/repos/$GITHUB_REPOSITORY/releases?limit=1&draft=false" | bun -e 'const r=await Bun.stdin.json(); console.log(r[0]?.tag_name ?? "")')
[ -z "$KEEP" ] || bun tools/forgejo-release.ts prune --keep "$KEEP"
- name: Dry run - delete the test draft
if: ${{ always() && needs.prepare.outputs.dry == 'true' }}
run: bun tools/forgejo-release.ts delete --tag "$TAG" --yes