Releases on git.crt.fyi from a workflow: a v* tag push runs .forgejo/workflows/release.yml (tag = package.json version and every version copy, the full CI, Linux AppImage + deb and Windows NSIS on the VDS runners, privacy scan with the private terms, one Forgejo release with short CHANGELOG-summary notes, the installers and SHA256SUMS, older release entries deleted, tags kept); workflow_dispatch dry run makes a draft test-<sha> and deletes it; desktop.yml folded in, ci.yml and release.yml share .forgejo/actions/linux-setup and ci-run; tools/forgejo-release.ts (notes, upsert, prune, delete; uploads over 100 MB go to the origin because Cloudflare refuses them); desktop-release.ts upload-forgejo adds the notarized DMG and updater bundle from the Mac; docs/desktop-release.md release order

This commit is contained in:
CRTkafa 2026-10-13 07:44:17 +08:00
parent e5f148e534
commit 2590b2e828
11 changed files with 871 additions and 229 deletions

View file

@ -0,0 +1,45 @@
# `bun run ci` after .forgejo/actions/linux-setup: the target / timings / Playwright caches, bun install, headless
# Chromium, Node 20 (the npm bundle's oldest supported Node) and the full CI. Shared by ci.yml and release.yml.
name: ci-run
description: storebound's full CI (bun run ci) on the Linux runner
runs:
using: composite
steps:
- uses: actions/cache@v4
with:
path: apps/desktop/src-tauri/target
key: ci-target-${{ runner.os }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ci-target-${{ runner.os }}-
# recorded test durations (shard balance) and tsc's incremental build info
- uses: actions/cache@v4
with:
path: node_modules/.cache/storebound-ci
key: ci-timings-${{ runner.os }}-${{ github.sha }}
restore-keys: ci-timings-${{ runner.os }}-
- name: Install
shell: bash
run: |
bun --version && cargo --version
bun install --frozen-lockfile
# Playwright's Chromium and headless shell (~280 MB), keyed by the browser builds the installed playwright-core
# wants (its browsers.json); the system libraries and fonts (--with-deps) are installed on every run, the job
# container starts clean
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('node_modules/playwright-core/browsers.json') }}
restore-keys: playwright-${{ runner.os }}-
# the job runs as root in a container: storebound adds --no-sandbox to the Chromium it starts itself there
# (cli/shots/browser.ts sandboxArgs), Playwright's own launches already pass it
- name: Headless Chromium (browser tests, desktop boot check)
shell: bash
run: bunx playwright install --with-deps chromium
# the npm bundle runs on Node 20+: tools/ci.ts smoke-tests dist/storebound.mjs with the oldest supported Node
- uses: actions/setup-node@v4
with:
node-version: '20'
# the runner sets CI=true in the job and it wins over the workflow env above: set CI=full for the command
# itself, or tools/ci.ts skips the desktop Rust tests and clippy when the push did not touch src-tauri
- name: bun run ci
shell: bash
run: CI=full bun run ci

View file

@ -0,0 +1,42 @@
# Shared setup of the Linux jobs (ci.yml; release.yml's ci and linux jobs) on the VDS runner (label ubuntu-22.04, the
# catthehacker act image): Tauri's system packages plus `packages`, the Bun / Cargo caches, Bun and Rust on PATH.
name: linux-setup
description: System packages, caches, Bun and Rust for storebound's Linux jobs
inputs:
packages:
description: Extra apt packages
default: ''
cache-key:
description: Cache key prefix (separate caches for CI and the installer build)
default: ci
rust-components:
description: Extra rustup components (for example clippy)
default: ''
runs:
using: composite
steps:
- name: System packages
shell: bash
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends libwebkit2gtk-4.1-dev libgtk-3-dev \
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libssl-dev libxdo-dev \
build-essential curl unzip pkg-config ${{ inputs.packages }} >/dev/null
- uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ inputs.cache-key }}-${{ runner.os }}-
- name: Bun and Rust
shell: bash
run: |
P="${FORGEJO_PATH:-$GITHUB_PATH}"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "$P"
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$P"
for c in ${{ inputs.rust-components }}; do "$HOME/.cargo/bin/rustup" component add "$c"; done

View file

@ -2,7 +2,8 @@
# desktop i18n / typecheck / build, desktop boot check (headless Chromium), desktop Rust tests and clippy, scrub. Runs on the VDS runner (docs/desktop-release.md). # 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 # 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. # 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. # Publishes nothing: site/ is still published by Jenkins; releases come from release.yml (a v* tag).
# release.yml's ci job runs the same steps (the setup is shared: .forgejo/actions/linux-setup).
name: ci name: ci
on: on:
@ -27,57 +28,8 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: System packages (Tauri on Linux) - uses: ./.forgejo/actions/linux-setup
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends libwebkit2gtk-4.1-dev libgtk-3-dev \
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libssl-dev libxdo-dev \
build-essential curl unzip pkg-config >/dev/null
- uses: actions/cache@v4
with: with:
path: | cache-key: ci
~/.bun/install/cache rust-components: clippy
~/.cargo/registry/index - uses: ./.forgejo/actions/ci-run
~/.cargo/registry/cache
~/.cargo/git/db
apps/desktop/src-tauri/target
key: ci-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ci-${{ runner.os }}-
# recorded test durations (shard balance) and tsc's incremental build info
- uses: actions/cache@v4
with:
path: node_modules/.cache/storebound-ci
key: ci-timings-${{ runner.os }}-${{ github.sha }}
restore-keys: ci-timings-${{ runner.os }}-
- name: Bun and Rust
run: |
P="${FORGEJO_PATH:-$GITHUB_PATH}"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "$P"
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy
echo "$HOME/.cargo/bin" >> "$P"
- name: Install
run: |
rustup component add clippy
bun --version && cargo --version
bun install --frozen-lockfile
# Playwright's Chromium and headless shell (~280 MB), keyed by the browser builds the installed playwright-core
# wants (its browsers.json); the system libraries and fonts (--with-deps) are installed on every run, the job
# container starts clean
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('node_modules/playwright-core/browsers.json') }}
restore-keys: playwright-${{ runner.os }}-
# the job runs as root in a container: storebound adds --no-sandbox to the Chromium it starts itself there
# (cli/shots/browser.ts sandboxArgs), Playwright's own launches already pass it
- name: Headless Chromium (browser tests, desktop boot check)
run: bunx playwright install --with-deps chromium
# the npm bundle runs on Node 20+: tools/ci.ts smoke-tests dist/storebound.mjs with the oldest supported Node
- uses: actions/setup-node@v4
with:
node-version: '20'
# the runner sets CI=true in the job and it wins over the workflow env above: set CI=full for the command
# itself, or tools/ci.ts skips the desktop Rust tests and clippy when the push did not touch src-tauri
- name: bun run ci
run: CI=full bun run ci

View file

@ -1,162 +0,0 @@
# storebound desktop: Linux (AppImage + deb) and Windows (NSIS, per user, unsigned) installers.
# macOS is built, signed and notarized on the owner's Mac (tools/desktop-release.ts mac); the updater signing key
# never reaches a runner: the Mac signs these installers after downloading them (tools/desktop-release.ts add).
# Runner setup (Forgejo runners on the VDS: Linux in Docker, Windows in a KVM VM): docs/desktop-release.md.
# Publishes nothing: the installers are workflow artifacts only (14 days); `tools/desktop-release.ts add` on the Mac
# signs and stages them, and only `publish` puts anything on storebound.dev.
name: desktop
on:
workflow_dispatch:
inputs:
windows:
description: Build Windows too (needs a runner with the "windows" label)
type: boolean
default: false
push:
tags: ['desktop-v*']
env:
BUN_VERSION: '1.4.0'
# the path-free build keeps the target folder outside every home folder
CARGO_TARGET_DIR: /tmp/storebound-target
CARGO_TERM_COLOR: never
jobs:
linux:
# 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:
# 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
- name: System packages
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libssl-dev libxdo-dev patchelf file build-essential \
curl unzip squashfs-tools p7zip-full xz-utils >/dev/null
- uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: desktop-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: desktop-${{ runner.os }}-
- name: Bun and Rust
run: |
P="${FORGEJO_PATH:-$GITHUB_PATH}"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "$P"
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$P"
- 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
windows:
# only on a manual run with "windows" ticked (a tag push never sets it), so without a Windows runner it is skipped
if: ${{ github.event_name == 'workflow_dispatch' && 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
- 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: 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

View file

@ -0,0 +1,305 @@
# The release of storebound: one version tag (v0.8.0) -> one Forgejo release with the Linux and Windows installers.
#
# 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.
#
# 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.
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Version tag to build (v0.8.0); empty = the selected branch's commit
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)
type: boolean
default: true
env:
BUN_VERSION: '1.4.0'
CARGO_TERM_COLOR: never
CARGO_INCREMENTAL: '0'
jobs:
prepare:
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 }}
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"
- uses: actions/checkout@v4
with:
ref: ${{ steps.mode.outputs.ref }}
- name: Tag, package.json version and every version copy 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
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: 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, linux, windows]
# a real release needs every platform; a dry run may skip Windows
if: ${{ always() && needs.prepare.result == 'success' && needs.ci.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}"
[ -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
- 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' }}
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

@ -144,6 +144,12 @@ TestFlight distribute from real use (2026-10-13, pitfall 42):
parameter the operation does not declare in `cli/api/apple.gen.ts`, or one in the new `APPLE_LIVE_REJECTED` list parameter the operation does not declare in `cli/api/apple.gen.ts`, or one in the new `APPLE_LIVE_REJECTED` list
(declared, refused live). `storebound api apple` refuses those parameters too. The fake App Store Connect in the (declared, refused live). `storebound api apple` refuses those parameters too. The fake App Store Connect in the
tests answers 400 PARAMETER_ERROR.ILLEGAL to such parameters, as the live API does. tests answers 400 PARAMETER_ERROR.ILLEGAL to such parameters, as the live API does.
- **Releases on git.crt.fyi come from a workflow.** A `v*` tag push runs `.forgejo/workflows/release.yml`: version
checks, the full CI, the Linux and Windows installers, the privacy scan, then one Forgejo release with short notes
(the CHANGELOG summary, install line, links), the installers and SHA256SUMS; older release entries are deleted
(tags stay). The Mac adds the notarized DMG with `desktop-release.ts upload-forgejo`. A dry run (workflow_dispatch)
makes a draft `test-<sha>` and deletes it. `desktop.yml` is folded into release.yml; ci.yml and release.yml share
their setup (`.forgejo/actions/`).
## 0.7.1 · 2026-10-13 ## 0.7.1 · 2026-10-13

View file

@ -31,9 +31,55 @@ 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` `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. 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. - `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): build and check the desktop artifacts (`mac`, `add`), `publish` them - **Release order** (one commit, one version): see "The release on git.crt.fyi" below. The app goes first
(it refuses a folder whose version is not package.json's), then `npm publish` of the same version (CONTRIBUTING.md). (`publish` refuses a folder whose version is not package.json's), then `npm publish` of the same version
The app goes first, so `npx storebound desktop` of the new CLI finds its own version. (CONTRIBUTING.md), so `npx storebound desktop` of the new CLI finds its own version.
## The release on git.crt.fyi
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.
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.
**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.
**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.
## Keys and private files (outside every repository) ## Keys and private files (outside every repository)
@ -144,8 +190,8 @@ and choose Open Anyway (once).
## Windows and Linux (Forgejo Actions) ## Windows and Linux (Forgejo Actions)
`.forgejo/workflows/desktop.yml` runs on a `desktop-v*` tag or by hand (workflow_dispatch; tick "windows" for the The `linux` and `windows` jobs of `.forgejo/workflows/release.yml` (a `v*` tag push, or a dry run by hand; untick
Windows installer too: a tag push builds Linux only). "windows" in a dry run to build Linux only).
- **Linux** (`runs-on: ubuntu-22.04`, a container): AppImage and .deb built with the path-free environment, then: - **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; - self-tests of the extracted AppImage and the unpacked .deb;
@ -181,8 +227,8 @@ installer compare with `Get-FileHash` of `C:\storebound-target\release\bundle\ns
### Runner setup ### Runner setup
Forgejo runs in Docker on the VDS. `.forgejo/workflows/ci.yml` (`bun run ci`, Rust tests and clippy included) runs on 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; `desktop.yml` as above. Neither publishes anything (Jenkins still publishes every push and pull request to main; `release.yml` as above makes the Forgejo release (Jenkins still publishes
site/). Workflow artifacts of this public repository can be downloaded by anyone for 14 days. 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 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 `FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org` in the compose file; the Actions unit is on for
@ -314,8 +360,8 @@ files stay where they are, because links and checksums must not change.
1. A signed, notarized macOS build from the release commit (the Developer ID certificate is on the Mac). The gates 1. A signed, notarized macOS build from the release commit (the Developer ID certificate is on the Mac). The gates
above must pass. above must pass.
2. Forgejo Actions enabled, the Linux runner registered, a `desktop-v0.1.0` tag built, `add` of the AppImage and 2. Forgejo Actions enabled, the Linux and Windows runners registered, a dry run of release.yml green, then the
deb. `v` tag, `add` of the AppImage, deb and setup.exe from the release.
3. Windows: a runner, or ship macOS + Linux first. The page and latest.json list only what exists. 3. Windows: a runner, or ship macOS + Linux first. The page and latest.json list only what exists.
4. `publish --yes` to stable. Then open storebound.dev/#download on each OS and run `storebound desktop --download`. 4. `publish --yes` to stable. Then open storebound.dev/#download on each OS and run `storebound desktop --download`.
5. An updater round trip: publish 0.1.1 to beta, switch a 0.1.0 install to Beta, Check for updates, Install. 5. An updater round trip: publish 0.1.1 to beta, switch a 0.1.0 install to Beta, Check for updates, Install.

View file

@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems, commitProblems, releaseTogetherProblems } from '../tools/desktop-release'; import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems, commitProblems, releaseTogetherProblems, forgejoMacFiles } from '../tools/desktop-release';
import { COPIES, setVersion, versions } from '../tools/desktop-version'; import { COPIES, setVersion, versions } from '../tools/desktop-version';
describe('desktop version authority', () => { describe('desktop version authority', () => {
@ -142,3 +142,13 @@ describe('architecture guard (no Rosetta notice for users)', () => {
expect(bundle).toBeGreaterThan(guard); expect(bundle).toBeGreaterThan(guard);
}); });
}); });
describe('upload-forgejo (the Mac adds its files to the workflow release)', () => {
test('DMG and updater bundle with signatures, only of this version', () => {
const names = ['storebound_0.8.0_universal.dmg', 'storebound_0.8.0_universal.dmg.sig', 'storebound_0.8.0_universal.app.tar.gz',
'storebound_0.8.0_universal.app.tar.gz.sig', 'storebound_0.8.0_amd64.deb', 'latest.json', 'SHA256SUMS', 'evidence-mac-0.8.0.json',
'storebound_0.7.0_universal.dmg'];
expect(forgejoMacFiles(names, '0.8.0')).toEqual(['storebound_0.8.0_universal.app.tar.gz', 'storebound_0.8.0_universal.app.tar.gz.sig',
'storebound_0.8.0_universal.dmg', 'storebound_0.8.0_universal.dmg.sig']);
});
});

View file

@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { anchor, changelogSection, isTestTag, mergeSums, pruneList, type Release, sectionSummary, shortNotes } from '../tools/forgejo-release';
const CL = `# Changelog
## Unreleased
Next things.
- a
## 0.2.0 · 2026-10-12
Summary line one
continues here. Second sentence.
- **Bullet.** detail
more detail
## 0.1.0 · 2026-10-10
- First bullet text
wraps here:
- nested
- second
`;
const rel = (id: number, tag: string, draft = false): Release => ({ id, tag_name: tag, name: tag, draft, prerelease: false, body: '', assets: [] });
describe('forgejo release notes', () => {
test('anchor matches Forgejo heading ids', () => {
expect(anchor('0.6.0 · 2026-10-12')).toBe('0-6-0-2026-10-12');
expect(anchor('Unreleased')).toBe('unreleased');
});
test('section and summary', () => {
expect(changelogSection(CL, '0.2.0')!.heading).toBe('0.2.0 · 2026-10-12');
expect(sectionSummary(changelogSection(CL, '0.2.0')!.body)).toBe('Summary line one continues here. Second sentence.');
expect(sectionSummary(changelogSection(CL, '0.1.0')!.body)).toBe('First bullet text wraps here.');
expect(changelogSection(CL, '0.3.0')).toBeNull();
});
test('short notes: summary, install, links; unknown version falls back to Unreleased', () => {
const n = shortNotes(CL, '0.2.0');
expect(n.split('\n')[0]).toBe('Summary line one continues here. Second sentence.');
expect(n).toContain('npx storebound@0.2.0 init');
expect(n).toContain('/src/tag/v0.2.0/CHANGELOG.md#0-2-0-2026-10-12');
expect(shortNotes(CL, '0.3.0')).toContain('CHANGELOG.md#unreleased');
});
test('the real CHANGELOG gives at most 8 short lines for every version', () => {
const real = readFileSync(join(import.meta.dir, '..', 'CHANGELOG.md'), 'utf8');
for (const v of [...real.matchAll(/^## (\d+\.\d+\.\d+)/gm)].map(m => m[1]!)) {
const lines = shortNotes(real, v).split('\n');
expect(lines.length).toBeLessThanOrEqual(8);
expect(lines.every(l => l.length <= 200)).toBe(true);
}
});
});
describe('forgejo release assets and pruning', () => {
test('SHA256SUMS merge: new entries win, sorted', () => {
const a = 'a'.repeat(64), b = 'b'.repeat(64), c = 'c'.repeat(64);
expect(mergeSums(`${a} x.deb\n${a} y.exe\n`, [{ file: 'y.exe', sha256: b }, { file: 'm.dmg', sha256: c }]))
.toBe(`${c} m.dmg\n${a} x.deb\n${b} y.exe\n`);
expect(mergeSums('', [])).toBe('\n');
});
test('prune keeps only the given tag, never when it is missing or a draft', () => {
const rs = [rel(3, 'v0.8.0'), rel(2, 'v0.7.0'), rel(1, 'test-abcdef1', true)];
expect(pruneList(rs, 'v0.8.0').remove.map(r => r.tag_name)).toEqual(['v0.7.0', 'test-abcdef1']);
expect(pruneList(rs, 'v0.9.0').problem).toContain('nothing is deleted');
expect(pruneList([rel(4, 'v1.0.0', true), ...rs], 'v1.0.0').problem).toContain('draft');
});
test('only test-<sha> tags may be deleted', () => {
expect(isTestTag('test-0a1b2c3')).toBe(true);
expect(isTestTag('v0.8.0')).toBe(false);
expect(isTestTag('test-latest')).toBe(false);
});
});

View file

@ -12,7 +12,7 @@
// never run, Rosetta), the native self-test (sidecar handshake) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with // never run, Rosetta), the native self-test (sidecar handshake) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with
// the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json. // the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json.
// bun tools/desktop-release.ts add --out <dir> [--commit <sha>] [--run <url>] <files...> [--code-signed] // bun tools/desktop-release.ts add --out <dir> [--commit <sha>] [--run <url>] <files...> [--code-signed]
// Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/desktop.yml) to the version // Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/release.yml) to the version
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and // 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 // 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). // folder's other evidence is refused: every artifact of a version comes from one commit).
@ -21,6 +21,10 @@
// 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).
// The folder's version must be package.json's: the npm CLI and the app are released together, the app first, // 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. // then `npm publish` of the same version.
// bun tools/desktop-release.ts upload-forgejo --out <dir> [--tag v<version>] [--yes]
// After `mac` (signed, notarized): attaches the DMG and the updater bundle with their minisign signatures to the
// Forgejo release of the tag that the release workflow made (.forgejo/workflows/release.yml); SHA256SUMS is merged.
// Without --yes only the plan. Token: FORGEJO_TOKEN or the git credential helper (tools/forgejo-release.ts).
// bun tools/desktop-release.ts verify --out <dir> // bun tools/desktop-release.ts verify --out <dir>
// The checks of publish without uploading. // The checks of publish without uploading.
// bun tools/desktop-release.ts exec -- <command...> // bun tools/desktop-release.ts exec -- <command...>
@ -36,6 +40,7 @@ import { type Download, type DownloadKey, type Manifest, manifestProblems } from
import { verify as verifySig } from '../cli/lib/minisign'; import { verify as verifySig } from '../cli/lib/minisign';
import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars'; import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
import { versions } from './desktop-version'; import { versions } from './desktop-version';
import { upsert as forgejoUpsert } from './forgejo-release';
import { scan } from './privacy-scan'; import { scan } from './privacy-scan';
export const ROOT = resolve(import.meta.dir, '..'); export const ROOT = resolve(import.meta.dir, '..');
@ -213,7 +218,7 @@ function gitState(): { commit: string; dirty: boolean } {
function args(argv: string[]) { function args(argv: string[]) {
const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; }; const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
return { get, has: (n: string) => argv.includes(`--${n}`), rest: argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !['--allow-dirty', '--no-notarize', '--yes', '--code-signed'].includes(argv[i - 1]!))) }; return { get, has: (n: string) => argv.includes(`--${n}`), rest: argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !['--allow-dirty', '--no-notarize', '--yes', '--code-signed', '--allow-unsigned'].includes(argv[i - 1]!))) };
} }
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
@ -673,6 +678,31 @@ function publish(argv: string[]): void {
say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`); say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`);
} }
// ---------------------------------------------------------------------------------------------------------------------
// upload-forgejo (the Mac's files join the release the workflow made)
/** The files of a version folder that go to the Forgejo release from the Mac: the DMG and the updater bundle, each
* with its .sig (pure). Linux and Windows files come from the workflow itself. */
export function forgejoMacFiles(names: string[], version: string): string[] {
return names.filter(n => n.startsWith(`${PRODUCT}_${version}_universal`) && /\.(dmg|app\.tar\.gz)(\.sig)?$/.test(n)).sort();
}
async function uploadForgejo(argv: string[]): Promise<void> {
const a = args(argv);
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
const { manifest } = verifyFolder(out);
const v = manifest.version;
const together = releaseTogetherProblems(v, versions().version);
if (together.length) fail(together.join('; '));
const tag = a.get('tag') ?? `v${v}`;
if (tag !== `v${v}`) fail(`--tag ${tag} is not v${v}: the folder is version ${v}`);
const files = forgejoMacFiles(readdirSync(out), v);
if (!files.some(f => f.endsWith('.dmg'))) fail(`${out}: no DMG of ${v} (run mac first)`);
if (files.some(f => f.includes('-unsigned')) && !a.has('allow-unsigned')) fail('the DMG is unsigned (-unsigned): only a notarized build goes to the release (--allow-unsigned for a preview)');
await forgejoUpsert({ tag, files: files.map(f => join(out, f)), create: false, yes: a.has('yes') });
if (a.has('yes')) say(`next: publish --out ${out.replace(homedir(), '~')} --host <user@host> (storebound.dev), then npm publish of ${v}`);
}
if (import.meta.main) { if (import.meta.main) {
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const cmd = argv[0]; const cmd = argv[0];
@ -680,6 +710,7 @@ if (import.meta.main) {
else if (cmd === 'add') add(argv); else if (cmd === 'add') add(argv);
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>'))); 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') publish(argv.slice(1));
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
else if (cmd === 'exec') { else if (cmd === 'exec') {
// `exec -- <command...>`: runs a build command with the path-free environment (the Forgejo workflow uses it) // `exec -- <command...>`: runs a build command with the path-free environment (the Forgejo workflow uses it)
const cmdline = argv.slice(argv.indexOf('--') + 1); const cmdline = argv.slice(argv.indexOf('--') + 1);
@ -687,6 +718,6 @@ if (import.meta.main) {
const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' }); const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' });
process.exit(r.status ?? 1); process.exit(r.status ?? 1);
} }
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 26).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); } else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 30).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
} }

289
tools/forgejo-release.ts Normal file
View file

@ -0,0 +1,289 @@
#!/usr/bin/env bun
// The one Forgejo release of storebound (git.crt.fyi/CRT/storebound/releases). The release workflow
// (.forgejo/workflows/release.yml) makes it on a `v*` tag push; the Mac adds the signed DMG to it afterwards
// (tools/desktop-release.ts upload-forgejo). Only the newest release entry is kept: older entries are deleted, their
// git tags stay. Notes are short: the CHANGELOG section's summary, the install line and links, never the whole section.
//
// bun tools/forgejo-release.ts notes <version> [--tag v<version>]
// Prints the short notes (summary paragraph of the CHANGELOG section, at most 4 lines, then install and links).
// bun tools/forgejo-release.ts upsert --tag <tag> [--title <t>] [--notes-file <f>] [--target <sha>] [--draft]
// [--no-create] <files...> [--yes]
// Creates or updates the release of <tag> (found by tag, drafts included), replaces assets of the same name and
// merges SHA256SUMS with the one already attached (the Mac's files join the runners' files). Safe to run again.
// bun tools/forgejo-release.ts prune --keep <tag> [--yes]
// Deletes every release entry except <tag>'s (published releases and leftover test-* drafts); tags stay.
// bun tools/forgejo-release.ts delete --tag test-<sha> [--yes]
// Deletes a dry-run draft release (and its tag, if one was made). Only test-* tags.
//
// Without --yes every write command prints its plan and changes nothing. Token: FORGEJO_TOKEN (the workflow's
// RELEASE_TOKEN secret, scope write:repository), else the git credential helper's login for the host (on the Mac).
// Never printed. FORGEJO_URL (default https://git.crt.fyi) and FORGEJO_REPOSITORY / GITHUB_REPOSITORY (CRT/storebound).
// Uploads go to the origin server directly when FORGEJO_ORIGIN (an IP; else ~/.storebound-keys/forgejo-origin) is
// set: git.crt.fyi sits behind Cloudflare, which refuses request bodies over 100 MB (502), and the AppImage and the
// DMG are bigger. TLS is still checked against the host name (curl --resolve).
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { basename, join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
export const SUMS = 'SHA256SUMS';
const SITE = 'https://storebound.dev';
// ---------------------------------------------------------------------------------------------------------------------
// Pure helpers (tests/forgejo-release.test.ts)
/** Forgejo's heading anchor: lowercase, every run of other characters becomes one dash ("0.6.0 · 2026-10-12" ->
* "0-6-0-2026-10-12"). */
export const anchor = (heading: string): string => heading.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
/** The CHANGELOG section of a version (`## 0.8.0 · date`), else null. */
export function changelogSection(changelog: string, version: string): { heading: string; body: string } | null {
const lines = changelog.split('\n');
const start = lines.findIndex(l => l.startsWith('## ') && l.slice(3).trim().split(/\s/)[0] === version);
if (start < 0) return null;
let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
if (end < 0) end = lines.length;
return { heading: lines[start]!.slice(3).trim(), body: lines.slice(start + 1, end).join('\n') };
}
/** The summary of a section: the paragraph before the first bullet, else the first bullet. One line of text. */
export function sectionSummary(body: string): string {
const lines = body.split('\n');
const firstBullet = lines.findIndex(l => /^\s*[-*] /.test(l));
const before = lines.slice(0, firstBullet < 0 ? lines.length : firstBullet).join(' ').replace(/\s+/g, ' ').trim();
if (before) return before;
if (firstBullet < 0) return '';
const bullet: string[] = [lines[firstBullet]!.replace(/^\s*[-*] /, '')];
for (const l of lines.slice(firstBullet + 1)) { if (!/^\s{2,}\S/.test(l) || /^\s*[-*] /.test(l)) break; bullet.push(l.trim()); }
return bullet.join(' ').replace(/\s+/g, ' ').replace(/:$/, '.').trim();
}
/** Cut at a sentence end within `max` characters (else at a word), then wrap to `width`. */
export function shorten(text: string, max = 420, width = 110): string[] {
let t = text;
if (t.length > max) {
const cut = t.slice(0, max);
const dot = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('.) '));
t = dot > max / 3 ? cut.slice(0, dot + 1) : `${cut.slice(0, cut.lastIndexOf(' '))} ...`;
}
const out: string[] = [];
let line = '';
for (const w of t.split(' ')) {
if (line && line.length + 1 + w.length > width) { out.push(line); line = w; } else line = line ? `${line} ${w}` : w;
}
if (line) out.push(line);
return out;
}
/** The release notes: summary (at most 4 lines), install line, links. */
export function shortNotes(changelog: string, version: string, opts: { tag?: string; repoUrl?: string } = {}): string {
const tag = opts.tag ?? `v${version}`;
const repoUrl = opts.repoUrl ?? 'https://git.crt.fyi/CRT/storebound';
const section = changelogSection(changelog, version) ?? changelogSection(changelog, 'Unreleased');
const summary = section ? shorten(sectionSummary(section.body)).slice(0, 4) : [];
const link = `${repoUrl}/src/tag/${tag}/CHANGELOG.md${section ? `#${anchor(section.heading)}` : ''}`;
return [
...(summary.length ? summary : [`storebound ${version}.`]),
'',
`Install: \`npx storebound@${version} init\` · desktop app: \`npx storebound@${version} desktop\``,
'',
`[Changelog](${link}) · [storebound.dev](${SITE}) · [Downloads](${SITE}/desktop/)`,
].join('\n');
}
/** Merge `sha256sum` lists: the new entries win for the same file name. Sorted, `sha256sum -c` format. */
export function mergeSums(existing: string, add: { file: string; sha256: string }[]): string {
const m = new Map<string, string>();
for (const l of existing.split('\n')) { const x = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(l.trim()); if (x) m.set(x[2]!, x[1]!); }
for (const e of add) m.set(e.file, e.sha256);
return [...m].sort((a, b) => a[0].localeCompare(b[0])).map(([f, h]) => `${h} ${f}`).join('\n') + '\n';
}
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. */
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) };
}
/** 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);
// ---------------------------------------------------------------------------------------------------------------------
// API
const BASE = (process.env.FORGEJO_URL ?? 'https://git.crt.fyi').replace(/\/+$/, '');
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 {
if (authHeader) return authHeader;
const token = process.env.FORGEJO_TOKEN;
if (token) return (authHeader = `token ${token}`);
const host = new URL(BASE).host;
const r = spawnSync('git', ['credential', 'fill'], { input: `protocol=https\nhost=${host}\n\n`, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
const get = (k: string) => new RegExp(`^${k}=(.*)$`, 'm').exec(r.stdout ?? '')?.[1];
const user = get('username'); const pass = get('password');
if (r.status !== 0 || !user || !pass) fail(`no FORGEJO_TOKEN and no git credential for ${host}`);
return (authHeader = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`);
}
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, {
method,
headers: { Authorization: auth(), ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) },
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
signal: AbortSignal.timeout(15 * 60_000),
});
// reads and deletes are safe to retry; creates are not (an upsert re-reads instead)
if (res.status >= 500 && attempt < 3 && (method === 'GET' || method === 'DELETE')) { await Bun.sleep(2000 * attempt); continue; }
if (!res.ok) fail(`${method} ${url.replace(BASE, '')}: ${res.status} ${(await res.text()).slice(0, 300)}`);
if (res.status === 204) return undefined as T;
return (raw ? await res.text() : await res.json()) as T;
}
}
export async function listReleases(): Promise<Release[]> {
const all: Release[] = [];
for (let page = 1; page < 50; page++) {
const rs = await api<Release[]>('GET', `/releases?limit=50&page=${page}`);
all.push(...rs);
if (rs.length < 50) break;
}
return all;
}
/** Cloudflare's request body limit: bigger uploads need the origin. */
export const PROXY_BODY_LIMIT = 100e6;
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;
if (!/^[0-9a-fA-F.:]+$/.test(v)) fail('FORGEJO_ORIGIN: an IP address');
return v;
}
/** 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> {
const url = `${API}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`;
const ip = origin();
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`);
const form = new FormData();
form.append('attachment', file ? Bun.file(file) : new Blob([data!]), name);
await api('POST', url, form);
return;
}
const dir = mkdtempSync(join(tmpdir(), 'forgejo-up-'));
try {
writeFileSync(join(dir, 'h'), `Authorization: ${auth()}\n`, { mode: 0o600 });
let src = file;
if (!src) { src = join(dir, name); writeFileSync(src, data!); }
const host = new URL(BASE).host;
const r = spawnSync('curl', ['-sS', '--fail-with-body', '--max-time', '1800', '--resolve', `${host}:443:${ip.includes(':') ? `[${ip}]` : ip}`,
'-H', `@${join(dir, 'h')}`, '-F', `attachment=@${src};filename=${name}`, url], { encoding: 'utf8', maxBuffer: 1 << 24 });
if (r.status !== 0) fail(`upload ${name} (origin): curl exit ${r.status}: ${(r.stdout + r.stderr).slice(0, 300)}`);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
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 }
/** 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> {
for (const f of o.files) statSync(f);
const files = o.files.filter(f => basename(f) !== SUMS);
let rel = (await listReleases()).find(r => r.tag_name === o.tag) ?? null;
const sumsBefore = rel?.assets.find(a => a.name === SUMS);
const existingSums = sumsBefore ? await api<string>('GET', sumsBefore.browser_download_url, undefined, true) : '';
const sums = mergeSums(existingSums, files.map(f => ({ file: basename(f), sha256: sha256(f) })));
console.log(`${rel ? `update release ${rel.id}` : 'create release'} ${o.tag}${o.draft ? ' (draft)' : ''}: ${o.title ?? rel?.name ?? o.tag}`);
for (const f of files) console.log(` ${rel?.assets.some(a => a.name === basename(f)) ? 'replace' : 'attach '} ${basename(f)} (${(statSync(f).size / 1e6).toFixed(1)} MB)`);
console.log(` ${sumsBefore ? 'replace' : 'attach '} ${SUMS} (${sums.trim().split('\n').length} files)`);
if (!o.yes) { console.log('dry run: nothing changed (--yes to write)'); return rel; }
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 };
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 });
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);
console.log(` uploaded ${name}`);
};
for (const f of files) await upload(basename(f), f, null);
await upload(SUMS, null, sums);
rel = await api<Release>('GET', `/releases/${rel.id}`);
// read back: every file attached with its size
for (const f of files) {
const a = rel.assets.find(x => x.name === basename(f));
if (!a || a.size !== statSync(f).size) fail(`read back: ${basename(f)} is ${a ? `${a.size} bytes, expected ${statSync(f).size}` : 'missing'}`);
}
console.log(`release ${o.tag}: ${rel.assets.length} assets, ${BASE}/${REPO}/releases/tag/${o.tag}`);
return rel;
}
async function prune(keep: string, yes: boolean): Promise<void> {
const { remove, problem } = pruneList(await listReleases(), keep);
if (problem) fail(problem);
if (!remove.length) { console.log(`only ${keep} is left: nothing to delete`); return; }
for (const r of remove) console.log(`delete release entry ${r.tag_name} (${r.name}${r.draft ? ', draft' : ''}); the tag stays${isTestTag(r.tag_name) ? ' (test tag: deleted too)' : ''}`);
if (!yes) { console.log('dry run: nothing deleted (--yes to delete)'); return; }
for (const r of remove) {
await api('DELETE', `/releases/${r.id}`);
if (isTestTag(r.tag_name)) await deleteTag(r.tag_name);
}
console.log(`kept ${keep}; deleted ${remove.length} release entr${remove.length === 1 ? 'y' : 'ies'}`);
}
async function deleteTag(tag: string): Promise<void> {
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}`);
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; }
if (rel) await api('DELETE', `/releases/${rel.id}`);
await deleteTag(tag);
console.log(`deleted ${tag}`);
}
function args(argv: string[]) {
const flags = new Set(['--yes', '--draft', '--no-create']);
const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
const rest = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !flags.has(argv[i - 1]!)));
return { get, has: (n: string) => argv.includes(`--${n}`), rest };
}
if (import.meta.main) {
const [cmd, ...argv] = process.argv.slice(2);
const a = args(argv);
if (cmd === 'notes') {
const version = a.rest[0] ?? fail('usage: notes <version>');
console.log(shortNotes(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, { tag: a.get('tag') }));
} else if (cmd === 'upsert') {
const tag = a.get('tag') ?? fail('--tag is required');
const nf = a.get('notes-file');
await upsert({ tag, title: a.get('title'), notes: nf ? readFileSync(nf, 'utf8') : undefined, target: a.get('target'), draft: a.has('draft'), create: !a.has('no-create'), files: a.rest, yes: a.has('yes') });
} else if (cmd === 'prune') await prune(a.get('keep') ?? fail('--keep <tag> is required'), a.has('yes'));
else if (cmd === 'delete') await deleteTest(a.get('tag') ?? fail('--tag is required'), 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); }
}