storebound/.forgejo/workflows/ci.yml

428 lines
22 KiB
YAML

# 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; no browser test runs here (tests/browser/, `bun run
# test:browser` by hand). The Rust steps run when the push changed a Rust input (STOREBOUND_CI_BASE).
# 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 installers already scanned with the private terms): 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, 'rehearsal/**']
pull_request:
branches: [main]
workflow_dispatch:
env:
BUN_VERSION: '1.4.0'
CARGO_TERM_COLOR: never
CARGO_INCREMENTAL: '0'
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: |
# tests never gate a release (the owner's decision 2026-10-16): no waiting on the parent's run, which the
# release's own bump push cancels; every commit builds its installers and runs the tests in the background
echo "reuse=false" >> "$GITHUB_OUTPUT"; echo "from=" >> "$GITHUB_OUTPUT"; echo "tests run in the background; installers build for this commit"
# background tests (the owner's decision 2026-10-16): they run on every push and report, but the record job (the
# release candidate) never waits for them; `bun tools/release.ts` and `bun tools/ci-status.ts` print a red result
ci:
needs: gate
if: github.event_name != 'pull_request' || true
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04
runs-on: ubuntu-22.04
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: ci
rust-components: clippy
- uses: ./.forgejo/actions/ci-run
# 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).
# after the installers: the one Windows runner builds the release installer first, the background tests wait
# (0.8.13's Windows installer queued behind six acceptance jobs)
acceptance:
needs: [gate, linux, windows]
if: github.event_name != 'pull_request' || true
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
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Bun (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- name: Acceptance matrix (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
node --version && bun --version
# npm pack runs prepack, which builds the package's declarations with the pinned TypeScript
bun install --frozen-lockfile
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
# npm pack runs prepack, which builds the package's declarations with the pinned TypeScript
bun install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw "bun install failed" }
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: 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"
# the AppImage tools come from GitHub releases, which answer 504 now and then (cached in ~/.cache/tauri)
bash ../../tools/ci-tauri-tools.sh
for i in 1 2 3; do bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci && break; [ $i = 3 ] && exit 1; sleep 30; done
- 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 (with the private terms; never printed)
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"
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out/ || rc=$?
rm -f "$RUNNER_TEMP/terms.txt"; exit $rc
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-linux
path: out/
retention-days: 7
# dev channel (tools/desktop-dev.ts): the same commit again with the version <next patch>-dev.<commit UTC>, only in
# this workspace (never committed); a failure here never touches the release candidate above
- name: Dev build (AppImage + deb, dev version)
id: dev
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
set -euo pipefail
V=$(bun tools/desktop-dev.ts set)
bun tools/build-sidecars.ts --target bun-linux-x64
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
bash tools/ci-tauri-tools.sh
for i in 1 2 3; do (cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci) && break; [ $i = 3 ] && exit 1; sleep 30; done
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
chmod +x dev/*.AppImage
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts dev/ || rc=$?
rm -f "$RUNNER_TEMP/terms.txt"; git checkout -- . ; exit $rc
- uses: actions/upload-artifact@v3
if: steps.dev.outcome == 'success'
with:
name: storebound-desktop-dev-linux
path: dev/
retention-days: 3
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; with the private terms, never printed)
shell: pwsh
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
if (-not $env:FORBIDDEN_TERMS) { throw "secret FORBIDDEN_TERMS is missing" }
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
$env:STOREBOUND_FORBIDDEN_TERMS = $t
bun tools/privacy-scan.ts out
$rc = $LASTEXITCODE
Remove-Item -Force $t
if ($rc -ne 0) { throw "privacy scan failed" }
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-windows
path: out/
retention-days: 7
# dev channel (tools/desktop-dev.ts): see the linux job
- name: Dev build (NSIS, dev version)
id: dev
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
shell: pwsh
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
$ErrorActionPreference = 'Stop'
$V = bun tools/desktop-dev.ts set
if ($LASTEXITCODE -ne 0) { throw "dev version stamp refused" }
bun tools/build-sidecars.ts --target bun-windows-x64
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
Push-Location apps/desktop
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
if ($LASTEXITCODE -ne 0) { throw "dev build failed" }
Pop-Location
New-Item -ItemType Directory -Force dev | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" dev\
Start-Process -Wait -FilePath "dev\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
& "$env:LOCALAPPDATA\storebound\storebound-desktop.exe" --self-test
if ($LASTEXITCODE -ne 0) { throw "dev self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
$env:STOREBOUND_FORBIDDEN_TERMS = $t
bun tools/privacy-scan.ts dev
$rc = $LASTEXITCODE
Remove-Item -Force $t
git checkout -- .
if ($rc -ne 0) { throw "privacy scan failed" }
- uses: actions/upload-artifact@v3
if: steps.dev.outcome == 'success'
with:
name: storebound-desktop-dev-windows
path: dev/
retention-days: 3
# dev channel: the dev bundles of both jobs go to a Forgejo draft dev-<version> (no tag; the newest 5 kept); the VDS
# (tools/vds/storebound-desktop-dev, a root timer) signs them with the updater key and publishes
# storebound.dev/desktop/dev/latest.json. macOS has no dev build: Developer ID signing needs the Mac's GUI session.
dev:
needs: [linux, windows]
if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.linux.result == 'success' && needs.windows.result == 'success' }}
runs-on: ubuntu-22.04
timeout-minutes: 40
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- name: Tools
run: |
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-dev-linux
path: dev
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-dev-windows
path: dev
- name: Draft dev-<version> (newest 5 kept), then wait for the VDS to publish it
run: |
ls -la dev
bun tools/desktop-dev.ts record --dir dev --yes
V=$(ls dev | sed -n 's/^storebound_\(.*\)_x64-setup\.exe$/\1/p')
bun tools/desktop-dev.ts wait "$V" --minutes 20
record:
needs: [gate, linux, windows]
if: ${{ always() && github.event_name != 'pull_request' && needs.gate.result == 'success' && needs.linux.result == 'success' && needs.windows.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
# no apt: the installers were scanned with the private terms in their own jobs; this job only records
- name: Tools
run: |
bash tools/ci-install-bun.sh
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
- name: Checksums file of the build jobs is not shipped as is
run: rm -f cand/SHA256SUMS
- 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