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

View file

@ -2,11 +2,23 @@
## Unreleased
Releases take one command and minutes: every push to main is a release candidate (full CI, the acceptance matrix and
the Linux / Windows installers stored per commit), a version tag only promotes it, and `bun tools/release.ts` does
the rest on the Mac.
- **A Chrome storebound starts never outlives its run.** The console window's Chrome is ended on exit, closed
gracefully on Ctrl+C / SIGTERM / SIGHUP, and ended by a watchdog when the run dies in any way (SIGKILL included).
Every start first ends headless storebound Chromes earlier runs left behind (an orphan kept the owner's own Chrome
from opening from the macOS Dock) and names orphaned windows. Headless runs on macOS prefer Chromium / Chrome for
Testing over Google Chrome.app. `console fill` closes the window on an error too. Pitfall 68.
- **Every push to main is a release candidate.** ci.yml runs the full CI, the acceptance matrix (Linux + Windows x
Node 20 / 22 / Bun) and the installer builds in parallel and stores the green result as the draft `rc-<sha>`.
A version-only bump commit (proven file by file) reuses its parent's CI and matrix and rebuilds only the
installers. A `v*` tag promotes the candidate (no rebuild, no retest) and fails at once on a red commit.
- **`bun tools/release.ts <version> --yes`:** bump and CHANGELOG cut, fast gate, commit, push, tag; the Mac build and
notarization in parallel with Forgejo; macOS acceptance cached per CLI key; add, verify, publish (each Mac file
crosses the owner's uplink once); /Applications/storebound.app updated; the npm commands printed. `--rehearsal`
runs it all as a throwaway prerelease and deletes it afterwards.
## 0.8.4 · 2026-10-14

View file

@ -31,55 +31,116 @@ in `~/Desktop/storebound-dist/desktop-0.1.0` was never published, so no installe
`tauri.conf.json`, `Cargo.toml` and the app's entry in `Cargo.lock`. CI runs this check; `src-tauri/tests/d5.rs`
checks the app's copies from the Rust side.
- `bun tools/desktop-version.ts --set 0.8.0` writes a new version into package.json and all of them.
- **Release order** (one commit, one version): see "The release on git.crt.fyi" below. The app goes first
- **Release order** (one commit, one version): see "The release in one command" below. The app goes first
(`publish` refuses a folder whose version is not package.json's), then `npm publish` of the same version
(CONTRIBUTING.md), so `npx storebound desktop` of the new CLI finds its own version.
## The release on git.crt.fyi
## The release in one command
One version tag, one Forgejo release: git.crt.fyi/CRT/storebound/releases keeps only the newest entry (older entries
are deleted after a successful release; their git tags stay). Notes are short: the CHANGELOG section's summary
paragraph (at most 4 lines), the install line and links, never the whole section. So the section's first paragraph
is the release summary: write it for that.
```sh
bun tools/release.ts 0.8.5 # checks and the plan
bun tools/release.ts 0.8.5 --yes # the release; then the owner's npm publish
```
1. **Version and changelog** on main: `bun tools/desktop-version.ts --set 0.8.0`, a `## 0.8.0 · <date>` section
that starts with a summary paragraph, `bun run ci`, push.
2. **Tag push:** `git tag v0.8.0 && git push origin v0.8.0`. `.forgejo/workflows/release.yml` then:
- checks that the tag is `v` + package.json's version, every version copy matches (tools/desktop-version.ts) and
the CHANGELOG has the section;
- runs the full CI (`bun run ci`, the same steps as ci.yml: `.forgejo/actions/linux-setup` and `ci-run`);
- builds the AppImage + deb (Linux runner) and the NSIS installer (Windows runner), with the checks described
under "Windows and Linux" below;
- scans every artifact with the private terms (secret `FORBIDDEN_TERMS`), writes SHA256SUMS;
- creates or updates the release "storebound 0.8.0" (`tools/forgejo-release.ts upsert`: found by tag, assets of
the same name replaced, SHA256SUMS merged, read back), then deletes the older release entries
(`forgejo-release.ts prune --keep v0.8.0`).
3. **macOS on the Mac** (the DMG cannot be built or notarized on these runners): `bun tools/desktop-release.ts mac`,
then `add` of the runners' Linux / Windows files (signs them for the updater, see below), then
`bun tools/desktop-release.ts upload-forgejo --out ~/.storebound-releases/desktop/0.8.0 --yes`: the signed DMG and
the updater bundle with their `.sig` join the same release, SHA256SUMS is merged. It refuses an unsigned DMG and
a release the workflow has not made yet. Token: `FORGEJO_TOKEN`, else the git credential helper of git.crt.fyi.
4. **storebound.dev:** `bun tools/desktop-release.ts publish --out ... --yes` (below).
5. **npm:** `npm publish` of the same version: the owner's step (CONTRIBUTING.md).
Option, not set up: an npm automation token as the Actions secret `NPM_TOKEN` and a manual `npm` job in
release.yml (`needs: release`, workflow_dispatch only, `npm publish` after the Mac's upload) would let the owner
publish with one click in Forgejo instead of a terminal. The token can publish any version of the package, so it
stays off until the owner wants it.
Target: tag push to everything published (the Forgejo release with every installer, storebound.dev's latest.json,
the app in /Applications) in under 10 minutes, with every gate still in place. It works because nothing is built or
tested twice: every push to main is already a release candidate.
**Dry run** (no version, nothing public): Actions > release > Run workflow with `dry` ticked (the default), or
`POST /api/v1/repos/CRT/storebound/actions/workflows/release.yml/dispatches` with
`{"ref":"main","inputs":{"dry":"on","windows":"on"}}` (over the API a boolean input is "on"; "true" counts as off).
The same jobs run on that commit; the release job makes a DRAFT `test-<sha>` with the real files, reads it back,
prints what `prune` would delete, and deletes the draft at the end (also when a step failed). `dry` off needs a `tag`
input and remakes that tag's release.
### Release candidates (every push to main)
`.forgejo/workflows/ci.yml` runs on every push to main, in parallel:
- `ci`: the full `bun run ci` (Rust tests and clippy included);
- `acceptance`: the release acceptance matrix, Linux and Windows x Node 20 / Node 22 / Bun (docs/acceptance.md);
- `linux`: the AppImage and the deb, self-tests, both started under Xvfb, privacy scan (the release target folder is
cached, so only changed crates compile);
- `windows`: the NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
When all are green, the `record` job scans the installers with the private terms (secret `FORBIDDEN_TERMS`), builds
the 6-run acceptance matrix, and stores everything as the **draft release `rc-<full sha>`** on git.crt.fyi:
installers, `SHA256SUMS`, the acceptance run files, `storebound_<version>_acceptance.md` and `green.json` (sha,
version, CLI key, run link, every file's SHA-256). `green.json` is uploaded last: a candidate without it is not
complete. The newest 12 candidates are kept (`tools/release-candidate.ts prune`); release pruning never touches them.
`bun tools/release-candidate.ts status --sha <sha>` says green (with the record), red (with the failed run's link),
pending or none.
**Version-only commits.** The release's bump commit changes only the version. ci.yml's first job, `gate`, proves it
file by file: every changed file must be a version copy of `tools/desktop-version.ts` whose new text is exactly the
parent's text with the new version written in by that same writer, or `CHANGELOG.md` / `docs/acceptance*`. Then the
parent's full CI and acceptance matrix count for the bump commit (the parent's run is waited for if it is still
running; a red parent fails the gate), and only the installers are built again, because their version is compiled in
(Cargo, tauri.conf.json, the bundled CLI, the file names). That is simpler and safer than making the installers
version-independent. The acceptance runs carry a **CLI key** (`tools/cli-key.ts`: a hash of everything the packed
CLI is made of, with the version copies normalized), and `acceptance.ts report --strict` accepts a run of another
version only when its key is the checkout's.
### The tag only promotes
`.forgejo/workflows/release.yml` on a `v*` tag checks the tag against package.json, every copy and the CHANGELOG
section, then `release-candidate.ts promote`: it waits for the tagged commit's candidate (heartbeat; fails at once
when the commit is red or has no ci run, with the run's link; 40 min timeout), checks its version, and makes the
release "storebound <version>" from the candidate's files (no rebuild, no retest). Stable versions then delete the
older release entries (tags stay). Versions with a "-" are prereleases: never pruning, never the stable release.
Run it by hand from Actions > release with a `tag` (dry by default: only the plan).
### What `tools/release.ts` does on the Mac
1. **Preflight:** a clean checkout equal to origin/main whose candidate is green or still running, the updater key,
the publish host (`STOREBOUND_PUBLISH_HOST` or `~/.storebound-keys/publish-host`). The macOS acceptance matrix
(Bun, Node 20, Node 22 from fnm) starts in the background unless its results for this CLI key are cached in
`~/.storebound-releases/acceptance-cache/<key>/` (a version bump keeps the key; `bun tools/release.ts
mac-acceptance` fills the cache ahead of time).
2. **Bump:** the version in package.json and every copy, the CHANGELOG's `## Unreleased` cut into
`## <version> · <date>` (its first paragraph is the release summary).
3. **Fast gate, commit, push:** `bun run ci:quick`, the commit as CRT, push to main, push the tag.
4. **In parallel:** `desktop-release.ts mac` (universal build, sign; Tauri notarizes the app while the boot check runs;
the DMG is notarized while the app and the updater bundle are checked, scanned and signed), Forgejo's ci run of the
bump commit (installers only) and release.yml's promotion. Meanwhile the candidate's Linux / Windows installers
and acceptance runs are downloaded.
5. **add, matrix, verify:** updater signatures of the Linux / Windows files, the 9-run acceptance matrix (strict) to
the release, `verify`.
6. **publish** with `--forgejo-tag`: the VDS fetches the Linux / Windows files from the Forgejo release over its own
network, rsync sends only the Mac's DMG and updater bundle, the VDS attaches those to the Forgejo release from its
copy, then the publish script swaps latest.json. The owner's uplink (about 0.5 to 1 MB/s) carries each Mac file
once instead of three times.
7. **/Applications/storebound.app:** the running app is asked to quit (an AppleEvent), then its exact PID gets
SIGTERM / SIGKILL if needed; the new app is copied from the DMG, verified (`codesign --verify --deep --strict`)
and reopened in the background (`open -g`).
8. **Prints** `npm publish` and `npm deprecate storebound@"<version>" ...` (the owner's step) and the time of every
phase (also in `~/.storebound-releases/timings/`). Logs: `~/.storebound-releases/logs/<version>/`.
Every wait has a timeout and a heartbeat; child processes are killed by PID when it exits.
**Notarization in parallel, where possible.** Apple notarizes the app (inside `tauri bundle`) and then the DMG. The
DMG cannot go first or at the same time: it must contain the app with its stapled ticket (the gate checks
`stapler validate` on the app inside the mounted DMG). So each wait for Apple runs next to other work instead: the
boot check during the app's notarization; every app and updater-bundle check, their privacy scan and the updater
signature during the DMG's.
**Rehearsal:** `bun tools/release.ts --rehearsal [<n>] --yes` runs all of it for version `0.0.0-rehearsal.<n>` on the
branch `rehearsal/<n>` (ci.yml runs there too): a prerelease on git.crt.fyi, the `rehearsal` channel on
storebound.dev (`/desktop/rehearsal/latest.json`, which no app reads; the publish script refuses rehearsal versions
on stable / beta and other versions on rehearsal), the app copied to a scratch folder (nothing quit or opened), never
npm. At the end it deletes the release and tag, the candidate, the branch, the version's files on storebound.dev and
the local folders, and prints the times.
### By hand (what release.ts runs)
- `bun tools/desktop-version.ts --set 0.8.5` and the CHANGELOG section, `bun run ci:quick`, commit, push, tag, push
the tag (release.yml promotes the candidate).
- `bun tools/desktop-release.ts mac --out ~/.storebound-releases/desktop/0.8.5`.
- `bun tools/release-candidate.ts fetch --sha <sha> --dir <dir>`, then `bun tools/desktop-release.ts add --out ...
--commit <sha> --run <run> <AppImage> <deb> <setup.exe>`.
- `bun tools/desktop-release.ts publish --out ... --forgejo-tag v0.8.5 --yes` (or `upload-forgejo`, then `publish`).
- `npm publish` of the same version: the owner's step (CONTRIBUTING.md).
**Secrets** (repo Settings > Actions > Secrets): `RELEASE_TOKEN`, a token of CRT with scope `write:repository`
(name `storebound-release-workflow`; releases, assets, the dry run's tag) and `FORBIDDEN_TERMS`, the contents of
`forbidden-terms.txt` (never printed: hits show `x***`), and `FORGEJO_ORIGIN`, the server's IP: git.crt.fyi is
behind Cloudflare, which refuses uploads over 100 MB (the AppImage, the DMG) with a 502, so `forgejo-release.ts`
uploads assets to the origin directly (`curl --resolve`, TLS still checked for git.crt.fyi). On the Mac the same IP
is in `~/.storebound-keys/forgejo-origin`. Rotate the token: create a new one under CRT's
Settings > Applications with that scope, put it into the secret, delete the old one.
(name `storebound-release-workflow`; releases, assets, candidates: ci.yml's gate and record jobs and release.yml use
it), `FORBIDDEN_TERMS`, the contents of `forbidden-terms.txt` (never printed: hits show `x***`), and
`FORGEJO_ORIGIN`, the server's IP: git.crt.fyi is behind Cloudflare, which refuses uploads over 100 MB (the AppImage,
the DMG) with a 502, so `forgejo-release.ts` uploads assets to the origin directly (`curl --resolve`, TLS still
checked for git.crt.fyi). On the Mac the same IP is in `~/.storebound-keys/forgejo-origin`. Rotate the token: create
a new one under CRT's Settings > Applications with that scope, put it into the secret, delete the old one.
## Keys and private files (outside every repository)
@ -197,8 +258,7 @@ and choose Open Anyway (once).
## Windows and Linux (Forgejo Actions)
The `linux` and `windows` jobs of `.forgejo/workflows/release.yml` (a `v*` tag push, or a dry run by hand; untick
"windows" in a dry run to build Linux only).
The `linux` and `windows` jobs of `.forgejo/workflows/ci.yml`, on every push to main (the release candidate).
- **Linux** (`runs-on: ubuntu-22.04`, a container): AppImage and .deb built with the path-free environment, then:
- self-tests of the extracted AppImage and the unpacked .deb;
@ -233,9 +293,9 @@ installer compare with `Get-FileHash` of `C:\storebound-target\release\bundle\ns
### Runner setup
Forgejo runs in Docker on the VDS. `.forgejo/workflows/ci.yml` (`bun run ci`, Rust tests and clippy included) runs on
every push and pull request to main; `release.yml` as above makes the Forgejo release (Jenkins still publishes
site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public repository can be downloaded by anyone for 14 days.
Forgejo runs in Docker on the VDS. `.forgejo/workflows/ci.yml` (the release candidate: `bun run ci`, the acceptance
matrix, the installers) runs on every push to main and `ci` + `acceptance` on pull requests; `release.yml` promotes a
candidate on a tag (Jenkins still publishes site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public repository can be downloaded by anyone for 14 days.
1. **Actions in Forgejo (done).** `FORGEJO__actions__ENABLED=true` and
`FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org` in the compose file; the Actions unit is on for
@ -292,7 +352,6 @@ site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public
- **Shared runner:** any workflow of any CRT repository with `runs-on: windows` runs here, as `runner`, with
internet access. Keep workflows of fork pull requests off it (as ci.yml does for Linux).
The workflow's Windows job runs only when "windows" is ticked (from the API, `"inputs": {"windows": "on"}`).
4. **Windows code signing (later).** Buy a code-signing certificate, then sign the installer before `add` and pass
`--code-signed`.
@ -301,7 +360,7 @@ site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public
| URL | served from | how it gets there |
|---|---|---|
| /#download (the downloads, a section of the home page; /desktop/ redirects there) | /var/www/storebound.dev/index.html, /var/www/storebound.dev/desktop/index.html | `site/index.html`, `site/desktop/index.html`, with the site (Jenkins + /usr/local/sbin/storebound-publish) |
| /desktop/latest.json, /desktop/beta/latest.json | /var/www/storebound-desktop/ | storebound-desktop-publish |
| /desktop/latest.json, /desktop/beta/latest.json, /desktop/rehearsal/latest.json | /var/www/storebound-desktop/ | storebound-desktop-publish |
| /desktop/dl/<version>/<files> | /var/www/storebound-desktop/dl/ | storebound-desktop-publish |
**Why the binaries live outside the site's document root.** The site's publish script rsyncs `site/` with
@ -313,7 +372,7 @@ Apache maps the three paths with `Include /etc/apache2/storebound-desktop.conf`
- version folders are sent `immutable`;
- no directory listings.
**`/usr/local/sbin/storebound-desktop-publish <version> [stable|beta]`** (`tools/vds/storebound-desktop-publish`,
**`/usr/local/sbin/storebound-desktop-publish <version> [stable|beta|rehearsal]`** (`tools/vds/storebound-desktop-publish`,
root:root 755) takes `/var/lib/storebound-desktop/incoming/<version>/` (root only, mode 700) and checks it:
- only plain files with plain names;
@ -358,7 +417,9 @@ bun tools/desktop-release.ts publish --out ~/.storebound-releases/desktop/0.1.0
3. **Publishes:** runs the publish script.
4. **Confirms:** reads the live latest.json back.
`--channel beta` publishes to /desktop/beta/latest.json, which apps on the Beta channel read.
`--channel beta` publishes to /desktop/beta/latest.json, which apps on the Beta channel read. `--channel rehearsal`
(release rehearsals only) to /desktop/rehearsal/latest.json, which no app reads. `--forgejo-tag v<version>`: the
server fetches the files the Forgejo release already has and attaches the Mac's files to it (see above).
**Rollback.** Tauri updates only to a higher version, so a bad release is replaced by a fixed higher version. Its
files stay where they are, because links and checksums must not change.

View file

@ -136,7 +136,7 @@ describe('architecture guard (no Rosetta notice for users)', () => {
});
test('the release runs the guard before signing (tauri build --no-sign, then tauri bundle)', () => {
const src = readFileSync(join(import.meta.dir, '..', 'tools/desktop-release.ts'), 'utf8');
const build = src.indexOf("'--no-sign'"), guard = src.indexOf("archGuard(join(targetDir"), bundle = src.indexOf("['tauri', 'bundle'");
const build = src.indexOf("'--no-sign'"), guard = src.indexOf("archGuard(join(targetDir"), bundle = src.indexOf("'tauri', 'bundle'");
expect(build).toBeGreaterThan(0);
expect(guard).toBeGreaterThan(build);
expect(bundle).toBeGreaterThan(guard);

View file

@ -0,0 +1,113 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { cliKeyOf, keyContent, keyPaths } from '../tools/cli-key';
import { staleReason } from '../tools/acceptance';
import { channelUrl, seedPlan } from '../tools/desktop-release';
import { COPIES } from '../tools/desktop-version';
import { candidateTag, isCandidateTag, isTestTag, pruneList, type Release } from '../tools/forgejo-release';
import { isPrerelease, tagVersion, taskState, type Task, versionOnlyProblems } from '../tools/release-candidate';
import { cutChangelog, newer, timingTable } from '../tools/release';
const ROOT = join(import.meta.dir, '..');
const read = (f: string) => readFileSync(join(ROOT, f), 'utf8');
const SHA = 'a'.repeat(40);
const bump = (to: string) => COPIES.map(c => ({ file: c.file, status: 'M', before: read(c.file), after: c.write(read(c.file), to) }));
describe('version-only commits (ci.yml gate)', () => {
const from = COPIES[0]!.read(read('package.json'))!;
test('a bump of every copy plus the changelog is version-only', () => {
const r = versionOnlyProblems([...bump('9.9.9'), { file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }, { file: 'docs/acceptance/macOS-bun.json', status: 'M', before: '{}', after: '[]' }]);
expect(r).toEqual({ from, to: '9.9.9', problems: [] });
});
test('any other file, or a copy with one more change, is not', () => {
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'cli/index.ts', status: 'M', before: 'a', after: 'b' }]).problems).toEqual(['cli/index.ts: not a version copy (M)']);
const sneaky = bump('9.9.9').map(c => (c.file === 'package.json' ? { ...c, after: c.after.replace('"type": "module"', '"type": "commonjs"') } : c));
expect(versionOnlyProblems(sneaky).problems).toEqual(['package.json: changes more than the version']);
const dep = bump('9.9.9').map(c => (c.file === 'apps/desktop/src-tauri/Cargo.lock' ? { ...c, after: `${c.after}\n# extra` } : c));
expect(versionOnlyProblems(dep).problems).toEqual(['apps/desktop/src-tauri/Cargo.lock: changes more than the version']);
expect(versionOnlyProblems([{ file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }]).problems[0]).toMatch(/version does not change/);
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'docs/acceptance/x.json', status: 'A', before: null, after: '{}' }]).problems).toEqual([]);
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'gemini-extension.json', status: 'D', before: '{}', after: null }]).problems.some(p => /gemini-extension\.json: D/.test(p))).toBe(true);
});
});
describe('candidate state from the ci.yml jobs', () => {
const t = (name: string, status: string, run = 5, sha = SHA, wf = 'ci.yml'): Task => ({ id: 1, name, run_number: run, workflow_id: wf, head_sha: sha, status });
const url = (n: number) => `run/${n}`;
test('none, pending, red; the newest run counts', () => {
expect(taskState([t('ci', 'success', 5, 'b'.repeat(40))], SHA, url)).toEqual({ state: 'none' });
expect(taskState([t('ci', 'success'), t('windows', 'running')], SHA, url)).toEqual({ state: 'pending', run: 'run/5', running: ['windows (running)'] });
expect(taskState([t('ci', 'success'), t('acceptance (bun)', 'failure')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['acceptance (bun)'] });
expect(taskState([t('ci', 'failure', 4), t('ci', 'running', 6)], SHA, url).state).toBe('pending');
expect(taskState([t('promote', 'failure', 9, SHA, 'release.yml')], SHA, url)).toEqual({ state: 'none' });
});
test('tags and prereleases', () => {
expect(tagVersion('v0.8.5')).toBe('0.8.5');
expect(tagVersion('v0.0.0-rehearsal.3')).toBe('0.0.0-rehearsal.3');
expect(tagVersion('0.8.5')).toBeNull();
expect(isPrerelease('0.0.0-rehearsal.3')).toBe(true);
expect(isPrerelease('0.8.5')).toBe(false);
});
});
describe('forgejo: candidates and rehearsals', () => {
const rel = (id: number, tag: string, draft = false): Release => ({ id, tag_name: tag, name: tag, draft, prerelease: false, body: '', assets: [] });
test('prune keeps candidate drafts; rehearsal tags are deletable test tags', () => {
const rc = candidateTag(SHA);
expect(isCandidateTag(rc)).toBe(true);
expect(pruneList([rel(1, 'v0.8.4'), rel(2, 'v0.8.5'), rel(3, rc, true)], 'v0.8.5').remove.map(r => r.tag_name)).toEqual(['v0.8.4']);
expect(isTestTag('v0.0.0-rehearsal.12')).toBe(true);
expect(isTestTag('v0.8.5')).toBe(false);
expect(isTestTag('v0.0.1-rehearsal.1')).toBe(false);
});
test('seed plan: files the server fetches from the release by hash (renamed installer too)', () => {
const h = (c: string) => c.repeat(64);
const sums = `${h('a')} storebound_1.0.0_amd64.AppImage\n${h('b')} storebound_1.0.0_x64-setup.exe\n`;
const assets = [{ name: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a' }, { name: 'storebound_1.0.0_x64-setup.exe', url: 'https://x/b' }];
expect(seedPlan([{ file: 'storebound_1.0.0_amd64.AppImage', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', sha256: h('b') }, { file: 'storebound_1.0.0_universal.dmg', sha256: h('c') }], sums, assets))
.toEqual([{ file: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', url: 'https://x/b', sha256: h('b') }]);
expect(channelUrl('stable')).toBe('https://storebound.dev/desktop/latest.json');
expect(channelUrl('rehearsal')).toBe('https://storebound.dev/desktop/rehearsal/latest.json');
});
});
describe('CLI key and acceptance runs of a version-only parent', () => {
test('the key ignores the version copies and the prose, not the code', () => {
const files: Record<string, string> = { 'package.json': read('package.json'), 'cli/a.ts': 'x', 'CHANGELOG.md': 'one' };
const key = (f: Record<string, string>) => cliKeyOf(Object.keys(f), n => Buffer.from(f[n]!));
const base = key(files);
expect(key({ ...files, 'package.json': COPIES[0]!.write(files['package.json']!, '9.9.9'), 'CHANGELOG.md': 'two' })).toBe(base);
expect(key({ ...files, 'cli/a.ts': 'y' })).not.toBe(base);
expect(keyContent('cli/a.ts', Buffer.from('0.8.4')).toString()).toBe('0.8.4');
expect(keyPaths(['dist/storebound.mjs', 'cli'])).toContain('tests/fixtures');
expect(keyPaths(['dist/storebound.mjs', 'cli'])).not.toContain('dist/storebound.mjs');
});
test('a run of another version counts only with the same CLI key', () => {
expect(staleReason({ version: '0.8.4', quick: false }, '0.8.4', 'k')).toBeNull();
expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'k' }, '0.8.5', 'k')).toBeNull();
expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'j' }, '0.8.5', 'k')).toMatch(/other CLI code/);
expect(staleReason({ version: '0.8.4', quick: false }, '0.8.5', 'k')).toMatch(/version 0.8.4/);
expect(staleReason({ version: '0.8.5', quick: true, cliKey: 'k' }, '0.8.5', 'k')).toBe('a quick run');
});
});
describe('release.ts', () => {
test('changelog cut', () => {
const cl = '# Changelog\n\n## Unreleased\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n';
expect(cutChangelog(cl, '0.8.5', '2026-10-15')).toBe('# Changelog\n\n## Unreleased\n\n## 0.8.5 · 2026-10-15\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n');
expect(() => cutChangelog('# Changelog\n\n## Unreleased\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/empty/);
expect(() => cutChangelog('# Changelog\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/Unreleased/);
expect(() => cutChangelog(cl, '0.8.4', 'd')).toThrow(/already/);
});
test('versions and the timing table', () => {
expect(newer('0.8.5', '0.8.4')).toBe(true);
expect(newer('0.10.0', '0.9.9')).toBe(true);
expect(newer('0.8.4', '0.8.4')).toBe(false);
expect(newer('0.8.5', '0.8.5-rc.1')).toBe(true);
expect(newer('0.8.5-rc.1', '0.8.5')).toBe(false);
const tbl = timingTable([{ name: 'a', start: 1000, end: 61_000 }, { name: 'b', start: 2000, end: 302_000 }], 1000);
expect(tbl).toContain('took 1:00');
expect(tbl).toMatch(/total .* 5:01/);
});
});

View file

@ -21,6 +21,7 @@ import { FakeAsc, FakePlay } from '../tests/helpers/fake-stores';
import { makeAab } from '../tests/build-fixtures';
import { ALPHA3 } from '../cli/lib/money';
import { CURRENCY } from '../cli/lib/ppp';
import { cliKey } from './cli-key';
const ROOT = resolve(import.meta.dir, '..');
const PKG = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { version: string };
@ -492,7 +493,7 @@ async function run(argv: string[]): Promise<void> {
const failed = results.filter(r => !r.ok);
const out = resolve(arg('--out') ?? join(ROOT, `acceptance-${platform}-${rt.name.replace(/\s+/g, '')}.json`));
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, JSON.stringify({ platform, runtime: rt.name, label: rt.label, version: PKG.version, quick, at: new Date().toISOString(), results }, null, 2));
writeFileSync(out, JSON.stringify({ platform, runtime: rt.name, label: rt.label, version: PKG.version, cliKey: cliKey(ROOT), quick, at: new Date().toISOString(), results }, null, 2));
console.log(`acceptance: ${results.length - failed.length}/${results.length} cells passed in ${Math.round((Date.now() - t0) / 1000)} s -> ${out}`);
if (failed.length) {
console.log(`acceptance: failed cells keep their folder and output.txt under ${work}`);
@ -504,7 +505,17 @@ async function run(argv: string[]): Promise<void> {
// ------------------------------------------------------------------------------------------------- report
interface RunFile { platform: string; runtime: string; label: string; version: string; quick: boolean; at: string; results: Result[] }
interface RunFile { platform: string; runtime: string; label: string; version: string; cliKey?: string; quick: boolean; at: string; results: Result[] }
/** Why a run does not count for this checkout (pure): a quick run, or another version whose CLI code differs. A run
* of another version counts when its CLI key (tools/cli-key.ts) is this checkout's: the commits differ only in
* version copies and prose (a release's version bump), so the CLI under test is the same code. */
export function staleReason(r: Pick<RunFile, 'version' | 'quick' | 'cliKey'>, version: string, key: string): string | null {
if (r.quick) return 'a quick run';
if (r.version === version) return null;
if (r.cliKey && r.cliKey === key) return null;
return `version ${r.version}, not ${version}${r.cliKey ? ' (other CLI code)' : ''}`;
}
export function renderReport(runs: RunFile[]): string {
const cols = runs.map(r => `${r.platform} · ${r.runtime}`);
@ -548,9 +559,11 @@ function report(argv: string[]): void {
runs.sort((a, b) => rank(a) - rank(b));
writeFileSync(md, renderReport(runs));
const bad = runs.flatMap(r => r.results.filter(x => !x.ok));
const stale = runs.filter(r => r.version !== PKG.version || r.quick);
const key = cliKey(ROOT);
const stale = runs.map(r => ({ r, why: staleReason(r, PKG.version, key) })).filter(x => x.why);
console.log(`acceptance report: ${runs.length} runs, ${bad.length} red cells -> ${md}`);
for (const r of stale) console.log(` ${r.platform} · ${r.runtime}: ${r.quick ? 'a quick run' : `version ${r.version}, not ${PKG.version}`}`);
for (const { r, why } of stale) console.log(` ${r.platform} · ${r.runtime}: ${why}`);
for (const r of runs) if (r.version !== PKG.version && !staleReason(r, PKG.version, key)) console.log(` ${r.platform} · ${r.runtime}: run on ${r.version}, same CLI code (key ${key.slice(0, 12)}): counts for ${PKG.version}`);
const missing = expect && runs.length < expect ? `${expect - runs.length} of ${expect} runs missing` : '';
if (missing) console.log(` ${missing}`);
if (argv.includes('--strict') && (bad.length || stale.length || missing)) process.exit(1);

48
tools/cli-key.ts Normal file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bun
// The CLI key: one hash of everything that decides how the packed npm CLI behaves in the acceptance matrix
// (tools/acceptance.ts), with the version copies normalized. Two commits with the same key run the same CLI code; a
// version bump (tools/desktop-version.ts --set) does not change it. Acceptance results carry the key, so the results
// of a commit count for a version-only child (tools/release-candidate.ts) and the Mac reuses its cached macOS results
// (tools/release.ts) until the CLI code really changes.
// bun tools/cli-key.ts print the key of the working tree (tracked files only)
// In the key: the npm package's files (package.json "files") without the prose that ships with it (CHANGELOG.md,
// README.md, docs/*.md: the CLI never reads storebound's own docs), package.json, bun.lock and what the acceptance
// run itself uses (tools/acceptance.ts, tools/build-node.ts, tests/helpers, tests/build-fixtures.ts, tests/fixtures).
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { COPIES } from './desktop-version';
const ROOT = resolve(import.meta.dir, '..');
const PROSE = /^(CHANGELOG\.md|README\.md|docs\/[^/]+\.md)$/;
const EXTRA = ['package.json', 'bun.lock', 'tools/acceptance.ts', 'tools/build-node.ts', 'tools/cli-key.ts', 'tools/desktop-version.ts', 'tests/helpers', 'tests/build-fixtures.ts', 'tests/fixtures'];
const NORMAL = '0.0.0-cli-key';
/** The paths of the key (git pathspecs). Pure. */
export function keyPaths(pkgFiles: string[]): string[] {
return [...new Set([...pkgFiles.filter(f => f !== 'dist/storebound.mjs'), ...EXTRA])].sort();
}
/** One file's content in the key: the version copies with the version normalized. Pure. */
export function keyContent(file: string, text: Buffer): Buffer {
const copy = COPIES.find(c => c.file === file);
return copy ? Buffer.from(copy.write(text.toString('utf8'), NORMAL)) : text;
}
/** sha256 over (path, content) of every tracked file of the key, sorted. Pure given the reader. */
export function cliKeyOf(files: string[], read: (f: string) => Buffer): string {
const h = createHash('sha256');
for (const f of [...files].filter(f => !PROSE.test(f)).sort()) { h.update(`${f}\0`); h.update(keyContent(f, read(f))); h.update('\0'); }
return h.digest('hex').slice(0, 32);
}
export function cliKey(root = ROOT): string {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { files: string[] };
const ls = spawnSync('git', ['ls-files', '-z', '--', ...keyPaths(pkg.files)], { cwd: root, encoding: 'utf8', maxBuffer: 64 << 20 });
if (ls.status !== 0) throw new Error(`git ls-files: ${ls.stderr}`);
const files = ls.stdout.split('\0').filter(f => f && existsSync(join(root, f)));
return cliKeyOf(files, f => readFileSync(join(root, f)));
}
if (import.meta.main) console.log(cliKey());

View file

@ -16,9 +16,12 @@
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and
// evidence-<linux|windows>-<version>.json (the run and commit they came from; a commit other than the one of the
// folder's other evidence is refused: every artifact of a version comes from one commit).
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta] [--yes]
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta|rehearsal]
// [--forgejo-tag v<version>] [--yes]
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run).
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run). --forgejo-tag: the
// server fetches the files the Forgejo release already has, and attaches the Mac's DMG and updater bundle to it
// from its copy (one upload over the owner's line instead of three; tools/release.ts uses this).
// The folder's version must be package.json's: the npm CLI and the app are released together, the app first,
// then `npm publish` of the same version.
// bun tools/desktop-release.ts upload-forgejo --out <dir> [--tag v<version>] [--yes]
@ -40,7 +43,7 @@ import { type Download, type DownloadKey, type Manifest, manifestProblems } from
import { verify as verifySig } from '../cli/lib/minisign';
import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
import { versions } from './desktop-version';
import { upsert as forgejoUpsert } from './forgejo-release';
import { listReleases, upsert as forgejoUpsert } from './forgejo-release';
import { isMacArtifact, scanSignedMac } from './mac-signature-scan';
import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan';
@ -165,6 +168,19 @@ function run(cmd: string[], opts: SpawnSyncOptions & { quiet?: boolean; allowFai
return res;
}
/** A command in the background (notarytool waiting for Apple, the boot check) while this process checks other
* artifacts. Output is collected; the caller decides what a failure means. */
function runAsync(cmd: string[], opts: { cwd?: string; env?: Record<string, string | undefined>; timeoutMs?: number; inherit?: boolean } = {}): Promise<{ code: number; out: string; err: string; ms: number }> {
const start = Date.now();
const p = Bun.spawn(cmd, { cwd: opts.cwd, env: (opts.env ?? process.env) as Record<string, string>, stdin: 'ignore', stdout: opts.inherit ? 'inherit' : 'pipe', stderr: opts.inherit ? 'inherit' : 'pipe' });
const timer = opts.timeoutMs ? setTimeout(() => p.kill(), opts.timeoutMs) : null;
const text = (s: ReadableStream<Uint8Array> | number | undefined | null) => (s && typeof s !== 'number' ? new Response(s).text() : Promise.resolve(''));
return Promise.all([text(p.stdout as ReadableStream<Uint8Array>), text(p.stderr as ReadableStream<Uint8Array>), p.exited]).then(([out, err, code]) => {
if (timer) clearTimeout(timer);
return { code, out, err, ms: Date.now() - start };
});
}
const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
const mb = (n: number) => `${(n / 1e6).toFixed(1)} MB`;
const confPubkey = (): string => JSON.parse(readFileSync(join(TAURI_DIR, 'tauri.conf.json'), 'utf8')).plugins.updater.pubkey;
@ -442,27 +458,25 @@ async function mac(argv: string[]): Promise<void> {
{ cwd: APP_DIR, env: unsignedEnv as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`);
const preSignGuard = archGuard(join(targetDir, 'universal-apple-darwin/release/bundle/macos', APP_NAME), 'unsigned app (before signing)');
say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'})`);
const tbs = spawnSync('bunx', ['tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile],
{ cwd: APP_DIR, env: env as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
if (tbs.status !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.status})`);
// the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
// in parallel: tauri bundle signs, notarizes (Apple's queue: minutes) and staples the app, while the boot check
// runs the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
// Object.prototype with old saved states (tools/desktop-boot-check.ts); a blank window never ships again
say('boot check of the built window (empty, D1 with folders gone, D3 saved state; missing bundle panel)');
const boot = spawnSync('bun', [join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, encoding: 'utf8', timeout: 600_000 });
process.stdout.write(boot.stdout ?? '');
if (boot.status !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${(boot.stderr ?? '').trim().split('\n').slice(-3).join(' / ')}`);
const bootCheck = (boot.stdout ?? '').trim().split('\n').at(-1) ?? 'ok';
say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'}), boot check of the built window at the same time`);
const bootP = runAsync(['bun', join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, timeoutMs: 600_000 });
const tbs = await runAsync(['bunx', 'tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile], { cwd: APP_DIR, env, inherit: true, timeoutMs: 3_600_000 });
say(`tauri bundle done in ${(tbs.ms / 1000).toFixed(0)} s`);
const boot = await bootP;
process.stdout.write(boot.out);
if (tbs.code !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.code})`);
if (boot.code !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${boot.err.trim().split('\n').slice(-3).join(' / ')}`);
say(`boot check passed in ${(boot.ms / 1000).toFixed(0)} s`);
const bootCheck = boot.out.trim().split('\n').at(-1) ?? 'ok';
const bundleDir = join(targetDir, 'universal-apple-darwin/release/bundle/macos');
const app = join(bundleDir, APP_NAME);
if (!existsSync(app)) fail(`no ${APP_NAME} in ${bundleDir}`);
const dsyms = readdirSync(bundleDir).filter(n => n.endsWith('.dSYM'));
if (dsyms.length) fail(`debug symbols next to the app: ${dsyms.join(', ')}`);
const arch: Record<string, ArchGuard> = { preSign: preSignGuard, app: archGuard(app, 'signed app') };
const checks: Record<string, Gate> = { app: gatekeeper(app, signed, notarized, 'built app') };
const tests: Record<string, SelfTest[]> = { app: selfTest(app, 'built app') };
// updater bundle: the app as Tauri's updater expects it (tar.gz, no macOS metadata, root-owned entries)
const updaterName = artifactName(version, 'updater', signed);
const updater = join(out, updaterName);
@ -479,18 +493,38 @@ async function mac(argv: string[]): Promise<void> {
say(`DMG ${dmgName}`);
run(['hdiutil', 'create', '-volname', PRODUCT, '-srcfolder', stage, '-fs', 'HFS+', '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-ov', dmg]);
rmSync(stage, { recursive: true, force: true });
// the DMG goes to Apple now; while Apple checks it, this process checks the app and the updater bundle (they
// carry the app's own stapled ticket and do not depend on the DMG's)
let dmgNotary: Promise<{ code: number; out: string; err: string; ms: number }> | null = null;
if (signed) {
run(['codesign', '--force', '--sign', identity!, '--timestamp', dmg]);
if (notarized) {
say('notarytool submit (DMG), waiting for Apple');
const n = run(['xcrun', 'notarytool', 'submit', dmg, '--key', notary!.APPLE_API_KEY_PATH!, '--key-id', notary!.APPLE_API_KEY!, '--issuer', notary!.APPLE_API_ISSUER!, '--wait', '--output-format', 'json'], { allowFail: true, timeout: 3_600_000 });
const status = (() => { try { return (JSON.parse(n.out) as { status?: string }).status; } catch { return undefined; } })();
if (status !== 'Accepted') fail(`notarization of the DMG: ${status ?? (n.err.trim() || n.out.trim() || `notarytool exited ${n.code} without an answer (killed or timed out): run again; xcrun notarytool history shows the submission`)}`);
run(['xcrun', 'stapler', 'staple', dmg]);
run(['xcrun', 'stapler', 'validate', dmg]);
say('notarytool submit (DMG): checking the app and the updater bundle while Apple checks the DMG');
dmgNotary = runAsync(['xcrun', 'notarytool', 'submit', dmg, '--key', notary!.APPLE_API_KEY_PATH!, '--key-id', notary!.APPLE_API_KEY!, '--issuer', notary!.APPLE_API_ISSUER!, '--wait', '--output-format', 'json'], { timeoutMs: 3_600_000 });
}
}
const arch: Record<string, ArchGuard> = { preSign: preSignGuard, app: archGuard(app, 'signed app') };
const checks: Record<string, Gate> = { app: gatekeeper(app, signed, notarized, 'built app') };
const tests: Record<string, SelfTest[]> = { app: selfTest(app, 'built app') };
const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-'));
run(['tar', '-xzf', updater, '-C', unpacked]);
arch.updaterApp = archGuard(join(unpacked, APP_NAME), 'app in the updater bundle');
checks.updaterApp = gatekeeper(join(unpacked, APP_NAME), signed, notarized, 'app in the updater bundle');
tests.updater = selfTest(join(unpacked, APP_NAME), 'updater bundle');
rmSync(unpacked, { recursive: true, force: true });
const privacyApp = privacyGate([app, updater]);
const updaterSig = signFile(updater);
if (dmgNotary) {
const n = await dmgNotary;
const status = (() => { try { return (JSON.parse(n.out) as { status?: string }).status; } catch { return undefined; } })();
if (status !== 'Accepted') fail(`notarization of the DMG: ${status ?? (n.err.trim() || n.out.trim() || `notarytool exited ${n.code} without an answer (killed or timed out): run again; xcrun notarytool history shows the submission`)}`);
say(`DMG notarized in ${(n.ms / 1000).toFixed(0)} s`);
run(['xcrun', 'stapler', 'staple', dmg]);
run(['xcrun', 'stapler', 'validate', dmg]);
}
// the exact artifacts: mount the DMG and unpack the updater bundle, then self-test what is inside
const mnt = mkdtempSync(join(tmpdir(), 'sb-mnt-'));
run(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]);
@ -512,18 +546,12 @@ async function mac(argv: string[]): Promise<void> {
dmgStapler = 'ticket stapled, validated';
}
say(`DMG: spctl -t open ${dmgSpctl}; ${dmgStapler}`);
const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-'));
run(['tar', '-xzf', updater, '-C', unpacked]);
arch.updaterApp = archGuard(join(unpacked, APP_NAME), 'app in the updater bundle');
checks.updaterApp = gatekeeper(join(unpacked, APP_NAME), signed, notarized, 'app in the updater bundle');
tests.updater = selfTest(join(unpacked, APP_NAME), 'updater bundle');
rmSync(unpacked, { recursive: true, force: true });
// privacy: the app folder, the DMG (mounted) and the updater bundle (unpacked)
const privacy = privacyGate([app, dmg, updater]);
// privacy: the app folder and the updater bundle (unpacked) above, the DMG (mounted) now
const privacyDmg = privacyGate([dmg]);
const privacy = { files: privacyApp.files + privacyDmg.files, bytes: privacyApp.bytes + privacyDmg.bytes, hits: 0 };
// signatures with the updater key, latest.json, SHA256SUMS
const updaterSig = signFile(updater);
// signatures with the updater key (the updater bundle's above), latest.json, SHA256SUMS
const dmgSig = signFile(dmg);
const notes = a.get('notes') ?? `storebound desktop ${version}`;
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
@ -684,27 +712,78 @@ export function publishSteps(o: { host: string; v: string; channel: string; out:
];
}
function publish(argv: string[]): void {
/** The channels of storebound.dev/desktop: stable (latest.json), beta, and rehearsal (tools/release.ts --rehearsal:
* a throwaway prerelease that no app reads). */
export const CHANNELS = ['stable', 'beta', 'rehearsal'] as const;
export const channelUrl = (channel: string): string => (channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/${channel}/latest.json`);
/**
* Files the server can fetch from the Forgejo release instead of the owner's uplink (pure): every file of the folder
* whose SHA-256 is in the release's SHA256SUMS under some asset name (the Windows installer is renamed -unsigned
* here). Each is downloaded on the server into the incoming folder before rsync, which then only compares checksums.
*/
export function seedPlan(local: { file: string; sha256: string }[], releaseSums: string, assets: { name: string; url: string }[]): { file: string; url: string; sha256: string }[] {
const byHash = new Map<string, string>();
for (const l of releaseSums.split('\n')) { const m = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(l.trim()); if (m) byHash.set(m[1]!, m[2]!); }
const out: { file: string; url: string; sha256: string }[] = [];
for (const f of local) {
const name = byHash.get(f.sha256);
const asset = name ? assets.find(x => x.name === name) : undefined;
if (asset) out.push({ file: f.file, url: asset.url, sha256: f.sha256 });
}
return out;
}
async function publish(argv: string[]): Promise<void> {
const a = args(argv);
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? fail('--host <user@host> (or STOREBOUND_PUBLISH_HOST) is required');
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
const channel = a.get('channel') ?? 'stable';
if (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta');
if (!(CHANNELS as readonly string[]).includes(channel)) fail(`--channel ${CHANNELS.join(', ')}`);
const { manifest, files } = verifyFolder(out);
const v = manifest.version;
const together = releaseTogetherProblems(v, versions().version);
if (together.length) fail(together.join('; '));
const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file);
const steps = publishSteps({ host, v, channel, out, files });
console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`}):`);
const forgejoTag = a.get('forgejo-tag');
if (forgejoTag && forgejoTag !== `v${v}`) fail(`--forgejo-tag ${forgejoTag} is not v${v}`);
// with --forgejo-tag: the server takes the Linux / Windows files from the Forgejo release (its own network), and
// after the rsync attaches the Mac's files to that release from its copy, so every byte crosses the owner's
// uplink once (the DMG and the updater bundle), not three times
const rel = forgejoTag ? (await listReleases()).find(r => r.tag_name === forgejoTag) : undefined;
if (forgejoTag && !rel) fail(`no Forgejo release ${forgejoTag} yet (release.yml promotes the candidate on the tag push)`);
const relSums = rel?.assets.find(x => x.name === 'SHA256SUMS');
const sumsText0 = relSums ? spawnSync('curl', ['-fsSL', relSums.browser_download_url], { encoding: 'utf8' }).stdout ?? '' : '';
const seed = rel ? seedPlan(files.map(f => ({ file: f, sha256: sha256(join(out, f)) })), sumsText0, rel.assets.map(x => ({ name: x.name, url: x.browser_download_url }))) : [];
const macFiles = forgejoTag ? forgejoMacFiles(files, v) : [];
console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channelUrl(channel)}):`);
for (const f of files) console.log(` ${f}`);
if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`);
console.log('\nsteps:');
for (const s of steps) console.log(` ${s[0]} ${s.slice(1).map(x => (x.includes(' ') ? `"${x}"` : x.replace(out, '<out>'))).join(' ')}`);
if (seed.length) console.log(` the server fetches from Forgejo ${forgejoTag}: ${seed.map(x => x.file).join(', ')}`);
if (macFiles.length) console.log(` then attaches to Forgejo ${forgejoTag} from its copy: ${macFiles.join(', ')}`);
if (!a.has('yes')) { console.log('\ndry run: nothing uploaded. Every installed app on this channel offers the update once latest.json is live; run again with --yes to publish.'); return; }
for (const s of steps) { say(`${s[0]} ...`); run(s, { stdio: ['ignore', 'inherit', 'inherit'] }); }
const live = spawnSync('curl', ['-fsS', channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`], { encoding: 'utf8' });
const [mkdir, rsync, finish] = steps as [string[], string[], string[]];
run(mkdir);
if (seed.length) {
const dir = `${REMOTE_INCOMING}/${v}`;
const lines = seed.map(x => { if (!/^[A-Za-z0-9._-]+$/.test(x.file) || !/^https:\/\/[A-Za-z0-9./_%-]+$/.test(x.url)) fail(`seed: unexpected ${x.file} ${x.url}`); return `curl -fsSL --max-time 900 -o '${dir}/${x.file}' '${x.url}' && echo '${x.sha256} ${dir}/${x.file}' | sha256sum -c --quiet`; });
say(`server: fetch ${seed.length} files from the Forgejo release`);
// in parallel on the server; any failed download or hash fails the step
run(['ssh', host, `pids=''; ${lines.map(l => `(${l}) & pids="$pids $!"`).join('; ')}; for p in $pids; do wait $p || exit 1; done`], { stdio: ['ignore', 'inherit', 'inherit'] });
}
say(`rsync (only what the server does not have yet) ...`);
run(rsync, { stdio: ['ignore', 'inherit', 'inherit'] });
if (macFiles.length) {
say(`Forgejo ${forgejoTag}: attach ${macFiles.join(', ')} from the server's copy`);
await forgejoUpsert({ tag: forgejoTag!, files: macFiles.map(f => join(out, f)), create: false, yes: true, remote: { host, dir: `${REMOTE_INCOMING}/${v}` } });
}
say('publish script ...');
run(finish, { stdio: ['ignore', 'inherit', 'inherit'] });
const live = spawnSync('curl', ['-fsS', channelUrl(channel)], { encoding: 'utf8' });
if (live.status !== 0 || (JSON.parse(live.stdout) as Manifest).version !== v) fail('latest.json on storebound.dev is not the new version yet');
say(`live: ${BASE_URL}/ serves ${v}`);
say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`);
@ -741,7 +820,7 @@ if (import.meta.main) {
if (cmd === 'mac') await mac(argv.slice(1));
else if (cmd === 'add') add(argv);
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>')));
else if (cmd === 'publish') publish(argv.slice(1));
else if (cmd === 'publish') await publish(argv.slice(1));
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
else if (cmd === 'exec') {
// `exec -- <command...>`: runs a build command with the path-free environment (the Forgejo workflow uses it)

View file

@ -103,27 +103,34 @@ export function mergeSums(existing: string, add: { file: string; sha256: string
export interface Release { id: number; tag_name: string; name: string; draft: boolean; prerelease: boolean; body: string; assets: { id: number; name: string; size: number; browser_download_url: string }[] }
/** Release entries `prune --keep <tag>` deletes: every other entry, the kept one must exist and be published. */
/** Release entries `prune --keep <tag>` deletes: every other entry, the kept one must exist and be published.
* Release candidates (rc-<sha> drafts of ci.yml, tools/release-candidate.ts) are not entries: they have their own
* pruning (`release-candidate.ts prune`), and a newer commit's candidate must survive a release. */
export function pruneList(releases: Release[], keep: string): { remove: Release[]; problem?: string } {
const kept = releases.find(r => r.tag_name === keep);
if (!kept) return { remove: [], problem: `no release for ${keep}: nothing is deleted` };
if (kept.draft) return { remove: [], problem: `the release of ${keep} is a draft: nothing is deleted` };
return { remove: releases.filter(r => r.id !== kept.id) };
return { remove: releases.filter(r => r.id !== kept.id && !isCandidateTag(r.tag_name)) };
}
/** Tag names of dry runs: only these may be deleted with their tag. */
export const isTestTag = (tag: string): boolean => /^test-[0-9a-f]{7,40}$/.test(tag);
/** Tag names of dry runs and release rehearsals (tools/release.ts --rehearsal): only these may be deleted with
* their tag. */
export const isTestTag = (tag: string): boolean => /^test-[0-9a-f]{7,40}$/.test(tag) || /^v0\.0\.0-rehearsal\.\d+$/.test(tag);
/** The draft that holds the release candidate of a commit (ci.yml's record job). */
export const candidateTag = (sha: string): string => `rc-${sha}`;
export const isCandidateTag = (tag: string): boolean => /^rc-[0-9a-f]{40}$/.test(tag);
// ---------------------------------------------------------------------------------------------------------------------
// API
const BASE = (process.env.FORGEJO_URL ?? 'https://git.crt.fyi').replace(/\/+$/, '');
const REPO = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'CRT/storebound';
export const BASE = (process.env.FORGEJO_URL ?? 'https://git.crt.fyi').replace(/\/+$/, '');
export const REPO = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'CRT/storebound';
const API = `${BASE}/api/v1/repos/${REPO}`;
function fail(msg: string): never { console.error(`forgejo-release: ${msg}`); process.exit(1); }
let authHeader: string | null = null;
function auth(): string {
export function auth(): string {
if (authHeader) return authHeader;
const token = process.env.FORGEJO_TOKEN;
if (token) return (authHeader = `token ${token}`);
@ -135,7 +142,7 @@ function auth(): string {
return (authHeader = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`);
}
async function api<T>(method: string, path: string, body?: unknown, raw = false): Promise<T> {
export async function api<T>(method: string, path: string, body?: unknown, raw = false): Promise<T> {
const url = path.startsWith('http') ? path : `${API}${path}`;
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, {
@ -152,6 +159,18 @@ async function api<T>(method: string, path: string, body?: unknown, raw = false)
}
}
/** Download one asset (drafts need the token) to a file; through the origin like uploads when it is set. */
export async function downloadAsset(url: string, dest: string): Promise<void> {
const ip = origin();
const dir = mkdtempSync(join(tmpdir(), 'forgejo-dl-'));
try {
writeFileSync(join(dir, 'h'), `Authorization: ${auth()}\n`, { mode: 0o600 });
const host = new URL(BASE).host;
const r = spawnSync('curl', ['-sS', '-L', '--fail', '--max-time', '1800', ...(ip ? ['--resolve', `${host}:443:${ip.includes(':') ? `[${ip}]` : ip}`] : []), '-H', `@${join(dir, 'h')}`, '-o', dest, url], { encoding: 'utf8' });
if (r.status !== 0) fail(`download ${basename(dest)}: curl exit ${r.status}: ${r.stderr.slice(0, 300)}`);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
export async function listReleases(): Promise<Release[]> {
const all: Release[] = [];
for (let page = 1; page < 50; page++) {
@ -165,7 +184,7 @@ export async function listReleases(): Promise<Release[]> {
/** Cloudflare's request body limit: bigger uploads need the origin. */
export const PROXY_BODY_LIMIT = 100e6;
function origin(): string | null {
export function origin(): string | null {
const f = join(homedir(), '.storebound-keys', 'forgejo-origin');
const v = (process.env.FORGEJO_ORIGIN ?? (existsSync(f) ? readFileSync(f, 'utf8') : '')).trim();
if (!v) return null;
@ -173,10 +192,25 @@ function origin(): string | null {
return v;
}
/** A host that already holds the files (the VDS after the publish rsync): its curl uploads them to Forgejo, so the
* owner's slow uplink carries each file once (tools/release.ts). The auth header goes over ssh's stdin into a 0600
* temp file there, removed right after; never into argv or a log. */
export interface RemoteFiles { host: string; dir: string }
/** One asset upload (multipart). With an origin: curl --resolve to it (header from a 0600 file, never argv). */
async function uploadAsset(releaseId: number, name: string, file: string | null, data: string | null): Promise<void> {
async function uploadAsset(releaseId: number, name: string, file: string | null, data: string | null, remote?: RemoteFiles): Promise<void> {
const url = `${API}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`;
const ip = origin();
if (remote && file) {
const host = new URL(BASE).host;
const resolveArg = ip ? `--resolve ${host}:443:${ip.includes(':') ? `[${ip}]` : ip}` : '';
const src = `${remote.dir}/${basename(file)}`;
if (!/^[A-Za-z0-9._\/-]+$/.test(src) || !/^[A-Za-z0-9._-]+$/.test(name)) fail(`remote upload: unexpected characters in ${src}`);
const script = `set -e; h=$(mktemp); trap 'rm -f "$h"' EXIT; chmod 600 "$h"; cat > "$h"; curl -sS --fail-with-body --max-time 1800 ${resolveArg} -H @"$h" -F 'attachment=@${src};filename=${name}' '${url}' >/dev/null`;
const r = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', remote.host, script], { input: `Authorization: ${auth()}\n`, encoding: 'utf8', timeout: 1_900_000 });
if (r.status !== 0) fail(`upload ${name} (from ${remote.host}): exit ${r.status}: ${(r.stdout + r.stderr).slice(0, 300)}`);
return;
}
const size = file ? statSync(file).size : Buffer.byteLength(data ?? '');
if (!ip) {
if (size > PROXY_BODY_LIMIT) fail(`${name} is ${(size / 1e6).toFixed(0)} MB: over Cloudflare's 100 MB limit; set FORGEJO_ORIGIN (the server's IP) or ~/.storebound-keys/forgejo-origin`);
@ -199,7 +233,7 @@ async function uploadAsset(releaseId: number, name: string, file: string | null,
const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
export interface UpsertOptions { tag: string; title?: string; notes?: string; target?: string; draft?: boolean; create?: boolean; files: string[]; yes: boolean }
export interface UpsertOptions { tag: string; title?: string; notes?: string; target?: string; draft?: boolean; prerelease?: boolean; create?: boolean; files: string[]; yes: boolean; remote?: RemoteFiles; quiet?: boolean }
/** Create or update the release of a tag and attach files (same name = replaced), SHA256SUMS merged. */
export async function upsert(o: UpsertOptions): Promise<Release | null> {
@ -216,13 +250,13 @@ export async function upsert(o: UpsertOptions): Promise<Release | null> {
const big = files.filter(f => statSync(f).size > PROXY_BODY_LIMIT);
if (big.length && !origin()) fail(`${big.map(f => basename(f)).join(', ')} over 100 MB: Cloudflare refuses them; set FORGEJO_ORIGIN (the server's IP) or ~/.storebound-keys/forgejo-origin`);
if (!rel && o.create === false) fail(`no release for ${o.tag} yet: push the tag first, the release workflow creates it`);
const fields = { name: o.title, body: o.notes, draft: o.draft ?? false, prerelease: false };
const fields = { name: o.title, body: o.notes, draft: o.draft ?? false, prerelease: o.prerelease };
if (rel) rel = await api<Release>('PATCH', `/releases/${rel.id}`, Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)));
else rel = await api<Release>('POST', '/releases', { tag_name: o.tag, target_commitish: o.target, ...fields, name: o.title ?? o.tag });
else rel = await api<Release>('POST', '/releases', { tag_name: o.tag, target_commitish: o.target, ...fields, prerelease: o.prerelease ?? false, name: o.title ?? o.tag });
const upload = async (name: string, file: string | null, data: string | null) => {
const old = rel!.assets.find(a => a.name === name);
if (old) await api('DELETE', `/releases/${rel!.id}/assets/${old.id}`);
await uploadAsset(rel!.id, name, file, data);
await uploadAsset(rel!.id, name, file, data, o.remote);
console.log(` uploaded ${name}`);
};
for (const f of files) await upload(basename(f), f, null);
@ -251,13 +285,14 @@ async function prune(keep: string, yes: boolean): Promise<void> {
}
async function deleteTag(tag: string): Promise<void> {
if (isCandidateTag(tag)) return; // a candidate draft never made a tag
if (!isTestTag(tag)) fail(`refusing to delete tag ${tag}: only test-* tags of dry runs`);
const res = await fetch(`${API}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE', headers: { Authorization: auth() } });
if (res.status !== 204 && res.status !== 404) fail(`DELETE tag ${tag}: ${res.status}`);
}
async function deleteTest(tag: string, yes: boolean): Promise<void> {
if (!isTestTag(tag)) fail(`delete is for dry-run drafts (test-<sha>), not ${tag}`);
export async function deleteTest(tag: string, yes: boolean): Promise<void> {
if (!isTestTag(tag) && !isCandidateTag(tag)) fail(`delete is for dry-run drafts (test-<sha>), rehearsals (v0.0.0-rehearsal.<n>) and candidates (rc-<sha>), not ${tag}`);
const rel = (await listReleases()).find(r => r.tag_name === tag);
console.log(rel ? `delete draft release ${tag} (${rel.assets.length} assets) and the tag if any` : `no release ${tag}; delete the tag if any`);
if (!yes) { console.log('dry run: nothing deleted (--yes to delete)'); return; }

285
tools/release-candidate.ts Normal file
View file

@ -0,0 +1,285 @@
#!/usr/bin/env bun
// Release candidates: every push to main is one. ci.yml runs the full CI, the acceptance matrix (Linux + Windows x
// Node 20 / Node 22 / Bun) and the Linux / Windows installer builds with their self-tests and privacy scans; its
// record job stores the result per commit as a DRAFT release `rc-<full sha>` on git.crt.fyi: the installers,
// SHA256SUMS, the six acceptance run files, the matrix and green.json (uploaded last: a candidate without green.json
// is incomplete). A `v*` tag then only promotes (release.yml): no rebuild, no retest.
//
// bun tools/release-candidate.ts gate --sha <sha> [--wait <min>]
// ci.yml's first job. When the commit differs from its parent only in version copies (tools/desktop-version.ts:
// each changed copy must be exactly the parent's text with the new version written in), CHANGELOG.md and
// docs/acceptance/*.json, and the parent is a green candidate, the full CI and the acceptance matrix of the parent
// count for this commit (reuse=true; the parent's run is waited for while it is still running). Only the
// installers, whose embedded version changes, are built again. Otherwise reuse=false: everything runs.
// bun tools/release-candidate.ts record --sha <sha> --run <url> --dir <dir> [--reused-from <sha>] [--yes]
// The record job: <dir> holds the installers and acceptance/*.json (with --reused-from, the parent's acceptance
// files are fetched from its candidate). Writes green.json and uploads everything to the draft rc-<sha>.
// bun tools/release-candidate.ts status --sha <sha> [--json]
// green (with the record), red (with the failed run's link), pending or none.
// bun tools/release-candidate.ts wait --sha <sha> [--timeout <min>]
// Waits for green (heartbeat every 30 s); exits 1 at once when the commit is red or has no ci run.
// bun tools/release-candidate.ts fetch --sha <sha> --dir <dir> [--only <regex>]
// Downloads a green candidate's files (checked against its SHA256SUMS).
// bun tools/release-candidate.ts promote --tag v<version> --sha <sha> [--wait <min>] [--yes]
// release.yml: waits for the tagged commit's green candidate (fails fast when it is red or missing, with the
// link), checks its version against the tag, and makes the release of the tag from the candidate's files
// (tools/forgejo-release.ts upsert; a prerelease for versions with a "-", which is never pruned and never
// prunes). Then the older release entries are deleted (stable versions only).
// bun tools/release-candidate.ts prune [--keep <n>] [--yes]
// Deletes candidate drafts except the newest n (default 12).
//
// Token: FORGEJO_TOKEN (ci.yml / release.yml: the RELEASE_TOKEN secret) or the git credential helper (on the Mac).
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { cliKey } from './cli-key';
import { COPIES, SEMVER } from './desktop-version';
import { api, BASE, candidateTag, deleteTest, downloadAsset, isCandidateTag, listReleases, type Release, REPO, shortNotes, SUMS, upsert } from './forgejo-release';
const ROOT = resolve(import.meta.dir, '..');
export const GREEN = 'green.json';
const HEARTBEAT_MS = 30_000;
export interface GreenRecord {
schema: 1;
sha: string;
version: string;
cliKey: string;
run: string;
at: string;
/** the parent whose full CI and acceptance matrix count for this version-only commit */
reusedFrom?: { sha: string; run: string };
files: Record<string, string>;
}
// ---------------------------------------------------------------------------------------------------------------------
// Pure parts (tests/release-candidate.test.ts)
export interface Change { file: string; status: 'A' | 'M' | 'D' | string; before: string | null; after: string | null }
/** Files a version-only commit may touch besides the version copies: the changelog and the macOS acceptance results. */
const FREE = [/^CHANGELOG\.md$/, /^docs\/acceptance\.md$/, /^docs\/acceptance\/[A-Za-z0-9_-]+\.json$/];
/**
* Why a commit is not a version-only change of its parent (empty = it is). Strict: every changed file is on the
* allow-list, and every version copy is byte for byte the parent's text with the new version written in by the very
* writer tools/desktop-version.ts uses (so no other line of package.json, Cargo.toml, ... can change).
*/
export function versionOnlyProblems(changes: Change[]): { from: string | null; to: string | null; problems: string[] } {
const problems: string[] = [];
const pkg = changes.find(c => c.file === 'package.json');
const read = (t: string | null) => (t ? COPIES[0]!.read(t) : null);
const from = read(pkg?.before ?? null);
const to = read(pkg?.after ?? null);
if (!pkg || !from || !to || from === to) problems.push('package.json: the version does not change');
else if (!SEMVER.test(to)) problems.push(`package.json: ${to} is not a version`);
for (const c of changes) {
if (FREE.some(re => re.test(c.file))) continue;
const copy = COPIES.find(x => x.file === c.file);
if (!copy) { problems.push(`${c.file}: not a version copy (${c.status})`); continue; }
if (c.status !== 'M' || c.before === null || c.after === null) { problems.push(`${c.file}: ${c.status}, not modified in place`); continue; }
if (!to || copy.read(c.before) !== from || copy.write(c.before, to) !== c.after) problems.push(`${c.file}: changes more than the version`);
}
return { from, to, problems };
}
export type Status =
| { state: 'green'; record: GreenRecord; release: Release }
| { state: 'red'; run: string; failed: string[] }
| { state: 'pending'; run: string; running: string[] }
| { state: 'none' };
export interface Task { id: number; name: string; run_number: number; workflow_id: string; head_sha: string; status: string }
/** The state of a commit from its ci.yml jobs (the newest run of that commit) and whether its record exists. Pure. */
export function taskState(tasks: Task[], sha: string, runUrl: (n: number) => string): Exclude<Status, { state: 'green' }> {
const mine = tasks.filter(t => t.head_sha === sha && t.workflow_id === 'ci.yml');
if (!mine.length) return { state: 'none' };
const latest = Math.max(...mine.map(t => t.run_number));
const jobs = mine.filter(t => t.run_number === latest);
const failed = jobs.filter(t => ['failure', 'cancelled'].includes(t.status)).map(t => t.name);
if (failed.length) return { state: 'red', run: runUrl(latest), failed };
const running = jobs.filter(t => !['success', 'skipped'].includes(t.status)).map(t => `${t.name} (${t.status})`);
// every job done and green but no green.json yet: the record job is still uploading, or ran in an older run
return { state: 'pending', run: runUrl(latest), running };
}
/** The version of a tag (v0.8.5 -> 0.8.5), else null. */
export const tagVersion = (tag: string): string | null => (/^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag)?.[1] ?? null);
/** Versions with a pre-release part (0.0.0-rehearsal.1) are prereleases: never the stable release, never pruning. */
export const isPrerelease = (version: string): boolean => version.includes('-');
// ---------------------------------------------------------------------------------------------------------------------
function fail(msg: string): never { console.error(`release-candidate: ${msg}`); process.exit(1); }
const t0 = Date.now();
const say = (m: string) => console.log(`[${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s] ${m}`);
const sha256 = (f: string) => createHash('sha256').update(readFileSync(f)).digest('hex');
const runUrl = (n: number) => `${BASE}/${REPO}/actions/runs/${n}`;
function git(args: string[], allowFail = false): string {
const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 });
if (r.status !== 0 && !allowFail) fail(`git ${args.join(' ')}: ${r.stderr.trim()}`);
return r.status === 0 ? r.stdout : '';
}
export function changesBetween(base: string, head: string): Change[] {
const out = git(['diff', '--name-status', '--no-renames', base, head]).trim();
if (!out) return [];
return out.split('\n').map(l => {
const [status, file] = l.split('\t') as [string, string];
const show = (rev: string) => { const r = spawnSync('git', ['show', `${rev}:${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 }); return r.status === 0 ? r.stdout : null; };
return { file, status, before: status === 'A' ? null : show(base), after: status === 'D' ? null : show(head) };
});
}
async function tasks(): Promise<Task[]> {
const all: Task[] = [];
for (let page = 1; page <= 3; page++) {
const r = await api<{ workflow_runs?: Task[] }>('GET', `/actions/tasks?limit=50&page=${page}`);
all.push(...(r.workflow_runs ?? []));
if ((r.workflow_runs ?? []).length < 50) break;
}
return all;
}
async function record(release: Release): Promise<GreenRecord | null> {
const a = release.assets.find(x => x.name === GREEN);
if (!a) return null;
return JSON.parse(await api<string>('GET', a.browser_download_url, undefined, true)) as GreenRecord;
}
export async function status(sha: string): Promise<Status> {
const rel = (await listReleases()).find(r => r.tag_name === candidateTag(sha));
const rec = rel ? await record(rel) : null;
if (rel && rec) return { state: 'green', record: rec, release: rel };
return taskState(await tasks(), sha, runUrl);
}
/** Waits for a commit's candidate: green returns, red and none (after `graceMin`) fail at once. */
export async function waitGreen(sha: string, timeoutMin: number, graceMin = 3): Promise<Extract<Status, { state: 'green' }>> {
const end = Date.now() + timeoutMin * 60_000;
let last = 0;
for (;;) {
const s = await status(sha);
if (s.state === 'green') { say(`candidate ${sha.slice(0, 10)} is green (run ${s.record.run}${s.record.reusedFrom ? `, CI and acceptance of ${s.record.reusedFrom.sha.slice(0, 10)}` : ''})`); return s; }
if (s.state === 'red') fail(`commit ${sha.slice(0, 10)} is red: ${s.failed.join(', ')} failed in ${s.run}. Fix it and push; nothing is released from a red commit.`);
if (s.state === 'none' && Date.now() - t0 > graceMin * 60_000) fail(`commit ${sha.slice(0, 10)} has no ci run on ${BASE}/${REPO}/actions: push it to main (ci.yml makes the candidate)`);
if (Date.now() > end) fail(`timeout after ${timeoutMin} min waiting for the candidate of ${sha.slice(0, 10)}${s.state === 'pending' ? `: ${s.run} still has ${s.running.join(', ') || 'the record upload'}` : ''}`);
if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(`waiting for candidate ${sha.slice(0, 10)}: ${s.state === 'pending' ? `${s.run}: ${s.running.join(', ') || 'record upload'}` : 'no run yet'}`); }
await Bun.sleep(10_000);
}
}
/** Download a green candidate's files (green.json excluded), each checked against its SHA256SUMS. */
export async function fetchCandidate(s: Extract<Status, { state: 'green' }>, dir: string, only?: RegExp): Promise<string[]> {
mkdirSync(dir, { recursive: true });
const got: string[] = [];
const assets = s.release.assets.filter(a => a.name !== GREEN && a.name !== SUMS && (!only || only.test(a.name)));
await Promise.all(assets.map(async a => {
const dest = join(dir, a.name);
if (existsSync(dest) && s.record.files[a.name] && sha256(dest) === s.record.files[a.name]) { got.push(dest); return; }
await downloadAsset(a.browser_download_url, dest);
const want = s.record.files[a.name];
if (want && sha256(dest) !== want) fail(`${a.name}: SHA-256 differs from the candidate's record`);
got.push(dest);
}));
return got.sort();
}
function output(values: Record<string, string>): void {
for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`);
const f = process.env.GITHUB_OUTPUT;
if (f) appendFileSync(f, Object.entries(values).map(([k, v]) => `${k}=${v}\n`).join(''));
}
async function gate(sha: string, waitMin: number): Promise<void> {
const parent = git(['rev-parse', `${sha}^`], true).trim();
if (!parent) { output({ reuse: 'false', from: '' }); say('no parent in this checkout (fetch-depth 2): everything runs'); return; }
const { from, to, problems } = versionOnlyProblems(changesBetween(parent, sha));
if (problems.length) { say(`not a version-only commit: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? ' ...' : ''}: everything runs`); output({ reuse: 'false', from: '' }); return; }
say(`version-only commit: ${from} -> ${to} (every copy exactly re-written, plus changelog / acceptance files); the parent ${parent.slice(0, 10)}'s CI and acceptance matrix count if it is green`);
const first = await status(parent);
if (first.state === 'none') { say(`the parent has no ci run (pushed together with this commit?): everything runs`); output({ reuse: 'false', from: '' }); return; }
const s = await waitGreen(parent, waitMin);
if (s.record.cliKey !== cliKey(ROOT)) { say(`the parent's CLI key ${s.record.cliKey} is not this commit's: everything runs`); output({ reuse: 'false', from: '' }); return; }
output({ reuse: 'true', from: parent });
}
async function recordCmd(sha: string, run: string, dir: string, reusedFrom: string | undefined, yes: boolean): Promise<void> {
const version = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!;
const acc = join(dir, 'acceptance');
let reused: GreenRecord['reusedFrom'];
if (reusedFrom) {
const p = await status(reusedFrom);
if (p.state !== 'green') fail(`--reused-from ${reusedFrom.slice(0, 10)} is ${p.state}, not green`);
await fetchCandidate(p, acc, /^acceptance-.*\.json$/);
reused = { sha: reusedFrom, run: p.record.run };
}
const files = readdirSync(dir, { recursive: true }).map(String).filter(f => statSync(join(dir, f)).isFile() && basename(f) !== SUMS && basename(f) !== GREEN);
const flat = files.map(f => ({ path: join(dir, f), name: f.startsWith('acceptance/') && !basename(f).startsWith('acceptance-') ? `acceptance-${basename(f)}` : basename(f) }));
const names = new Set<string>();
for (const f of flat) { if (names.has(f.name)) fail(`two files named ${f.name}`); names.add(f.name); }
for (const f of flat) if (!f.name.startsWith('acceptance-') && !f.name.includes(`_${version}_`)) fail(`${f.name} is not version ${version}`);
const rec: GreenRecord = { schema: 1, sha, version, cliKey: cliKey(ROOT), run, at: new Date().toISOString(), reusedFrom: reused, files: Object.fromEntries(flat.map(f => [f.name, sha256(f.path)])) };
// assets keep flat names: copy acceptance files under their asset name
const stage = join(dir, '.stage');
rmSync(stage, { recursive: true, force: true });
mkdirSync(stage);
const paths = flat.map(f => { const p = join(stage, f.name); writeFileSync(p, readFileSync(f.path)); return p; });
const tag = candidateTag(sha);
await upsert({ tag, target: sha, title: `candidate ${sha.slice(0, 10)} (${version})`, notes: `Release candidate of ${sha} (${version}): ${run}${reused ? `; CI and acceptance matrix of ${reused.sha} (version-only parent): ${reused.run}` : ''}. Promoted by a v${version} tag (release.yml); deleted by \`release-candidate.ts prune\`.`, draft: true, files: paths, yes });
if (!yes) return;
// green.json last: a candidate is complete only with it
writeFileSync(join(stage, GREEN), `${JSON.stringify(rec, null, 2)}\n`);
await upsert({ tag, draft: true, files: [join(stage, GREEN)], yes: true });
say(`candidate ${tag} recorded: ${flat.length} files${reused ? `, CI + acceptance reused from ${reused.sha.slice(0, 10)}` : ''}`);
}
async function promote(tag: string, sha: string, waitMin: number, yes: boolean): Promise<void> {
const version = tagVersion(tag) ?? fail(`${tag} is not v<version>`);
const s = await waitGreen(sha, waitMin);
if (s.record.version !== version) fail(`the candidate of ${sha.slice(0, 10)} is version ${s.record.version}, the tag says ${version}`);
const pre = isPrerelease(version);
const dir = join(ROOT, '.promote');
rmSync(dir, { recursive: true, force: true });
const files = await fetchCandidate(s, dir, /^(?!acceptance-).+/);
say(`fetched ${files.length} files of the candidate (${files.map(f => basename(f)).join(', ')})`);
const notes = shortNotes(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, { tag });
const pick = (re: RegExp) => files.filter(f => re.test(basename(f)));
const ship = [...pick(/\.(AppImage|deb|exe)$/), ...pick(/_acceptance\.md$/)];
if (!pick(/\.AppImage$/).length || !pick(/\.deb$/).length || !pick(/\.exe$/).length) fail('the candidate lacks the AppImage, the deb or the Windows installer');
await upsert({ tag, target: sha, title: pre ? `storebound ${version} (prerelease)` : `storebound ${version}`, notes, prerelease: pre, files: ship, yes });
if (!yes) { say('dry run: nothing released (--yes)'); return; }
say(`released ${tag} from candidate ${sha.slice(0, 10)}: ${BASE}/${REPO}/releases/tag/${tag}`);
}
async function prune(keep: number, yes: boolean): Promise<void> {
const rcs = (await listReleases()).filter(r => isCandidateTag(r.tag_name)).sort((a, b) => b.id - a.id);
const drop = rcs.slice(keep);
say(`${rcs.length} candidates; deleting ${drop.length} older than the newest ${keep}`);
for (const r of drop) await deleteTest(r.tag_name, yes);
}
function args(argv: string[]) {
const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
return { get, has: (n: string) => argv.includes(`--${n}`) };
}
if (import.meta.main) {
const [cmd, ...argv] = process.argv.slice(2);
const a = args(argv);
const sha = () => { const s = a.get('sha') ?? fail('--sha <full commit sha>'); if (!/^[0-9a-f]{40}$/.test(s)) fail('--sha: a full 40-character sha'); return s; };
if (cmd === 'gate') await gate(sha(), Number(a.get('wait') ?? 40));
else if (cmd === 'record') await recordCmd(sha(), a.get('run') ?? fail('--run <url>'), resolve(a.get('dir') ?? fail('--dir <dir>')), a.get('reused-from') || undefined, a.has('yes'));
else if (cmd === 'status') {
const s = await status(sha());
if (a.has('json')) console.log(JSON.stringify(s.state === 'green' ? { state: s.state, record: s.record } : s));
else console.log(s.state === 'green' ? `green: ${s.record.run}` : s.state === 'none' ? 'none' : `${s.state}: ${s.run} ${'failed' in s ? s.failed.join(', ') : s.running.join(', ')}`);
} else if (cmd === 'wait') await waitGreen(sha(), Number(a.get('timeout') ?? 40));
else if (cmd === 'fetch') { const s = await waitGreen(sha(), 1); const only = a.get('only'); for (const f of await fetchCandidate(s, resolve(a.get('dir') ?? fail('--dir')), only ? new RegExp(only) : undefined)) console.log(f); }
else if (cmd === 'promote') await promote(a.get('tag') ?? fail('--tag v<version>'), sha(), Number(a.get('wait') ?? 40), a.has('yes'));
else if (cmd === 'prune') await prune(Number(a.get('keep') ?? 12), a.has('yes'));
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
}

361
tools/release.ts Normal file
View file

@ -0,0 +1,361 @@
#!/usr/bin/env bun
// One command for a whole storebound release on the owner's Mac (docs/desktop-release.md, "The release in one
// command"). Everything but `npm publish` (the owner's step; the commands are printed at the end):
//
// bun tools/release.ts <version> [--yes] e.g. bun tools/release.ts 0.8.5 --yes
// bun tools/release.ts --rehearsal [<n>] [--yes] the same end to end as version 0.0.0-rehearsal.<n> on the branch
// rehearsal/<n>: a prerelease on git.crt.fyi and the rehearsal
// channel of storebound.dev (no app reads it), never npm and never
// /Applications; everything it made is deleted at the end
// bun tools/release.ts mac-acceptance the macOS acceptance runs for this checkout's CLI key (cached)
//
// Without --yes: the checks and the plan only. With --yes, in order:
// 1. preflight: a clean checkout of origin/main whose candidate is green or still running (tools/release-candidate.ts),
// the updater key, the publish host; the macOS acceptance matrix starts in the background unless its results for
// this CLI key are cached (~/.storebound-releases/acceptance-cache/<key>/; a version bump keeps the key);
// 2. the version in package.json and every copy, the CHANGELOG's Unreleased section cut as "## <version> · <date>";
// 3. the fast gate (bun run ci:quick), the commit (as CRT), push to main, push the tag v<version>;
// 4. at once, in parallel: the Mac build (tools/desktop-release.ts mac: universal app, sign, notarize app and DMG,
// every check), Forgejo's ci run of the bump commit (version-only: the installers only, the parent's CI and
// matrix count) and the promotion of the tag (release.yml); this waits for the candidate (heartbeat, timeout)
// and downloads its Linux / Windows installers while the Mac builds;
// 5. add (updater signatures of the Linux / Windows files), the full acceptance matrix (9 runs), verify;
// 6. publish: the VDS fetches the Linux / Windows files from Forgejo itself, rsync sends only the Mac's files,
// the VDS attaches them to the Forgejo release from its copy, then storebound.dev's latest.json;
// 7. /Applications/storebound.app: the running app is quit (gracefully, then its exact PID), the new one copied
// from the DMG, reopened in the background;
// 8. prints `npm publish` and `npm deprecate`, and the time of every phase (also in
// ~/.storebound-releases/timings/).
// Every wait has a timeout and a heartbeat; child processes are killed by PID when this exits.
import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { cliKey } from './cli-key';
import { remoteSessionReason } from './desktop-release';
import { COPIES, SEMVER, setVersion } from './desktop-version';
import { candidateTag, deleteTest, listReleases, upsert } from './forgejo-release';
import { fetchCandidate, status, waitGreen } from './release-candidate';
const ROOT = resolve(import.meta.dir, '..');
const HOME = homedir();
const RELEASES = join(HOME, '.storebound-releases');
const KEYS = process.env.STOREBOUND_KEYS_DIR ?? join(HOME, '.storebound-keys');
const ACC_CACHE = join(RELEASES, 'acceptance-cache');
const APP_ID = 'dev.storebound.desktop';
const HEARTBEAT_MS = 30_000;
// ---------------------------------------------------------------------------------------------------------------------
// Pure parts (tests/release.test.ts)
/** The CHANGELOG with its Unreleased section turned into the version's section and a new empty Unreleased on top. */
export function cutChangelog(text: string, version: string, date: string): string {
const lines = text.split('\n');
const i = lines.findIndex(l => /^## Unreleased\s*$/.test(l));
if (i < 0) throw new Error('CHANGELOG.md has no "## Unreleased" section');
const next = lines.findIndex((l, j) => j > i && l.startsWith('## '));
const body = lines.slice(i + 1, next < 0 ? lines.length : next).join('\n').trim();
if (!body) throw new Error('the Unreleased section of CHANGELOG.md is empty: write the release summary first');
if (lines.some(l => l.startsWith('## ') && l.slice(3).trim().split(/\s/)[0] === version)) throw new Error(`CHANGELOG.md already has a section for ${version}`);
return [...lines.slice(0, i), '## Unreleased', '', `## ${version} · ${date}`, ...lines.slice(i + 1)].join('\n');
}
/** a > b for x.y.z versions (pre-release parts lower than the plain version). */
export function newer(a: string, b: string): boolean {
const p = (v: string) => { const [core, pre] = v.split('-', 2); return { n: core!.split('.').map(Number), pre: pre ?? null }; };
const x = p(a); const y = p(b);
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i]! > y.n[i]!;
if (x.pre === y.pre) return false;
if (x.pre === null) return true;
if (y.pre === null) return false;
return x.pre > y.pre;
}
export interface Phase { name: string; start: number; end?: number }
/** The timing table: each phase's start offset and duration, the critical path is the total. */
export function timingTable(phases: Phase[], t0: number): string {
const f = (ms: number) => { const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
const rows = phases.map(p => ` ${p.name.padEnd(46)} start +${f(p.start - t0).padStart(5)} took ${p.end ? f(p.end - p.start).padStart(5) : ' open'}`);
const end = Math.max(...phases.map(p => p.end ?? p.start));
return [...rows, ` ${'total (tag push to everything published)'.padEnd(46)} ${''.padStart(13)} ${f(end - t0).padStart(10)}`].join('\n');
}
// ---------------------------------------------------------------------------------------------------------------------
const t0 = Date.now();
const phases: Phase[] = [];
const children = new Set<ChildProcess>();
const say = (m: string) => console.log(`[${new Date().toISOString().slice(11, 19)} +${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s] ${m}`);
function fail(msg: string): never {
console.error(`\nrelease: ${msg}`);
if (phases.length) console.error(`\n${timingTable(phases, t0)}`);
process.exit(1);
}
process.on('exit', () => { for (const c of children) if (c.exitCode === null && c.pid) { try { process.kill(c.pid, 'SIGTERM'); } catch { /* gone */ } } });
process.on('SIGINT', () => fail('interrupted'));
function phase<T>(name: string, fn: () => Promise<T>): Promise<T> {
const p: Phase = { name, start: Date.now() };
phases.push(p);
say(`>> ${name}`);
return fn().then(r => { p.end = Date.now(); say(`<< ${name} (${Math.round((p.end - p.start) / 1000)} s)`); return r; });
}
function sh(cmd: string[], opts: { cwd?: string; allowFail?: boolean; env?: Record<string, string | undefined>; input?: string } = {}): string {
const r = spawnSync(cmd[0]!, cmd.slice(1), { cwd: opts.cwd ?? ROOT, encoding: 'utf8', maxBuffer: 256 << 20, env: (opts.env ?? process.env) as NodeJS.ProcessEnv, input: opts.input });
if (r.status !== 0 && !opts.allowFail) fail(`${cmd.slice(0, 3).join(' ')} failed (exit ${r.status}): ${(r.stderr || r.stdout).trim().split('\n').slice(-8).join('\n')}`);
return r.status === 0 ? r.stdout : '';
}
/** A child process with its output in a log file; resolves with the exit code. Heartbeat: the log's last line. */
function bg(name: string, cmd: string[], log: string, opts: { env?: Record<string, string | undefined>; timeoutMs: number }): Promise<number> {
mkdirSync(resolve(log, '..'), { recursive: true });
const fd = Bun.file(log).writer();
const c = spawn(cmd[0]!, cmd.slice(1), { cwd: ROOT, env: (opts.env ?? process.env) as NodeJS.ProcessEnv, stdio: ['ignore', 'pipe', 'pipe'] });
children.add(c);
let lastLine = '';
const onData = (d: Buffer) => { fd.write(d); const t = d.toString().trim().split('\n').at(-1); if (t) lastLine = t; };
c.stdout!.on('data', onData); c.stderr!.on('data', onData);
const beat = setInterval(() => say(` ${name} (pid ${c.pid}): ${lastLine.slice(0, 150)}`), HEARTBEAT_MS);
const timer = setTimeout(() => { say(`${name}: timeout after ${opts.timeoutMs / 60_000} min, killing pid ${c.pid}`); c.kill('SIGTERM'); }, opts.timeoutMs);
return new Promise(res => c.on('close', code => { clearInterval(beat); clearTimeout(timer); children.delete(c); void fd.end(); res(code ?? 1); }));
}
function publishHost(): string {
const f = join(KEYS, 'publish-host');
const h = process.env.STOREBOUND_PUBLISH_HOST ?? (existsSync(f) ? readFileSync(f, 'utf8').trim() : '');
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(h)) fail(`the publish host (user@host of the VDS) is not set: STOREBOUND_PUBLISH_HOST or ${f.replace(HOME, '~')}`);
return h;
}
/** Node binaries for the macOS acceptance runs: fnm's Node 20 and 22 (installed from nodejs.org once if missing). */
function nodeBin(major: 20 | 22): string {
const find = () => sh(['fnm', 'exec', `--using=${major}`, '--', 'node', '-p', 'process.execPath'], { allowFail: true }).trim();
let p = find();
if (!p) { say(`fnm install ${major} (Node ${major} for the macOS acceptance run)`); sh(['fnm', 'install', String(major)]); p = find(); }
if (!p || !existsSync(p)) fail(`no Node ${major}: fnm install ${major}`);
return p;
}
const RUNTIMES = [
{ file: 'macOS-bun.json', args: ['--runtime', 'bun'] },
{ file: 'macOS-node20.json', args: ['--runtime', 'node', '--node', '@20'] },
{ file: 'macOS-node22.json', args: ['--runtime', 'node', '--node', '@22'] },
];
function cachedMacAcceptance(key: string): string | null {
const dir = join(ACC_CACHE, key);
const ok = RUNTIMES.every(r => {
const f = join(dir, r.file);
if (!existsSync(f)) return false;
const run = JSON.parse(readFileSync(f, 'utf8')) as { cliKey?: string; quick?: boolean; results: { ok: boolean }[] };
return run.cliKey === key && !run.quick && run.results.length > 0 && run.results.every(x => x.ok);
});
return ok ? dir : null;
}
/** The macOS acceptance matrix for this checkout's CLI key: from the cache, else the three runs in parallel from one
* packed tarball (parallel `npm pack` in one checkout would race on dist/). */
async function macAcceptance(key: string, logDir: string): Promise<string> {
const hit = cachedMacAcceptance(key);
if (hit) { say(`macOS acceptance: cached for CLI key ${key.slice(0, 12)} (${hit.replace(HOME, '~')})`); return hit; }
const dir = join(ACC_CACHE, key);
mkdirSync(dir, { recursive: true });
const packDir = mkdtempSync(join(tmpdir(), 'sb-pack-'));
sh(['npm', 'pack', '--silent', '--pack-destination', packDir]);
const tgz = join(packDir, readdirSync(packDir).find(f => f.endsWith('.tgz')) ?? fail('npm pack made no tarball'));
const codes = await Promise.all(RUNTIMES.map(r => {
const args = r.args.map(a => (a === '@20' ? nodeBin(20) : a === '@22' ? nodeBin(22) : a));
return bg(`acceptance ${r.file}`, ['bun', 'tools/acceptance.ts', 'run', ...args, '--tgz', tgz, '--platform', 'macOS', '--jobs', '3', '--out', join(dir, r.file)], join(logDir, `acceptance-${r.file}.log`), { timeoutMs: 30 * 60_000 });
}));
rmSync(packDir, { recursive: true, force: true });
if (codes.some(c => c !== 0)) fail(`macOS acceptance has red cells (logs in ${logDir.replace(HOME, '~')}); nothing is released`);
if (!cachedMacAcceptance(key)) fail('macOS acceptance: results missing after the run');
return dir;
}
async function waitRelease(tag: string, timeoutMin: number): Promise<void> {
const end = Date.now() + timeoutMin * 60_000;
let last = 0;
for (;;) {
const rel = (await listReleases()).find(r => r.tag_name === tag);
if (rel && !rel.draft && rel.assets.some(a => a.name.endsWith('.AppImage')) && rel.assets.some(a => a.name.endsWith('.exe'))) return;
if (Date.now() > end) fail(`timeout after ${timeoutMin} min: release.yml has not released ${tag} (Actions > release on git.crt.fyi)`);
if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(` waiting for release.yml to release ${tag}${rel ? ` (${rel.assets.length} assets so far)` : ''}`); }
await Bun.sleep(5_000);
}
}
/** Quit the running app (gracefully, then its exact PID), copy the new one from the DMG, reopen it in the
* background. In a rehearsal the copy goes to a scratch folder and nothing is quit or opened. */
function installApp(dmg: string, target: string, rehearsal: boolean): string {
const pids = rehearsal ? [] : sh(['pgrep', '-f', `${target}/Contents/MacOS/storebound-desktop`], { allowFail: true }).trim().split('\n').filter(Boolean).map(Number);
if (pids.length) {
say(`quitting storebound (pid ${pids.join(', ')}) gracefully`);
sh(['osascript', '-e', `tell application id "${APP_ID}" to quit`], { allowFail: true });
const gone = (pid: number) => { try { process.kill(pid, 0); return false; } catch { return true; } };
const until = Date.now() + 15_000;
while (Date.now() < until && !pids.every(gone)) Bun.sleepSync(250);
for (const pid of pids.filter(p => !gone(p))) { say(`pid ${pid} still running: SIGTERM`); try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
const until2 = Date.now() + 5_000;
while (Date.now() < until2 && !pids.every(gone)) Bun.sleepSync(250);
for (const pid of pids.filter(p => !gone(p))) { say(`pid ${pid} still running: SIGKILL`); try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
}
const mnt = mkdtempSync(join(tmpdir(), 'sb-inst-'));
sh(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]);
try {
mkdirSync(resolve(target, '..'), { recursive: true });
const tmp = `${target}.new`;
rmSync(tmp, { recursive: true, force: true });
sh(['ditto', join(mnt, 'storebound.app'), tmp]);
sh(['codesign', '--verify', '--deep', '--strict', tmp]);
rmSync(target, { recursive: true, force: true });
sh(['mv', tmp, target]);
} finally { sh(['hdiutil', 'detach', mnt, '-force'], { allowFail: true }); rmSync(mnt, { recursive: true, force: true }); }
const v = JSON.parse(sh(['plutil', '-convert', 'json', '-o', '-', join(target, 'Contents/Info.plist')])).CFBundleShortVersionString as string;
if (pids.length) { sh(['open', '-g', target]); say(`reopened ${target} in the background`); }
return v;
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const has = (f: string) => argv.includes(f);
const yes = has('--yes');
if (argv[0] === 'mac-acceptance') { const key = cliKey(ROOT); console.log(await macAcceptance(key, join(RELEASES, 'logs', `acceptance-${key}`))); return; }
const rehearsal = has('--rehearsal');
const positional = argv.filter(a => !a.startsWith('--'));
const current = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!;
const n = rehearsal ? Number(positional[0] ?? Math.floor(Date.now() / 1000)) : 0;
const version = rehearsal ? `0.0.0-rehearsal.${n}` : positional[0] ?? fail('usage: bun tools/release.ts <version> [--yes] | --rehearsal [<n>] [--yes]');
if (!SEMVER.test(version)) fail(`${version} is not a version`);
if (!rehearsal && !newer(version, current)) fail(`${version} is not newer than ${current}`);
const tag = `v${version}`;
const branch = rehearsal ? `rehearsal/${n}` : 'main';
const out = join(RELEASES, 'desktop', version);
const logDir = join(RELEASES, 'logs', version);
const host = publishHost();
// ---------------------------------------------------------------- 1. preflight
const remote = remoteSessionReason(process.env, sh(['launchctl', 'managername'], { allowFail: true }).trim());
if (remote) fail(`${remote}: signing and notarization need the owner's logged-in macOS session`);
if (!existsSync(join(KEYS, 'updater.key'))) fail(`no updater key in ${KEYS.replace(HOME, '~')}`);
if (sh(['git', 'status', '--porcelain']).trim()) fail('the checkout has uncommitted changes');
sh(['git', 'fetch', '--quiet', 'origin', 'main', '--tags']);
const head = sh(['git', 'rev-parse', 'HEAD']).trim();
if (head !== sh(['git', 'rev-parse', 'origin/main']).trim()) fail(`HEAD ${head.slice(0, 10)} is not origin/main: release from main as it is on git.crt.fyi (pull / push first), so the bump's parent is a candidate`);
if (!rehearsal && sh(['git', 'rev-parse', '-q', '--verify', `refs/tags/${tag}`], { allowFail: true }).trim()) fail(`tag ${tag} exists already`);
const parent = await status(head);
if (parent.state === 'red') fail(`origin/main ${head.slice(0, 10)} is red (${parent.failed.join(', ')}): ${parent.run}`);
if (parent.state === 'none') fail(`origin/main ${head.slice(0, 10)} has no ci run: its candidate is needed (push to main runs ci.yml)`);
say(`storebound ${current} -> ${version}${rehearsal ? ' (rehearsal: prerelease, rehearsal channel, deleted at the end)' : ''}; parent ${head.slice(0, 10)} is ${parent.state}${parent.state === 'pending' ? ` (${parent.run})` : ''}`);
// the release commit's author is the one of the commits already on main (the project's identity)
const identity = sh(['git', 'log', '-1', '--format=%an <%ae>', 'origin/main']).trim();
const key = cliKey(ROOT);
const accCached = cachedMacAcceptance(key);
console.log(`plan:
bump ${current} -> ${version} (package.json + ${COPIES.length - 1} copies)${rehearsal ? '' : ', CHANGELOG Unreleased -> ' + version}; bun run ci:quick; commit as ${identity}
push ${branch}, push ${tag}; then in parallel: Mac build + notarize -> ${out.replace(HOME, '~')}; macOS acceptance (${accCached ? 'cached' : 'runs now'}, key ${key.slice(0, 12)});
Forgejo ci (candidate of the bump commit) + release.yml (promotion); add, verify, publish to ${rehearsal ? 'the rehearsal channel' : 'stable'} via ${host};
${rehearsal ? 'app copied to a scratch folder; then the tag, release, candidates, branch and storebound.dev files of the rehearsal are deleted' : 'update /Applications/storebound.app; print npm publish + npm deprecate'}`);
if (!yes) { console.log('\ndry run: nothing changed (--yes to release)'); return; }
const accP = phase('macOS acceptance (cache or 3 runs)', () => macAcceptance(key, logDir));
accP.catch(() => undefined);
// ---------------------------------------------------------------- 2. + 3. bump, gate, commit, push
const startBranch = sh(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
let sha = '';
await phase('bump + fast gate + commit + push', async () => {
if (rehearsal) sh(['git', 'checkout', '-q', '-b', branch]);
setVersion(version);
if (!rehearsal) writeFileSync(join(ROOT, 'CHANGELOG.md'), cutChangelog(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, new Date().toISOString().slice(0, 10)));
const code = await bg('ci:quick', ['bun', 'run', 'ci:quick'], join(logDir, 'ci-quick.log'), { timeoutMs: 15 * 60_000 });
if (code !== 0) fail(`bun run ci:quick failed (${join(logDir, 'ci-quick.log').replace(HOME, '~')}); the bump is not committed (git checkout -- . to undo)`);
const me = `${sh(['git', 'config', 'user.name']).trim()} <${sh(['git', 'config', 'user.email']).trim()}>`;
if (me !== identity) fail(`git identity is ${me}; the commits on main are by ${identity} (git config user.name / user.email)`);
sh(['git', 'add', '-A']);
sh(['git', 'commit', '-q', '-m', rehearsal ? `release rehearsal ${version} (deleted after the run)` : `storebound ${version}: version in package.json and every copy, CHANGELOG ${version} section`]);
sha = sh(['git', 'rev-parse', 'HEAD']).trim();
sh(['git', 'push', '-q', 'origin', `HEAD:refs/heads/${branch}`]);
sh(['git', 'tag', tag]);
sh(['git', 'push', '-q', 'origin', `refs/tags/${tag}`]);
say(`pushed ${sha.slice(0, 10)} to ${branch} and ${tag}`);
});
const tPush = Date.now();
phases.push({ name: '== tag pushed: clock for "tag -> published" ==', start: tPush, end: tPush });
// ---------------------------------------------------------------- 4. in parallel
rmSync(out, { recursive: true, force: true });
const macP = phase('Mac build + sign + notarize (app, DMG)', async () => {
const code = await bg('mac build', ['bun', 'tools/desktop-release.ts', 'mac', '--out', out], join(logDir, 'mac.log'), { timeoutMs: 60 * 60_000 });
if (code !== 0) fail(`the Mac build failed: ${join(logDir, 'mac.log').replace(HOME, '~')}`);
});
const dl = join(RELEASES, 'candidates', sha);
const candP = phase('Forgejo: candidate of the bump commit (installers)', async () => {
const s = await waitGreen(sha, 45);
return s;
});
const fetchP = candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl)));
const relP = candP.then(() => phase('Forgejo: release.yml promotes the tag', () => waitRelease(tag, 15)));
await Promise.all([macP, fetchP, relP, accP]);
const cand = await candP;
// ---------------------------------------------------------------- 5. add, acceptance matrix, verify
await phase('add Linux / Windows + acceptance matrix + verify', async () => {
const files = readdirSync(dl).filter(f => /\.(AppImage|deb|exe)$/.test(f)).map(f => join(dl, f));
sh(['bun', 'tools/desktop-release.ts', 'add', '--out', out, '--commit', sha, '--run', cand.record.run, ...files]);
const accDir = mkdtempSync(join(tmpdir(), 'sb-acc-'));
for (const f of readdirSync(dl).filter(f => /^acceptance-.*\.json$/.test(f))) writeFileSync(join(accDir, f), readFileSync(join(dl, f)));
const macDir = await accP;
for (const r of RUNTIMES) writeFileSync(join(accDir, r.file), readFileSync(join(macDir, r.file)));
const md = join(logDir, `storebound_${version}_acceptance.md`);
sh(['bun', 'tools/acceptance.ts', 'report', accDir, '--md', md, '--expect', '9', '--strict']);
rmSync(accDir, { recursive: true, force: true });
sh(['bun', 'tools/desktop-release.ts', 'verify', '--out', out]);
await upsert({ tag, files: [md], create: false, yes: true });
});
// ---------------------------------------------------------------- 6. publish
await phase('publish (VDS seeds from Forgejo, Mac files once, latest.json)', async () => {
const code = await bg('publish', ['bun', 'tools/desktop-release.ts', 'publish', '--out', out, '--host', host, '--channel', rehearsal ? 'rehearsal' : 'stable', '--forgejo-tag', tag, '--yes'], join(logDir, 'publish.log'), { timeoutMs: 45 * 60_000 });
if (code !== 0) fail(`publish failed: ${join(logDir, 'publish.log').replace(HOME, '~')}`);
});
const tPublished = Date.now();
phases.push({ name: '== published ==', start: tPublished, end: tPublished });
// ---------------------------------------------------------------- 7. the app on this Mac
const dmg = readdirSync(out).find(f => f.endsWith('.dmg')) ?? fail('no DMG');
const target = rehearsal ? join(RELEASES, 'rehearsal-install', 'storebound.app') : '/Applications/storebound.app';
const installed = await phase(`install ${target.replace(HOME, '~')}`, async () => installApp(join(out, dmg), target, rehearsal));
say(`installed storebound ${installed} at ${target.replace(HOME, '~')}`);
// ---------------------------------------------------------------- 8. the end
if (rehearsal) {
await phase('rehearsal cleanup', async () => {
await deleteTest(tag, true);
for (const s of [sha]) await deleteTest(candidateTag(s), true);
sh(['git', 'push', '-q', 'origin', '--delete', branch], { allowFail: true });
sh(['git', 'checkout', '-q', startBranch === 'HEAD' ? head : startBranch]);
sh(['git', 'branch', '-q', '-D', branch], { allowFail: true });
sh(['git', 'tag', '-d', tag], { allowFail: true });
if (!/^0\.0\.0-rehearsal\.\d+$/.test(version)) fail('cleanup: not a rehearsal version');
sh(['ssh', '-o', 'BatchMode=yes', host, `rm -rf /var/www/storebound-desktop/dl/${version} /var/www/storebound-desktop/rehearsal/latest.json /var/lib/storebound-desktop/incoming/${version}`]);
rmSync(out, { recursive: true, force: true });
rmSync(dl, { recursive: true, force: true });
rmSync(resolve(target, '..'), { recursive: true, force: true });
});
}
const table = timingTable(phases, tPush);
mkdirSync(join(RELEASES, 'timings'), { recursive: true });
const tf = join(RELEASES, 'timings', `${version}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
writeFileSync(tf, `${JSON.stringify({ version, sha, tPush, tPublished, phases }, null, 2)}\n`);
console.log(`\nstorebound ${version}: ${rehearsal ? 'rehearsal done and cleaned up' : 'released'}. Times (from the tag push):\n${table}\n start of this command to tag push: ${Math.round((tPush - t0) / 1000)} s; timings in ${tf.replace(HOME, '~')}`);
if (!rehearsal) {
console.log(`\nThe owner's step (npm), from this checkout at ${tag}:
npm publish
npm deprecate storebound@"<${version}" "storebound ${version} is out: npx storebound@${version}"`);
}
}
if (import.meta.main) await main();

View file

@ -5,6 +5,7 @@
# document root, because the site's publish rsync deletes everything that is not in the repository.
Alias /desktop/latest.json /var/www/storebound-desktop/latest.json
Alias /desktop/beta/latest.json /var/www/storebound-desktop/beta/latest.json
Alias /desktop/rehearsal/latest.json /var/www/storebound-desktop/rehearsal/latest.json
Alias /desktop/dl/ /var/www/storebound-desktop/dl/
<Directory /var/www/storebound-desktop>
Options -Indexes -FollowSymLinks
@ -15,7 +16,7 @@ Alias /desktop/dl/ /var/www/storebound-desktop/dl/
Header set Content-Disposition "attachment"
</FilesMatch>
</Directory>
<LocationMatch "^/desktop/(beta/)?latest\.json$">
<LocationMatch "^/desktop/((beta|rehearsal)/)?latest\.json$">
Header set Cache-Control "no-cache"
Header set Access-Control-Allow-Origin "*"
</LocationMatch>

View file

@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Root-owned publish step for storebound desktop releases (install as /usr/local/sbin/storebound-desktop-publish,
# root:root 755; see docs/desktop-release.md). tools/desktop-release.ts publish uploads one version folder to
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta]
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta|rehearsal]
# (rehearsal: tools/release.ts --rehearsal, a throwaway prerelease at /desktop/rehearsal/latest.json that no app reads)
#
# It checks the upload (regular files with plain names only, SHA256SUMS, latest.json of that version whose every URL
# points at an uploaded file of /desktop/dl/<version>/), copies the files into /var/www/storebound-desktop/dl/<version>/
@ -20,8 +21,10 @@ ROOT=/var/www/storebound-desktop
SITE=https://storebound.dev/desktop
die() { echo "storebound-desktop-publish: $*" >&2; exit 1; }
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta]"
[[ "$CHANNEL" == stable || "$CHANNEL" == beta ]] || die "channel: stable or beta"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta|rehearsal]"
[[ "$CHANNEL" == stable || "$CHANNEL" == beta || "$CHANNEL" == rehearsal ]] || die "channel: stable, beta or rehearsal"
# a stable or beta version is never a rehearsal, and a rehearsal never reaches stable or beta
if [[ "$VERSION" == *-rehearsal.* ]]; then [ "$CHANNEL" = rehearsal ] || die "a rehearsal version goes to the rehearsal channel only"; else [ "$CHANNEL" != rehearsal ] || die "the rehearsal channel takes rehearsal versions only"; fi
IN="$INCOMING/$VERSION"
DL="$ROOT/dl/$VERSION"
[ -d "$IN" ] && [ ! -L "$IN" ] || die "nothing uploaded at $IN"
@ -51,7 +54,7 @@ for e in entries:
assert e.get("signature"), url
PY
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta"
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta" "$ROOT/rehearsal"
if [ ! -e "$DL" ]; then
TMP="$(mktemp -d "$ROOT/dl/.tmp-$VERSION.XXXXXX")"
for f in "$IN"/*; do n="$(basename "$f")"; [ "$n" = latest.json ] || install -m 644 "$f" "$TMP/$n"; done
@ -76,7 +79,7 @@ fi
install -m 644 "$IN/latest.json" "$DL/.latest.json.tmp" && mv -f "$DL/.latest.json.tmp" "$DL/latest.json"
# latest.json last: installed apps see the new version only once every file it names is in place
if [ "$CHANNEL" = stable ]; then LJ="$ROOT/latest.json"; URL="$SITE/latest.json"; else LJ="$ROOT/beta/latest.json"; URL="$SITE/beta/latest.json"; fi
if [ "$CHANNEL" = stable ]; then LJ="$ROOT/latest.json"; URL="$SITE/latest.json"; else LJ="$ROOT/$CHANNEL/latest.json"; URL="$SITE/$CHANNEL/latest.json"; fi
install -m 644 "$IN/latest.json" "$LJ.tmp"
mv -f "$LJ.tmp" "$LJ"
rm -rf "$IN"