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:
parent
e5f148e534
commit
2590b2e828
11 changed files with 871 additions and 229 deletions
45
.forgejo/actions/ci-run/action.yml
Normal file
45
.forgejo/actions/ci-run/action.yml
Normal 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
|
||||
42
.forgejo/actions/linux-setup/action.yml
Normal file
42
.forgejo/actions/linux-setup/action.yml
Normal 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
|
||||
|
|
@ -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).
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
on:
|
||||
|
|
@ -27,57 +28,8 @@ jobs:
|
|||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: System packages (Tauri on Linux)
|
||||
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
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
~/.cargo/registry/index
|
||||
~/.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
|
||||
cache-key: ci
|
||||
rust-components: clippy
|
||||
- uses: ./.forgejo/actions/ci-run
|
||||
|
|
|
|||
|
|
@ -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
|
||||
305
.forgejo/workflows/release.yml
Normal file
305
.forgejo/workflows/release.yml
Normal 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
|
||||
|
|
@ -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
|
||||
(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.
|
||||
- **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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
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): build and check the desktop artifacts (`mac`, `add`), `publish` them
|
||||
(it refuses a folder whose version is not package.json's), then `npm publish` of the same version (CONTRIBUTING.md).
|
||||
The app goes first, so `npx storebound desktop` of the new CLI finds its own version.
|
||||
- **Release order** (one commit, one version): see "The release on git.crt.fyi" 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
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -144,8 +190,8 @@ and choose Open Anyway (once).
|
|||
|
||||
## Windows and Linux (Forgejo Actions)
|
||||
|
||||
`.forgejo/workflows/desktop.yml` runs on a `desktop-v*` tag or by hand (workflow_dispatch; tick "windows" for the
|
||||
Windows installer too: a tag push builds Linux only).
|
||||
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).
|
||||
|
||||
- **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;
|
||||
|
|
@ -181,8 +227,8 @@ 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; `desktop.yml` as above. Neither publishes anything (Jenkins still publishes
|
||||
site/). Workflow artifacts of this public repository can be downloaded by anyone for 14 days.
|
||||
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.
|
||||
|
||||
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
|
||||
|
|
@ -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
|
||||
above must pass.
|
||||
2. Forgejo Actions enabled, the Linux runner registered, a `desktop-v0.1.0` tag built, `add` of the AppImage and
|
||||
deb.
|
||||
2. Forgejo Actions enabled, the Linux and Windows runners registered, a dry run of release.yml green, then the
|
||||
`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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test';
|
|||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
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';
|
||||
|
||||
describe('desktop version authority', () => {
|
||||
|
|
@ -142,3 +142,13 @@ describe('architecture guard (no Rosetta notice for users)', () => {
|
|||
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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
78
tests/forgejo-release.test.ts
Normal file
78
tests/forgejo-release.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// 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]
|
||||
// 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
|
||||
// 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).
|
||||
|
|
@ -21,6 +21,10 @@
|
|||
// 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,
|
||||
// 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>
|
||||
// The checks of publish without uploading.
|
||||
// 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 { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
|
||||
import { versions } from './desktop-version';
|
||||
import { upsert as forgejoUpsert } from './forgejo-release';
|
||||
import { scan } from './privacy-scan';
|
||||
|
||||
export const ROOT = resolve(import.meta.dir, '..');
|
||||
|
|
@ -213,7 +218,7 @@ function gitState(): { commit: string; dirty: boolean } {
|
|||
|
||||
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}`), 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.`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 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) {
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0];
|
||||
|
|
@ -680,6 +710,7 @@ if (import.meta.main) {
|
|||
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 === '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)
|
||||
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' });
|
||||
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
289
tools/forgejo-release.ts
Normal 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); }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue