release candidates: every push to main runs the full CI, the acceptance matrix and the Linux / Windows installer builds in parallel (ci.yml) and records the green result per commit as the draft rc-<sha> (green.json last); a version-only bump commit (proven file by file with the version writers) reuses its parent's CI and matrix and rebuilds only the installers; a v* tag only promotes the candidate (release.yml), fails fast on a red or missing commit; acceptance runs carry a CLI key; tools/release.ts does the whole release on the Mac (bump, CHANGELOG cut, fast gate, push, tag, Mac build + notarize in parallel with Forgejo, cached macOS acceptance, add, verify, publish with one upload of the Mac files, /Applications update, npm commands) and --rehearsal; DMG notarization runs while the app and updater bundle are checked; rehearsal channel on the VDS; docs
This commit is contained in:
parent
89d94d6993
commit
c3b1e8c370
14 changed files with 1438 additions and 526 deletions
|
|
@ -1,15 +1,23 @@
|
|||
# bun run ci on every push to main and every pull request into main: adapters, desktop version, tests, typecheck,
|
||||
# desktop i18n / typecheck / build, desktop boot check (headless Chromium), desktop Rust tests and clippy, scrub. Runs on the VDS runner (docs/desktop-release.md).
|
||||
# tools/ci.ts runs the steps as a task graph and the tests as parallel shards (CPU cores - 1, max 8) on the one
|
||||
# self-hosted runner, so a job matrix would only queue; CI=full makes it run the Rust steps on every push.
|
||||
# Publishes nothing: site/ is still published by Jenkins; releases come from release.yml (a v* tag).
|
||||
# acceptance-quick: the quick subset of the acceptance matrix (tools/acceptance.ts) on the Linux and Windows runners.
|
||||
# release.yml's ci job runs the same steps (the setup is shared: .forgejo/actions/linux-setup).
|
||||
# Every push to main is a release candidate (docs/desktop-release.md, "Release candidates"). In parallel:
|
||||
# ci bun run ci (adapters, desktop version, tests, typecheck, desktop i18n / typecheck / build, the boot check
|
||||
# in headless Chromium, desktop Rust tests and clippy, scrub); tools/ci.ts runs it as a task graph with
|
||||
# parallel test shards on the one Linux runner. CI=full runs the Rust steps on every push.
|
||||
# acceptance the release acceptance matrix (tools/acceptance.ts): every user-facing command from the packed npm
|
||||
# tarball with real piped stdin, Linux and Windows x Node 20 / Node 22 / Bun.
|
||||
# linux AppImage + deb (path-free build), self-tests, both started under Xvfb, privacy scan.
|
||||
# windows NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
|
||||
# record when all are green: the draft release rc-<sha> with the installers, the acceptance runs and matrix and
|
||||
# green.json (tools/release-candidate.ts record). A v* tag on this commit only promotes it (release.yml).
|
||||
# gate decides first: a commit that differs from its green parent only in version copies + CHANGELOG (a release's
|
||||
# version bump, proven file by file: tools/release-candidate.ts gate) reuses the parent's ci and acceptance results
|
||||
# and builds only the installers, whose embedded version changed.
|
||||
# Pull requests run ci + acceptance (no installers, no record). rehearsal/* branches are release rehearsals
|
||||
# (tools/release.ts --rehearsal). site/ is still published by Jenkins.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, 'rehearsal/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
|
@ -21,11 +29,37 @@ env:
|
|||
CI: full
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-22.04
|
||||
# the self-hosted runners only run code from this repo: pull requests from forks are skipped
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
reuse: ${{ steps.gate.outputs.reuse }}
|
||||
from: ${{ steps.gate.outputs.from }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Version-only commit of a green parent?
|
||||
id: gate
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "$EVENT" = pull_request ]; then echo "reuse=false" >> "$GITHUB_OUTPUT"; echo "pull request: everything runs"; exit 0; fi
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
bun tools/release-candidate.ts gate --sha "$SHA" --wait 40
|
||||
|
||||
ci:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.reuse != 'true'
|
||||
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04
|
||||
runs-on: ubuntu-22.04
|
||||
# the self-hosted runner only runs code from this repo: pull requests from forks are skipped
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -35,43 +69,255 @@ jobs:
|
|||
rust-components: clippy
|
||||
- uses: ./.forgejo/actions/ci-run
|
||||
|
||||
# The quick subset of the release acceptance matrix (tools/acceptance.ts --quick: --version, init, check, scan, the
|
||||
# desktop [Y/n] prompt answered n, mcp, a console plan, store --dry writes and confirm prompts answered n) from the
|
||||
# packed npm tarball with real piped stdin, on Linux and Windows with Node 20. The full matrix gates the release.
|
||||
acceptance-quick:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
# The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed
|
||||
# npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22
|
||||
# (npx --package=<tgz>) / Bun (bun x). One red cell and the commit is no candidate. macOS runs on the owner's Mac
|
||||
# (tools/release.ts, cached per CLI key).
|
||||
acceptance:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.reuse != 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: ubuntu-22.04, platform: Linux }
|
||||
- { os: windows, platform: Windows }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
|
||||
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: ${{ matrix.node }}
|
||||
- name: Bun (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
- name: Acceptance quick (Linux, Node 20)
|
||||
- name: Acceptance matrix (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: bun tools/acceptance.ts run --runtime node --platform Linux --quick --jobs 4 --out acceptance/Linux-node20-quick.json
|
||||
- name: Acceptance quick (Windows, Node 20)
|
||||
run: |
|
||||
node --version && bun --version
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
|
||||
- name: Acceptance matrix (Windows)
|
||||
if: ${{ matrix.platform == 'Windows' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun tools/acceptance.ts run --runtime node --platform Windows --quick --jobs 3 --out acceptance/Windows-node20-quick.json
|
||||
if ($LASTEXITCODE -ne 0) { throw "acceptance quick: red cells" }
|
||||
node --version; bun --version
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
|
||||
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: acceptance-quick-${{ matrix.platform }}
|
||||
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
|
||||
path: acceptance/
|
||||
retention-days: 14
|
||||
|
||||
linux:
|
||||
needs: gate
|
||||
if: github.event_name != 'pull_request'
|
||||
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
|
||||
# deb then run on Ubuntu 22.04 and newer, Debian 12)
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# the path-free build keeps the target folder outside every home folder
|
||||
CARGO_TARGET_DIR: /tmp/storebound-target
|
||||
# linuxdeploy (AppImage) runs without FUSE inside the container
|
||||
APPIMAGE_EXTRACT_AND_RUN: '1'
|
||||
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
|
||||
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
|
||||
NO_STRIP: '1'
|
||||
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
|
||||
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
cache-key: desktop
|
||||
packages: patchelf file squashfs-tools p7zip-full xz-utils
|
||||
# the release target folder (dependencies compiled once; a version bump recompiles only the app crate)
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/storebound-target
|
||||
key: desktop-target-${{ runner.os }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
|
||||
restore-keys: desktop-target-${{ runner.os }}-
|
||||
- name: Install and check
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-linux-x64
|
||||
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
|
||||
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
|
||||
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
|
||||
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
||||
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
|
||||
- name: Build AppImage and deb (path-free)
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
rm -rf "$CARGO_TARGET_DIR/release/bundle"
|
||||
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
|
||||
- name: Self-test the exact artifacts
|
||||
run: |
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
B="$CARGO_TARGET_DIR/release/bundle"
|
||||
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
|
||||
chmod +x out/*.AppImage
|
||||
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
||||
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
|
||||
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
|
||||
- name: Start the app under Xvfb (AppImage and deb)
|
||||
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
|
||||
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
|
||||
run: |
|
||||
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
smoke() { # <label> <executable>
|
||||
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
|
||||
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
|
||||
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
|
||||
local pid=$! ok=0
|
||||
for i in $(seq 1 90); do
|
||||
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
|
||||
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
|
||||
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
|
||||
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
|
||||
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
|
||||
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
|
||||
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
|
||||
echo "$1: window started, UI reached the sidecar"
|
||||
}
|
||||
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
|
||||
smoke deb /tmp/deb/usr/bin/storebound-desktop
|
||||
- name: Privacy scan
|
||||
run: bun tools/privacy-scan.ts out/
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: out/
|
||||
retention-days: 7
|
||||
|
||||
windows:
|
||||
needs: gate
|
||||
if: github.event_name != 'pull_request'
|
||||
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
|
||||
runs-on: windows
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TARGET_DIR: C:\storebound-target
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Install and check
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-windows-x64
|
||||
- name: Node bundle on Windows (npx storebound) - stdin prompts read with node, clean exit
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun tools/build-node.ts
|
||||
node --version
|
||||
bun tools/node-smoke.ts --only prompt
|
||||
if ($LASTEXITCODE -ne 0) { throw "node prompt smoke failed" }
|
||||
"n" | node dist/storebound.mjs --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "node dist/storebound.mjs --version failed" }
|
||||
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
|
||||
shell: pwsh
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
|
||||
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
|
||||
- name: Install per user, self-test, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$V = bun tools/desktop-version.ts --print
|
||||
New-Item -ItemType Directory -Force out | Out-Null
|
||||
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
|
||||
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
|
||||
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
|
||||
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
|
||||
& $exe --self-test | Tee-Object -Variable st
|
||||
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
|
||||
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
|
||||
- name: Privacy scan (no .pdb, no paths)
|
||||
shell: pwsh
|
||||
run: bun tools/privacy-scan.ts out
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: out/
|
||||
retention-days: 7
|
||||
|
||||
record:
|
||||
needs: [gate, ci, acceptance, linux, windows]
|
||||
if: ${{ always() && github.event_name != 'pull_request' && needs.gate.result == 'success' && needs.linux.result == 'success' && needs.windows.result == 'success' && ((needs.gate.outputs.reuse == 'true' && needs.ci.result == 'skipped' && needs.acceptance.result == 'skipped') || (needs.ci.result == 'success' && needs.acceptance.result == 'success')) }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
SHA: ${{ github.sha }}
|
||||
REUSE: ${{ needs.gate.outputs.reuse }}
|
||||
FROM: ${{ needs.gate.outputs.from }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tools
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq --no-install-recommends squashfs-tools p7zip-full xz-utils binutils unzip file >/dev/null
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: cand
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: cand
|
||||
- uses: actions/download-artifact@v3
|
||||
if: ${{ needs.gate.outputs.reuse != 'true' }}
|
||||
with:
|
||||
path: runs
|
||||
- name: Acceptance runs (this run's, or the version-only parent's)
|
||||
run: |
|
||||
mkdir -p cand/acceptance
|
||||
if [ "$REUSE" = true ]; then
|
||||
bun tools/release-candidate.ts fetch --sha "$FROM" --dir cand/acceptance --only '^acceptance-.*\.json$'
|
||||
else
|
||||
find runs -path '*acceptance-*' -name '*.json' -exec cp {} cand/acceptance/ \;
|
||||
fi
|
||||
ls cand/acceptance
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
bun tools/acceptance.ts report cand/acceptance --md "cand/storebound_${V}_acceptance.md" --expect 6 --strict
|
||||
- name: Privacy scan (private terms) of the installers
|
||||
env:
|
||||
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
|
||||
run: |
|
||||
[ -n "$FORBIDDEN_TERMS" ] || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
|
||||
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
|
||||
rm -f cand/SHA256SUMS
|
||||
STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts cand/*.AppImage cand/*.deb cand/*.exe
|
||||
rm -f "$RUNNER_TEMP/terms.txt"
|
||||
- name: Record the candidate (draft rc-<sha>, green.json last)
|
||||
run: |
|
||||
bun tools/release-candidate.ts record --sha "$SHA" --run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_NUMBER" --dir cand ${FROM:+--reused-from "$FROM"} --yes
|
||||
bun tools/release-candidate.ts prune --keep 12 --yes
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
# The release of storebound: one version tag (v0.8.0) -> one Forgejo release with the Linux and Windows installers.
|
||||
# The release of storebound: a version tag (v0.8.5) promotes the release candidate of its commit. Nothing is rebuilt
|
||||
# or retested here: ci.yml ran the full CI, the acceptance matrix (Linux + Windows x Node 20 / 22 / Bun) and built,
|
||||
# self-tested and privacy-scanned the Linux and Windows installers for that very commit (for a version-only bump
|
||||
# commit: the installers, with the parent's CI and matrix, proven version-only file by file), and stored them as the
|
||||
# draft rc-<sha> with green.json (tools/release-candidate.ts).
|
||||
#
|
||||
# push of a tag v* checks (tag = package.json version, every version copy equal), the full CI, the
|
||||
# AppImage + deb (Linux runner, Docker) and the NSIS installer (Windows runner, VM), the
|
||||
# privacy scan of every artifact, then ONE release "storebound <version>" with short notes
|
||||
# (tools/forgejo-release.ts notes: the CHANGELOG summary, install line, links), the
|
||||
# installers and SHA256SUMS; older release entries are deleted afterwards (tags stay).
|
||||
# workflow_dispatch dry (default): the same jobs on this commit (or on `tag`), a DRAFT release test-<sha>
|
||||
# with the real files, read back, then deleted. dry off + tag: (re)make that tag's release.
|
||||
# push of a tag v* checks (tag = package.json version, every version copy equal, the CHANGELOG section), waits
|
||||
# for the commit's candidate (heartbeat; fails at once when the commit is red or has no ci run,
|
||||
# with the run's link; times out after 40 min), then ONE release "storebound <version>" with the
|
||||
# candidate's installers, the acceptance matrix, SHA256SUMS and short notes. Stable versions
|
||||
# then delete the older release entries (tags stay). A version with a "-" (0.0.0-rehearsal.1)
|
||||
# is a prerelease: never pruning, never the stable release.
|
||||
# workflow_dispatch tag: the same for an existing tag; dry (default): only the plan.
|
||||
#
|
||||
# Not here: macOS. The DMG is built, signed and notarized on the owner's Mac and added to this release with
|
||||
# `bun tools/desktop-release.ts upload-forgejo --out <dir> --yes`; then `publish` (storebound.dev) and `npm publish`
|
||||
# (the owner's step). Order and details: docs/desktop-release.md. The updater signing key never reaches a runner.
|
||||
# Secrets (repo Settings > Actions > Secrets): RELEASE_TOKEN (token, scope write:repository: releases and assets),
|
||||
# FORBIDDEN_TERMS (the private terms list of the privacy scan; never printed), FORGEJO_ORIGIN (the server's IP: asset
|
||||
# uploads over 100 MB bypass Cloudflare).
|
||||
# Runners on the VDS (labels ubuntu-22.04 and windows): docs/desktop-release.md.
|
||||
# Not here: macOS. tools/release.ts on the owner's Mac builds, signs and notarizes the DMG in parallel and adds it to
|
||||
# this release, then publishes storebound.dev (docs/desktop-release.md). The updater signing key never reaches a runner.
|
||||
# Secrets: RELEASE_TOKEN (scope write:repository), FORGEJO_ORIGIN (the server's IP: uploads over 100 MB bypass
|
||||
# Cloudflare). The private terms scan of the installers ran in ci.yml's record job (FORBIDDEN_TERMS).
|
||||
name: release
|
||||
|
||||
on:
|
||||
|
|
@ -23,401 +24,55 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Version tag to build (v0.8.0); empty = the selected branch's commit
|
||||
description: Version tag to release (v0.8.5)
|
||||
type: string
|
||||
default: ''
|
||||
dry:
|
||||
description: Dry run (a draft release test-<sha> with the real files, deleted at the end)
|
||||
type: boolean
|
||||
default: true
|
||||
windows:
|
||||
description: Build Windows too (always on for a tag push)
|
||||
description: Dry run (only the plan)
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
BUN_VERSION: '1.4.0'
|
||||
CARGO_TERM_COLOR: never
|
||||
CARGO_INCREMENTAL: '0'
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
promote:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
ref: ${{ steps.mode.outputs.ref }}
|
||||
tag: ${{ steps.check.outputs.tag }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
dry: ${{ steps.mode.outputs.dry }}
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
|
||||
steps:
|
||||
- name: Mode
|
||||
id: mode
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_DRY: ${{ inputs.dry }}
|
||||
run: |
|
||||
# a boolean input arrives as true / false (API: also "on" / "1"); only an explicit no is a real release
|
||||
case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) want_dry=false;; *) want_dry=true;; esac
|
||||
echo "inputs: tag='$IN_TAG' dry='$IN_DRY'"
|
||||
if [ "$EVENT" = push ]; then ref="refs/tags/$REF_NAME"; dry=false
|
||||
elif [ -n "$IN_TAG" ]; then ref="refs/tags/$IN_TAG"; dry=$want_dry
|
||||
else ref="$SHA"; dry=true
|
||||
[ "$want_dry" = true ] || { echo "a real release needs a tag (input tag), or push the tag" >&2; exit 1; }
|
||||
fi
|
||||
echo "ref=$ref" >> "$GITHUB_OUTPUT"; echo "dry=$dry" >> "$GITHUB_OUTPUT"
|
||||
echo "event=$EVENT ref=$ref dry=$dry"
|
||||
[ -n "$TAG" ] || { echo "input tag is required" >&2; exit 1; }
|
||||
case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) dry=false;; *) dry=true;; esac
|
||||
[ "$EVENT" = push ] && dry=false
|
||||
echo "dry=$dry" >> "$GITHUB_OUTPUT"; echo "tag=$TAG dry=$dry"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.mode.outputs.ref }}
|
||||
- name: Tag, package.json version and every version copy agree
|
||||
ref: refs/tags/${{ env.TAG }}
|
||||
- name: Tag, package.json version, every version copy and the CHANGELOG agree
|
||||
id: check
|
||||
env:
|
||||
TAG_IN: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
|
||||
DRY: ${{ steps.mode.outputs.dry }}
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
bun tools/desktop-version.ts
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
if [ -n "$TAG_IN" ] && [ "$TAG_IN" != "v$V" ]; then echo "tag $TAG_IN is not v$V (package.json): bun tools/desktop-version.ts --set <version>, commit, tag that commit" >&2; exit 1; fi
|
||||
grep -q "^## $V" CHANGELOG.md || { [ "$DRY" = true ] && echo "note: no CHANGELOG section for $V (the notes use Unreleased)"; } || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; }
|
||||
if [ "$DRY" = true ]; then tag="test-$(git rev-parse --short=10 HEAD)"; else tag="$TAG_IN"; fi
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"; echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "release $tag (version $V, dry=$DRY)"
|
||||
|
||||
ci:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CI: full
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
cache-key: ci
|
||||
rust-components: clippy
|
||||
- uses: ./.forgejo/actions/ci-run
|
||||
|
||||
linux:
|
||||
needs: prepare
|
||||
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
|
||||
# deb then run on Ubuntu 22.04 and newer, Debian 12)
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# the path-free build keeps the target folder outside every home folder
|
||||
CARGO_TARGET_DIR: /tmp/storebound-target
|
||||
# linuxdeploy (AppImage) runs without FUSE inside the container
|
||||
APPIMAGE_EXTRACT_AND_RUN: '1'
|
||||
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
|
||||
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
|
||||
NO_STRIP: '1'
|
||||
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
|
||||
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
cache-key: desktop
|
||||
packages: patchelf file squashfs-tools p7zip-full xz-utils
|
||||
- name: Install and check
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-linux-x64
|
||||
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
|
||||
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
|
||||
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
|
||||
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
||||
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
|
||||
- name: Build AppImage and deb (path-free)
|
||||
working-directory: apps/desktop
|
||||
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
|
||||
- name: Self-test the exact artifacts
|
||||
run: |
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
B="$CARGO_TARGET_DIR/release/bundle"
|
||||
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
|
||||
chmod +x out/*.AppImage
|
||||
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
||||
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
|
||||
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
|
||||
- name: Start the app under Xvfb (AppImage and deb)
|
||||
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
|
||||
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
|
||||
run: |
|
||||
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
smoke() { # <label> <executable>
|
||||
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
|
||||
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
|
||||
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
|
||||
local pid=$! ok=0
|
||||
for i in $(seq 1 90); do
|
||||
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
|
||||
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
|
||||
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
|
||||
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
|
||||
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
|
||||
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
|
||||
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
|
||||
echo "$1: window started, UI reached the sidecar"
|
||||
}
|
||||
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
|
||||
smoke deb /tmp/deb/usr/bin/storebound-desktop
|
||||
- name: Privacy scan
|
||||
run: bun tools/privacy-scan.ts out/
|
||||
- name: Checksums
|
||||
run: (cd out && sha256sum * | tee SHA256SUMS)
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: out/
|
||||
retention-days: 14
|
||||
|
||||
|
||||
# The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed
|
||||
# npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22
|
||||
# (npx --package=<tgz>) / Bun (bun x). One red cell and there is no release. macOS runs on the owner's Mac
|
||||
# (bun tools/acceptance.ts run ...) before the DMG upload; its result files live in docs/acceptance/.
|
||||
acceptance:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
|
||||
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- name: Bun (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
- name: Acceptance matrix (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
node --version && bun --version
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
|
||||
- name: Acceptance matrix (Windows)
|
||||
if: ${{ matrix.platform == 'Windows' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
node --version; bun --version
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
|
||||
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
|
||||
path: acceptance/
|
||||
retention-days: 30
|
||||
|
||||
acceptance-report:
|
||||
needs: [prepare, acceptance]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Linux-node20
|
||||
path: acceptance-runs/Linux-node20
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Linux-node22
|
||||
path: acceptance-runs/Linux-node22
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Linux-bun
|
||||
path: acceptance-runs/Linux-bun
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Windows-node20
|
||||
path: acceptance-runs/Windows-node20
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Windows-node22
|
||||
path: acceptance-runs/Windows-node22
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Windows-bun
|
||||
path: acceptance-runs/Windows-bun
|
||||
- name: Matrix (markdown), red cells fail the job
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
V="${{ needs.prepare.outputs.version }}"
|
||||
mkdir -p out
|
||||
bun tools/acceptance.ts report acceptance-runs docs/acceptance --md "out/storebound_${V}_acceptance.md" --expect 9 --strict
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: storebound-acceptance
|
||||
path: out/
|
||||
retention-days: 30
|
||||
|
||||
windows:
|
||||
needs: prepare
|
||||
# a tag push always builds Windows; a manual run unless "windows" is unticked
|
||||
if: ${{ github.event_name == 'push' || inputs.windows }}
|
||||
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
|
||||
runs-on: windows
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TARGET_DIR: C:\storebound-target
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- name: Install and check
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-windows-x64
|
||||
- name: Node bundle on Windows (npx storebound) - stdin prompts read with node, clean exit
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun tools/build-node.ts
|
||||
node --version
|
||||
bun tools/node-smoke.ts --only prompt
|
||||
if ($LASTEXITCODE -ne 0) { throw "node prompt smoke failed" }
|
||||
"n" | node dist/storebound.mjs --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "node dist/storebound.mjs --version failed" }
|
||||
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
|
||||
shell: pwsh
|
||||
working-directory: apps/desktop
|
||||
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
|
||||
- name: Install per user, self-test, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$V = bun tools/desktop-version.ts --print
|
||||
New-Item -ItemType Directory -Force out | Out-Null
|
||||
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
|
||||
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
|
||||
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
|
||||
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
|
||||
& $exe --self-test | Tee-Object -Variable st
|
||||
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
|
||||
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
|
||||
- name: Privacy scan (no .pdb, no paths)
|
||||
shell: pwsh
|
||||
run: bun tools/privacy-scan.ts out
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: out/
|
||||
retention-days: 14
|
||||
|
||||
release:
|
||||
needs: [prepare, ci, acceptance, acceptance-report, linux, windows]
|
||||
# a real release needs every platform and a green acceptance matrix; a dry run may skip Windows
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.acceptance.result == 'success' && needs.acceptance-report.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
DRY: ${{ needs.prepare.outputs.dry }}
|
||||
SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- name: Tools
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq --no-install-recommends squashfs-tools p7zip-full xz-utils binutils unzip file >/dev/null
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
[ "$TAG" = "v$V" ] || { echo "tag $TAG is not v$V (package.json): bun tools/release.ts <version> makes the bump commit and the tag" >&2; exit 1; }
|
||||
case "$V" in *-*) echo "prerelease $V: the CHANGELOG section is optional";; *) grep -q "^## $V" CHANGELOG.md || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; };; esac
|
||||
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: dist/linux
|
||||
- uses: actions/download-artifact@v3
|
||||
if: ${{ needs.windows.result == 'success' }}
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: dist/windows
|
||||
# the acceptance matrix goes with the release (storebound_<version>_acceptance.md)
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-acceptance
|
||||
path: dist/acceptance
|
||||
- name: Collect, privacy scan (private terms), checksums
|
||||
env:
|
||||
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
|
||||
run: |
|
||||
mkdir -p out
|
||||
find dist -type f ! -name SHA256SUMS -exec cp {} out/ \;
|
||||
for f in out/*; do case "$f" in *"_${VERSION}_"*) ;; *) echo "$f is not version $VERSION" >&2; exit 1;; esac; done
|
||||
[ -n "$FORBIDDEN_TERMS" ] || { [ "$DRY" = true ] && echo "note: secret FORBIDDEN_TERMS is missing: generic checks only"; } || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
|
||||
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
|
||||
STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out
|
||||
rm -f "$RUNNER_TEMP/terms.txt"
|
||||
(cd out && sha256sum * | tee ../SHA256SUMS.local)
|
||||
ls -la out
|
||||
- name: Notes
|
||||
run: |
|
||||
bun tools/forgejo-release.ts notes "$VERSION" --tag "$([ "$DRY" = true ] && echo "v$VERSION" || echo "$TAG")" | tee notes.md
|
||||
[ "$(wc -l < notes.md)" -le 8 ] || { echo "notes are longer than 8 lines" >&2; exit 1; }
|
||||
- name: Release (create or update, attach, read back)
|
||||
run: |
|
||||
if [ "$DRY" = true ]; then
|
||||
bun tools/forgejo-release.ts upsert --tag "$TAG" --target "$SHA" --title "$TAG (dry run, deleted at the end)" --notes-file notes.md --draft --yes out/*
|
||||
else
|
||||
bun tools/forgejo-release.ts upsert --tag "$TAG" --title "storebound $VERSION" --notes-file notes.md --yes out/*
|
||||
fi
|
||||
- name: Keep only the newest release (tags stay)
|
||||
if: ${{ needs.prepare.outputs.dry != 'true' }}
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
- name: Promote the commit's green candidate
|
||||
run: bun tools/release-candidate.ts promote --tag "$TAG" --sha "${{ steps.check.outputs.sha }}" --wait 40 ${{ steps.mode.outputs.dry == 'false' && '--yes' || '' }}
|
||||
- name: Keep only the newest release (stable versions; tags stay)
|
||||
if: ${{ steps.mode.outputs.dry == 'false' && !contains(steps.check.outputs.version, '-') }}
|
||||
run: bun tools/forgejo-release.ts prune --keep "$TAG" --yes
|
||||
- name: Dry run - what prune would delete
|
||||
if: ${{ needs.prepare.outputs.dry == 'true' }}
|
||||
run: |
|
||||
KEEP=$(curl -fsS -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_URL/api/v1/repos/$GITHUB_REPOSITORY/releases?limit=1&draft=false" | bun -e 'const r=await Bun.stdin.json(); console.log(r[0]?.tag_name ?? "")')
|
||||
[ -z "$KEEP" ] || bun tools/forgejo-release.ts prune --keep "$KEEP"
|
||||
- name: Dry run - delete the test draft
|
||||
if: ${{ always() && needs.prepare.outputs.dry == 'true' }}
|
||||
run: bun tools/forgejo-release.ts delete --tag "$TAG" --yes
|
||||
|
|
|
|||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -2,11 +2,23 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
Releases take one command and minutes: every push to main is a release candidate (full CI, the acceptance matrix and
|
||||
the Linux / Windows installers stored per commit), a version tag only promotes it, and `bun tools/release.ts` does
|
||||
the rest on the Mac.
|
||||
|
||||
- **A Chrome storebound starts never outlives its run.** The console window's Chrome is ended on exit, closed
|
||||
gracefully on Ctrl+C / SIGTERM / SIGHUP, and ended by a watchdog when the run dies in any way (SIGKILL included).
|
||||
Every start first ends headless storebound Chromes earlier runs left behind (an orphan kept the owner's own Chrome
|
||||
from opening from the macOS Dock) and names orphaned windows. Headless runs on macOS prefer Chromium / Chrome for
|
||||
Testing over Google Chrome.app. `console fill` closes the window on an error too. Pitfall 68.
|
||||
- **Every push to main is a release candidate.** ci.yml runs the full CI, the acceptance matrix (Linux + Windows x
|
||||
Node 20 / 22 / Bun) and the installer builds in parallel and stores the green result as the draft `rc-<sha>`.
|
||||
A version-only bump commit (proven file by file) reuses its parent's CI and matrix and rebuilds only the
|
||||
installers. A `v*` tag promotes the candidate (no rebuild, no retest) and fails at once on a red commit.
|
||||
- **`bun tools/release.ts <version> --yes`:** bump and CHANGELOG cut, fast gate, commit, push, tag; the Mac build and
|
||||
notarization in parallel with Forgejo; macOS acceptance cached per CLI key; add, verify, publish (each Mac file
|
||||
crosses the owner's uplink once); /Applications/storebound.app updated; the npm commands printed. `--rehearsal`
|
||||
runs it all as a throwaway prerelease and deletes it afterwards.
|
||||
|
||||
## 0.8.4 · 2026-10-14
|
||||
|
||||
|
|
|
|||
|
|
@ -31,55 +31,116 @@ in `~/Desktop/storebound-dist/desktop-0.1.0` was never published, so no installe
|
|||
`tauri.conf.json`, `Cargo.toml` and the app's entry in `Cargo.lock`. CI runs this check; `src-tauri/tests/d5.rs`
|
||||
checks the app's copies from the Rust side.
|
||||
- `bun tools/desktop-version.ts --set 0.8.0` writes a new version into package.json and all of them.
|
||||
- **Release order** (one commit, one version): see "The release on git.crt.fyi" below. The app goes first
|
||||
- **Release order** (one commit, one version): see "The release in one command" below. The app goes first
|
||||
(`publish` refuses a folder whose version is not package.json's), then `npm publish` of the same version
|
||||
(CONTRIBUTING.md), so `npx storebound desktop` of the new CLI finds its own version.
|
||||
|
||||
## The release on git.crt.fyi
|
||||
## The release in one command
|
||||
|
||||
One version tag, one Forgejo release: git.crt.fyi/CRT/storebound/releases keeps only the newest entry (older entries
|
||||
are deleted after a successful release; their git tags stay). Notes are short: the CHANGELOG section's summary
|
||||
paragraph (at most 4 lines), the install line and links, never the whole section. So the section's first paragraph
|
||||
is the release summary: write it for that.
|
||||
```sh
|
||||
bun tools/release.ts 0.8.5 # checks and the plan
|
||||
bun tools/release.ts 0.8.5 --yes # the release; then the owner's npm publish
|
||||
```
|
||||
|
||||
1. **Version and changelog** on main: `bun tools/desktop-version.ts --set 0.8.0`, a `## 0.8.0 · <date>` section
|
||||
that starts with a summary paragraph, `bun run ci`, push.
|
||||
2. **Tag push:** `git tag v0.8.0 && git push origin v0.8.0`. `.forgejo/workflows/release.yml` then:
|
||||
- checks that the tag is `v` + package.json's version, every version copy matches (tools/desktop-version.ts) and
|
||||
the CHANGELOG has the section;
|
||||
- runs the full CI (`bun run ci`, the same steps as ci.yml: `.forgejo/actions/linux-setup` and `ci-run`);
|
||||
- builds the AppImage + deb (Linux runner) and the NSIS installer (Windows runner), with the checks described
|
||||
under "Windows and Linux" below;
|
||||
- scans every artifact with the private terms (secret `FORBIDDEN_TERMS`), writes SHA256SUMS;
|
||||
- creates or updates the release "storebound 0.8.0" (`tools/forgejo-release.ts upsert`: found by tag, assets of
|
||||
the same name replaced, SHA256SUMS merged, read back), then deletes the older release entries
|
||||
(`forgejo-release.ts prune --keep v0.8.0`).
|
||||
3. **macOS on the Mac** (the DMG cannot be built or notarized on these runners): `bun tools/desktop-release.ts mac`,
|
||||
then `add` of the runners' Linux / Windows files (signs them for the updater, see below), then
|
||||
`bun tools/desktop-release.ts upload-forgejo --out ~/.storebound-releases/desktop/0.8.0 --yes`: the signed DMG and
|
||||
the updater bundle with their `.sig` join the same release, SHA256SUMS is merged. It refuses an unsigned DMG and
|
||||
a release the workflow has not made yet. Token: `FORGEJO_TOKEN`, else the git credential helper of git.crt.fyi.
|
||||
4. **storebound.dev:** `bun tools/desktop-release.ts publish --out ... --yes` (below).
|
||||
5. **npm:** `npm publish` of the same version: the owner's step (CONTRIBUTING.md).
|
||||
Option, not set up: an npm automation token as the Actions secret `NPM_TOKEN` and a manual `npm` job in
|
||||
release.yml (`needs: release`, workflow_dispatch only, `npm publish` after the Mac's upload) would let the owner
|
||||
publish with one click in Forgejo instead of a terminal. The token can publish any version of the package, so it
|
||||
stays off until the owner wants it.
|
||||
Target: tag push to everything published (the Forgejo release with every installer, storebound.dev's latest.json,
|
||||
the app in /Applications) in under 10 minutes, with every gate still in place. It works because nothing is built or
|
||||
tested twice: every push to main is already a release candidate.
|
||||
|
||||
**Dry run** (no version, nothing public): Actions > release > Run workflow with `dry` ticked (the default), or
|
||||
`POST /api/v1/repos/CRT/storebound/actions/workflows/release.yml/dispatches` with
|
||||
`{"ref":"main","inputs":{"dry":"on","windows":"on"}}` (over the API a boolean input is "on"; "true" counts as off).
|
||||
The same jobs run on that commit; the release job makes a DRAFT `test-<sha>` with the real files, reads it back,
|
||||
prints what `prune` would delete, and deletes the draft at the end (also when a step failed). `dry` off needs a `tag`
|
||||
input and remakes that tag's release.
|
||||
### Release candidates (every push to main)
|
||||
|
||||
`.forgejo/workflows/ci.yml` runs on every push to main, in parallel:
|
||||
|
||||
- `ci`: the full `bun run ci` (Rust tests and clippy included);
|
||||
- `acceptance`: the release acceptance matrix, Linux and Windows x Node 20 / Node 22 / Bun (docs/acceptance.md);
|
||||
- `linux`: the AppImage and the deb, self-tests, both started under Xvfb, privacy scan (the release target folder is
|
||||
cached, so only changed crates compile);
|
||||
- `windows`: the NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
|
||||
|
||||
When all are green, the `record` job scans the installers with the private terms (secret `FORBIDDEN_TERMS`), builds
|
||||
the 6-run acceptance matrix, and stores everything as the **draft release `rc-<full sha>`** on git.crt.fyi:
|
||||
installers, `SHA256SUMS`, the acceptance run files, `storebound_<version>_acceptance.md` and `green.json` (sha,
|
||||
version, CLI key, run link, every file's SHA-256). `green.json` is uploaded last: a candidate without it is not
|
||||
complete. The newest 12 candidates are kept (`tools/release-candidate.ts prune`); release pruning never touches them.
|
||||
`bun tools/release-candidate.ts status --sha <sha>` says green (with the record), red (with the failed run's link),
|
||||
pending or none.
|
||||
|
||||
**Version-only commits.** The release's bump commit changes only the version. ci.yml's first job, `gate`, proves it
|
||||
file by file: every changed file must be a version copy of `tools/desktop-version.ts` whose new text is exactly the
|
||||
parent's text with the new version written in by that same writer, or `CHANGELOG.md` / `docs/acceptance*`. Then the
|
||||
parent's full CI and acceptance matrix count for the bump commit (the parent's run is waited for if it is still
|
||||
running; a red parent fails the gate), and only the installers are built again, because their version is compiled in
|
||||
(Cargo, tauri.conf.json, the bundled CLI, the file names). That is simpler and safer than making the installers
|
||||
version-independent. The acceptance runs carry a **CLI key** (`tools/cli-key.ts`: a hash of everything the packed
|
||||
CLI is made of, with the version copies normalized), and `acceptance.ts report --strict` accepts a run of another
|
||||
version only when its key is the checkout's.
|
||||
|
||||
### The tag only promotes
|
||||
|
||||
`.forgejo/workflows/release.yml` on a `v*` tag checks the tag against package.json, every copy and the CHANGELOG
|
||||
section, then `release-candidate.ts promote`: it waits for the tagged commit's candidate (heartbeat; fails at once
|
||||
when the commit is red or has no ci run, with the run's link; 40 min timeout), checks its version, and makes the
|
||||
release "storebound <version>" from the candidate's files (no rebuild, no retest). Stable versions then delete the
|
||||
older release entries (tags stay). Versions with a "-" are prereleases: never pruning, never the stable release.
|
||||
Run it by hand from Actions > release with a `tag` (dry by default: only the plan).
|
||||
|
||||
### What `tools/release.ts` does on the Mac
|
||||
|
||||
1. **Preflight:** a clean checkout equal to origin/main whose candidate is green or still running, the updater key,
|
||||
the publish host (`STOREBOUND_PUBLISH_HOST` or `~/.storebound-keys/publish-host`). The macOS acceptance matrix
|
||||
(Bun, Node 20, Node 22 from fnm) starts in the background unless its results for this CLI key are cached in
|
||||
`~/.storebound-releases/acceptance-cache/<key>/` (a version bump keeps the key; `bun tools/release.ts
|
||||
mac-acceptance` fills the cache ahead of time).
|
||||
2. **Bump:** the version in package.json and every copy, the CHANGELOG's `## Unreleased` cut into
|
||||
`## <version> · <date>` (its first paragraph is the release summary).
|
||||
3. **Fast gate, commit, push:** `bun run ci:quick`, the commit as CRT, push to main, push the tag.
|
||||
4. **In parallel:** `desktop-release.ts mac` (universal build, sign; Tauri notarizes the app while the boot check runs;
|
||||
the DMG is notarized while the app and the updater bundle are checked, scanned and signed), Forgejo's ci run of the
|
||||
bump commit (installers only) and release.yml's promotion. Meanwhile the candidate's Linux / Windows installers
|
||||
and acceptance runs are downloaded.
|
||||
5. **add, matrix, verify:** updater signatures of the Linux / Windows files, the 9-run acceptance matrix (strict) to
|
||||
the release, `verify`.
|
||||
6. **publish** with `--forgejo-tag`: the VDS fetches the Linux / Windows files from the Forgejo release over its own
|
||||
network, rsync sends only the Mac's DMG and updater bundle, the VDS attaches those to the Forgejo release from its
|
||||
copy, then the publish script swaps latest.json. The owner's uplink (about 0.5 to 1 MB/s) carries each Mac file
|
||||
once instead of three times.
|
||||
7. **/Applications/storebound.app:** the running app is asked to quit (an AppleEvent), then its exact PID gets
|
||||
SIGTERM / SIGKILL if needed; the new app is copied from the DMG, verified (`codesign --verify --deep --strict`)
|
||||
and reopened in the background (`open -g`).
|
||||
8. **Prints** `npm publish` and `npm deprecate storebound@"<version>" ...` (the owner's step) and the time of every
|
||||
phase (also in `~/.storebound-releases/timings/`). Logs: `~/.storebound-releases/logs/<version>/`.
|
||||
|
||||
Every wait has a timeout and a heartbeat; child processes are killed by PID when it exits.
|
||||
|
||||
**Notarization in parallel, where possible.** Apple notarizes the app (inside `tauri bundle`) and then the DMG. The
|
||||
DMG cannot go first or at the same time: it must contain the app with its stapled ticket (the gate checks
|
||||
`stapler validate` on the app inside the mounted DMG). So each wait for Apple runs next to other work instead: the
|
||||
boot check during the app's notarization; every app and updater-bundle check, their privacy scan and the updater
|
||||
signature during the DMG's.
|
||||
|
||||
**Rehearsal:** `bun tools/release.ts --rehearsal [<n>] --yes` runs all of it for version `0.0.0-rehearsal.<n>` on the
|
||||
branch `rehearsal/<n>` (ci.yml runs there too): a prerelease on git.crt.fyi, the `rehearsal` channel on
|
||||
storebound.dev (`/desktop/rehearsal/latest.json`, which no app reads; the publish script refuses rehearsal versions
|
||||
on stable / beta and other versions on rehearsal), the app copied to a scratch folder (nothing quit or opened), never
|
||||
npm. At the end it deletes the release and tag, the candidate, the branch, the version's files on storebound.dev and
|
||||
the local folders, and prints the times.
|
||||
|
||||
### By hand (what release.ts runs)
|
||||
|
||||
- `bun tools/desktop-version.ts --set 0.8.5` and the CHANGELOG section, `bun run ci:quick`, commit, push, tag, push
|
||||
the tag (release.yml promotes the candidate).
|
||||
- `bun tools/desktop-release.ts mac --out ~/.storebound-releases/desktop/0.8.5`.
|
||||
- `bun tools/release-candidate.ts fetch --sha <sha> --dir <dir>`, then `bun tools/desktop-release.ts add --out ...
|
||||
--commit <sha> --run <run> <AppImage> <deb> <setup.exe>`.
|
||||
- `bun tools/desktop-release.ts publish --out ... --forgejo-tag v0.8.5 --yes` (or `upload-forgejo`, then `publish`).
|
||||
- `npm publish` of the same version: the owner's step (CONTRIBUTING.md).
|
||||
|
||||
**Secrets** (repo Settings > Actions > Secrets): `RELEASE_TOKEN`, a token of CRT with scope `write:repository`
|
||||
(name `storebound-release-workflow`; releases, assets, the dry run's tag) and `FORBIDDEN_TERMS`, the contents of
|
||||
`forbidden-terms.txt` (never printed: hits show `x***`), and `FORGEJO_ORIGIN`, the server's IP: git.crt.fyi is
|
||||
behind Cloudflare, which refuses uploads over 100 MB (the AppImage, the DMG) with a 502, so `forgejo-release.ts`
|
||||
uploads assets to the origin directly (`curl --resolve`, TLS still checked for git.crt.fyi). On the Mac the same IP
|
||||
is in `~/.storebound-keys/forgejo-origin`. Rotate the token: create a new one under CRT's
|
||||
Settings > Applications with that scope, put it into the secret, delete the old one.
|
||||
(name `storebound-release-workflow`; releases, assets, candidates: ci.yml's gate and record jobs and release.yml use
|
||||
it), `FORBIDDEN_TERMS`, the contents of `forbidden-terms.txt` (never printed: hits show `x***`), and
|
||||
`FORGEJO_ORIGIN`, the server's IP: git.crt.fyi is behind Cloudflare, which refuses uploads over 100 MB (the AppImage,
|
||||
the DMG) with a 502, so `forgejo-release.ts` uploads assets to the origin directly (`curl --resolve`, TLS still
|
||||
checked for git.crt.fyi). On the Mac the same IP is in `~/.storebound-keys/forgejo-origin`. Rotate the token: create
|
||||
a new one under CRT's Settings > Applications with that scope, put it into the secret, delete the old one.
|
||||
|
||||
## Keys and private files (outside every repository)
|
||||
|
||||
|
|
@ -197,8 +258,7 @@ and choose Open Anyway (once).
|
|||
|
||||
## Windows and Linux (Forgejo Actions)
|
||||
|
||||
The `linux` and `windows` jobs of `.forgejo/workflows/release.yml` (a `v*` tag push, or a dry run by hand; untick
|
||||
"windows" in a dry run to build Linux only).
|
||||
The `linux` and `windows` jobs of `.forgejo/workflows/ci.yml`, on every push to main (the release candidate).
|
||||
|
||||
- **Linux** (`runs-on: ubuntu-22.04`, a container): AppImage and .deb built with the path-free environment, then:
|
||||
- self-tests of the extracted AppImage and the unpacked .deb;
|
||||
|
|
@ -233,9 +293,9 @@ installer compare with `Get-FileHash` of `C:\storebound-target\release\bundle\ns
|
|||
|
||||
### Runner setup
|
||||
|
||||
Forgejo runs in Docker on the VDS. `.forgejo/workflows/ci.yml` (`bun run ci`, Rust tests and clippy included) runs on
|
||||
every push and pull request to main; `release.yml` as above makes the Forgejo release (Jenkins still publishes
|
||||
site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public repository can be downloaded by anyone for 14 days.
|
||||
Forgejo runs in Docker on the VDS. `.forgejo/workflows/ci.yml` (the release candidate: `bun run ci`, the acceptance
|
||||
matrix, the installers) runs on every push to main and `ci` + `acceptance` on pull requests; `release.yml` promotes a
|
||||
candidate on a tag (Jenkins still publishes site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public repository can be downloaded by anyone for 14 days.
|
||||
|
||||
1. **Actions in Forgejo (done).** `FORGEJO__actions__ENABLED=true` and
|
||||
`FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org` in the compose file; the Actions unit is on for
|
||||
|
|
@ -292,7 +352,6 @@ site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public
|
|||
- **Shared runner:** any workflow of any CRT repository with `runs-on: windows` runs here, as `runner`, with
|
||||
internet access. Keep workflows of fork pull requests off it (as ci.yml does for Linux).
|
||||
|
||||
The workflow's Windows job runs only when "windows" is ticked (from the API, `"inputs": {"windows": "on"}`).
|
||||
4. **Windows code signing (later).** Buy a code-signing certificate, then sign the installer before `add` and pass
|
||||
`--code-signed`.
|
||||
|
||||
|
|
@ -301,7 +360,7 @@ site/, and `publish` storebound.dev/desktop). Workflow artifacts of this public
|
|||
| URL | served from | how it gets there |
|
||||
|---|---|---|
|
||||
| /#download (the downloads, a section of the home page; /desktop/ redirects there) | /var/www/storebound.dev/index.html, /var/www/storebound.dev/desktop/index.html | `site/index.html`, `site/desktop/index.html`, with the site (Jenkins + /usr/local/sbin/storebound-publish) |
|
||||
| /desktop/latest.json, /desktop/beta/latest.json | /var/www/storebound-desktop/ | storebound-desktop-publish |
|
||||
| /desktop/latest.json, /desktop/beta/latest.json, /desktop/rehearsal/latest.json | /var/www/storebound-desktop/ | storebound-desktop-publish |
|
||||
| /desktop/dl/<version>/<files> | /var/www/storebound-desktop/dl/ | storebound-desktop-publish |
|
||||
|
||||
**Why the binaries live outside the site's document root.** The site's publish script rsyncs `site/` with
|
||||
|
|
@ -313,7 +372,7 @@ Apache maps the three paths with `Include /etc/apache2/storebound-desktop.conf`
|
|||
- version folders are sent `immutable`;
|
||||
- no directory listings.
|
||||
|
||||
**`/usr/local/sbin/storebound-desktop-publish <version> [stable|beta]`** (`tools/vds/storebound-desktop-publish`,
|
||||
**`/usr/local/sbin/storebound-desktop-publish <version> [stable|beta|rehearsal]`** (`tools/vds/storebound-desktop-publish`,
|
||||
root:root 755) takes `/var/lib/storebound-desktop/incoming/<version>/` (root only, mode 700) and checks it:
|
||||
|
||||
- only plain files with plain names;
|
||||
|
|
@ -358,7 +417,9 @@ bun tools/desktop-release.ts publish --out ~/.storebound-releases/desktop/0.1.0
|
|||
3. **Publishes:** runs the publish script.
|
||||
4. **Confirms:** reads the live latest.json back.
|
||||
|
||||
`--channel beta` publishes to /desktop/beta/latest.json, which apps on the Beta channel read.
|
||||
`--channel beta` publishes to /desktop/beta/latest.json, which apps on the Beta channel read. `--channel rehearsal`
|
||||
(release rehearsals only) to /desktop/rehearsal/latest.json, which no app reads. `--forgejo-tag v<version>`: the
|
||||
server fetches the files the Forgejo release already has and attaches the Mac's files to it (see above).
|
||||
|
||||
**Rollback.** Tauri updates only to a higher version, so a bad release is replaced by a fixed higher version. Its
|
||||
files stay where they are, because links and checksums must not change.
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ describe('architecture guard (no Rosetta notice for users)', () => {
|
|||
});
|
||||
test('the release runs the guard before signing (tauri build --no-sign, then tauri bundle)', () => {
|
||||
const src = readFileSync(join(import.meta.dir, '..', 'tools/desktop-release.ts'), 'utf8');
|
||||
const build = src.indexOf("'--no-sign'"), guard = src.indexOf("archGuard(join(targetDir"), bundle = src.indexOf("['tauri', 'bundle'");
|
||||
const build = src.indexOf("'--no-sign'"), guard = src.indexOf("archGuard(join(targetDir"), bundle = src.indexOf("'tauri', 'bundle'");
|
||||
expect(build).toBeGreaterThan(0);
|
||||
expect(guard).toBeGreaterThan(build);
|
||||
expect(bundle).toBeGreaterThan(guard);
|
||||
|
|
|
|||
113
tests/release-pipeline.test.ts
Normal file
113
tests/release-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { cliKeyOf, keyContent, keyPaths } from '../tools/cli-key';
|
||||
import { staleReason } from '../tools/acceptance';
|
||||
import { channelUrl, seedPlan } from '../tools/desktop-release';
|
||||
import { COPIES } from '../tools/desktop-version';
|
||||
import { candidateTag, isCandidateTag, isTestTag, pruneList, type Release } from '../tools/forgejo-release';
|
||||
import { isPrerelease, tagVersion, taskState, type Task, versionOnlyProblems } from '../tools/release-candidate';
|
||||
import { cutChangelog, newer, timingTable } from '../tools/release';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const read = (f: string) => readFileSync(join(ROOT, f), 'utf8');
|
||||
const SHA = 'a'.repeat(40);
|
||||
const bump = (to: string) => COPIES.map(c => ({ file: c.file, status: 'M', before: read(c.file), after: c.write(read(c.file), to) }));
|
||||
|
||||
describe('version-only commits (ci.yml gate)', () => {
|
||||
const from = COPIES[0]!.read(read('package.json'))!;
|
||||
test('a bump of every copy plus the changelog is version-only', () => {
|
||||
const r = versionOnlyProblems([...bump('9.9.9'), { file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }, { file: 'docs/acceptance/macOS-bun.json', status: 'M', before: '{}', after: '[]' }]);
|
||||
expect(r).toEqual({ from, to: '9.9.9', problems: [] });
|
||||
});
|
||||
test('any other file, or a copy with one more change, is not', () => {
|
||||
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'cli/index.ts', status: 'M', before: 'a', after: 'b' }]).problems).toEqual(['cli/index.ts: not a version copy (M)']);
|
||||
const sneaky = bump('9.9.9').map(c => (c.file === 'package.json' ? { ...c, after: c.after.replace('"type": "module"', '"type": "commonjs"') } : c));
|
||||
expect(versionOnlyProblems(sneaky).problems).toEqual(['package.json: changes more than the version']);
|
||||
const dep = bump('9.9.9').map(c => (c.file === 'apps/desktop/src-tauri/Cargo.lock' ? { ...c, after: `${c.after}\n# extra` } : c));
|
||||
expect(versionOnlyProblems(dep).problems).toEqual(['apps/desktop/src-tauri/Cargo.lock: changes more than the version']);
|
||||
expect(versionOnlyProblems([{ file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }]).problems[0]).toMatch(/version does not change/);
|
||||
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'docs/acceptance/x.json', status: 'A', before: null, after: '{}' }]).problems).toEqual([]);
|
||||
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'gemini-extension.json', status: 'D', before: '{}', after: null }]).problems.some(p => /gemini-extension\.json: D/.test(p))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('candidate state from the ci.yml jobs', () => {
|
||||
const t = (name: string, status: string, run = 5, sha = SHA, wf = 'ci.yml'): Task => ({ id: 1, name, run_number: run, workflow_id: wf, head_sha: sha, status });
|
||||
const url = (n: number) => `run/${n}`;
|
||||
test('none, pending, red; the newest run counts', () => {
|
||||
expect(taskState([t('ci', 'success', 5, 'b'.repeat(40))], SHA, url)).toEqual({ state: 'none' });
|
||||
expect(taskState([t('ci', 'success'), t('windows', 'running')], SHA, url)).toEqual({ state: 'pending', run: 'run/5', running: ['windows (running)'] });
|
||||
expect(taskState([t('ci', 'success'), t('acceptance (bun)', 'failure')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['acceptance (bun)'] });
|
||||
expect(taskState([t('ci', 'failure', 4), t('ci', 'running', 6)], SHA, url).state).toBe('pending');
|
||||
expect(taskState([t('promote', 'failure', 9, SHA, 'release.yml')], SHA, url)).toEqual({ state: 'none' });
|
||||
});
|
||||
test('tags and prereleases', () => {
|
||||
expect(tagVersion('v0.8.5')).toBe('0.8.5');
|
||||
expect(tagVersion('v0.0.0-rehearsal.3')).toBe('0.0.0-rehearsal.3');
|
||||
expect(tagVersion('0.8.5')).toBeNull();
|
||||
expect(isPrerelease('0.0.0-rehearsal.3')).toBe(true);
|
||||
expect(isPrerelease('0.8.5')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgejo: candidates and rehearsals', () => {
|
||||
const rel = (id: number, tag: string, draft = false): Release => ({ id, tag_name: tag, name: tag, draft, prerelease: false, body: '', assets: [] });
|
||||
test('prune keeps candidate drafts; rehearsal tags are deletable test tags', () => {
|
||||
const rc = candidateTag(SHA);
|
||||
expect(isCandidateTag(rc)).toBe(true);
|
||||
expect(pruneList([rel(1, 'v0.8.4'), rel(2, 'v0.8.5'), rel(3, rc, true)], 'v0.8.5').remove.map(r => r.tag_name)).toEqual(['v0.8.4']);
|
||||
expect(isTestTag('v0.0.0-rehearsal.12')).toBe(true);
|
||||
expect(isTestTag('v0.8.5')).toBe(false);
|
||||
expect(isTestTag('v0.0.1-rehearsal.1')).toBe(false);
|
||||
});
|
||||
test('seed plan: files the server fetches from the release by hash (renamed installer too)', () => {
|
||||
const h = (c: string) => c.repeat(64);
|
||||
const sums = `${h('a')} storebound_1.0.0_amd64.AppImage\n${h('b')} storebound_1.0.0_x64-setup.exe\n`;
|
||||
const assets = [{ name: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a' }, { name: 'storebound_1.0.0_x64-setup.exe', url: 'https://x/b' }];
|
||||
expect(seedPlan([{ file: 'storebound_1.0.0_amd64.AppImage', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', sha256: h('b') }, { file: 'storebound_1.0.0_universal.dmg', sha256: h('c') }], sums, assets))
|
||||
.toEqual([{ file: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', url: 'https://x/b', sha256: h('b') }]);
|
||||
expect(channelUrl('stable')).toBe('https://storebound.dev/desktop/latest.json');
|
||||
expect(channelUrl('rehearsal')).toBe('https://storebound.dev/desktop/rehearsal/latest.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI key and acceptance runs of a version-only parent', () => {
|
||||
test('the key ignores the version copies and the prose, not the code', () => {
|
||||
const files: Record<string, string> = { 'package.json': read('package.json'), 'cli/a.ts': 'x', 'CHANGELOG.md': 'one' };
|
||||
const key = (f: Record<string, string>) => cliKeyOf(Object.keys(f), n => Buffer.from(f[n]!));
|
||||
const base = key(files);
|
||||
expect(key({ ...files, 'package.json': COPIES[0]!.write(files['package.json']!, '9.9.9'), 'CHANGELOG.md': 'two' })).toBe(base);
|
||||
expect(key({ ...files, 'cli/a.ts': 'y' })).not.toBe(base);
|
||||
expect(keyContent('cli/a.ts', Buffer.from('0.8.4')).toString()).toBe('0.8.4');
|
||||
expect(keyPaths(['dist/storebound.mjs', 'cli'])).toContain('tests/fixtures');
|
||||
expect(keyPaths(['dist/storebound.mjs', 'cli'])).not.toContain('dist/storebound.mjs');
|
||||
});
|
||||
test('a run of another version counts only with the same CLI key', () => {
|
||||
expect(staleReason({ version: '0.8.4', quick: false }, '0.8.4', 'k')).toBeNull();
|
||||
expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'k' }, '0.8.5', 'k')).toBeNull();
|
||||
expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'j' }, '0.8.5', 'k')).toMatch(/other CLI code/);
|
||||
expect(staleReason({ version: '0.8.4', quick: false }, '0.8.5', 'k')).toMatch(/version 0.8.4/);
|
||||
expect(staleReason({ version: '0.8.5', quick: true, cliKey: 'k' }, '0.8.5', 'k')).toBe('a quick run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('release.ts', () => {
|
||||
test('changelog cut', () => {
|
||||
const cl = '# Changelog\n\n## Unreleased\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n';
|
||||
expect(cutChangelog(cl, '0.8.5', '2026-10-15')).toBe('# Changelog\n\n## Unreleased\n\n## 0.8.5 · 2026-10-15\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n');
|
||||
expect(() => cutChangelog('# Changelog\n\n## Unreleased\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/empty/);
|
||||
expect(() => cutChangelog('# Changelog\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/Unreleased/);
|
||||
expect(() => cutChangelog(cl, '0.8.4', 'd')).toThrow(/already/);
|
||||
});
|
||||
test('versions and the timing table', () => {
|
||||
expect(newer('0.8.5', '0.8.4')).toBe(true);
|
||||
expect(newer('0.10.0', '0.9.9')).toBe(true);
|
||||
expect(newer('0.8.4', '0.8.4')).toBe(false);
|
||||
expect(newer('0.8.5', '0.8.5-rc.1')).toBe(true);
|
||||
expect(newer('0.8.5-rc.1', '0.8.5')).toBe(false);
|
||||
const tbl = timingTable([{ name: 'a', start: 1000, end: 61_000 }, { name: 'b', start: 2000, end: 302_000 }], 1000);
|
||||
expect(tbl).toContain('took 1:00');
|
||||
expect(tbl).toMatch(/total .* 5:01/);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,7 @@ import { FakeAsc, FakePlay } from '../tests/helpers/fake-stores';
|
|||
import { makeAab } from '../tests/build-fixtures';
|
||||
import { ALPHA3 } from '../cli/lib/money';
|
||||
import { CURRENCY } from '../cli/lib/ppp';
|
||||
import { cliKey } from './cli-key';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const PKG = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { version: string };
|
||||
|
|
@ -492,7 +493,7 @@ async function run(argv: string[]): Promise<void> {
|
|||
const failed = results.filter(r => !r.ok);
|
||||
const out = resolve(arg('--out') ?? join(ROOT, `acceptance-${platform}-${rt.name.replace(/\s+/g, '')}.json`));
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, JSON.stringify({ platform, runtime: rt.name, label: rt.label, version: PKG.version, quick, at: new Date().toISOString(), results }, null, 2));
|
||||
writeFileSync(out, JSON.stringify({ platform, runtime: rt.name, label: rt.label, version: PKG.version, cliKey: cliKey(ROOT), quick, at: new Date().toISOString(), results }, null, 2));
|
||||
console.log(`acceptance: ${results.length - failed.length}/${results.length} cells passed in ${Math.round((Date.now() - t0) / 1000)} s -> ${out}`);
|
||||
if (failed.length) {
|
||||
console.log(`acceptance: failed cells keep their folder and output.txt under ${work}`);
|
||||
|
|
@ -504,7 +505,17 @@ async function run(argv: string[]): Promise<void> {
|
|||
|
||||
// ------------------------------------------------------------------------------------------------- report
|
||||
|
||||
interface RunFile { platform: string; runtime: string; label: string; version: string; quick: boolean; at: string; results: Result[] }
|
||||
interface RunFile { platform: string; runtime: string; label: string; version: string; cliKey?: string; quick: boolean; at: string; results: Result[] }
|
||||
|
||||
/** Why a run does not count for this checkout (pure): a quick run, or another version whose CLI code differs. A run
|
||||
* of another version counts when its CLI key (tools/cli-key.ts) is this checkout's: the commits differ only in
|
||||
* version copies and prose (a release's version bump), so the CLI under test is the same code. */
|
||||
export function staleReason(r: Pick<RunFile, 'version' | 'quick' | 'cliKey'>, version: string, key: string): string | null {
|
||||
if (r.quick) return 'a quick run';
|
||||
if (r.version === version) return null;
|
||||
if (r.cliKey && r.cliKey === key) return null;
|
||||
return `version ${r.version}, not ${version}${r.cliKey ? ' (other CLI code)' : ''}`;
|
||||
}
|
||||
|
||||
export function renderReport(runs: RunFile[]): string {
|
||||
const cols = runs.map(r => `${r.platform} · ${r.runtime}`);
|
||||
|
|
@ -548,9 +559,11 @@ function report(argv: string[]): void {
|
|||
runs.sort((a, b) => rank(a) - rank(b));
|
||||
writeFileSync(md, renderReport(runs));
|
||||
const bad = runs.flatMap(r => r.results.filter(x => !x.ok));
|
||||
const stale = runs.filter(r => r.version !== PKG.version || r.quick);
|
||||
const key = cliKey(ROOT);
|
||||
const stale = runs.map(r => ({ r, why: staleReason(r, PKG.version, key) })).filter(x => x.why);
|
||||
console.log(`acceptance report: ${runs.length} runs, ${bad.length} red cells -> ${md}`);
|
||||
for (const r of stale) console.log(` ${r.platform} · ${r.runtime}: ${r.quick ? 'a quick run' : `version ${r.version}, not ${PKG.version}`}`);
|
||||
for (const { r, why } of stale) console.log(` ${r.platform} · ${r.runtime}: ${why}`);
|
||||
for (const r of runs) if (r.version !== PKG.version && !staleReason(r, PKG.version, key)) console.log(` ${r.platform} · ${r.runtime}: run on ${r.version}, same CLI code (key ${key.slice(0, 12)}): counts for ${PKG.version}`);
|
||||
const missing = expect && runs.length < expect ? `${expect - runs.length} of ${expect} runs missing` : '';
|
||||
if (missing) console.log(` ${missing}`);
|
||||
if (argv.includes('--strict') && (bad.length || stale.length || missing)) process.exit(1);
|
||||
|
|
|
|||
48
tools/cli-key.ts
Normal file
48
tools/cli-key.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bun
|
||||
// The CLI key: one hash of everything that decides how the packed npm CLI behaves in the acceptance matrix
|
||||
// (tools/acceptance.ts), with the version copies normalized. Two commits with the same key run the same CLI code; a
|
||||
// version bump (tools/desktop-version.ts --set) does not change it. Acceptance results carry the key, so the results
|
||||
// of a commit count for a version-only child (tools/release-candidate.ts) and the Mac reuses its cached macOS results
|
||||
// (tools/release.ts) until the CLI code really changes.
|
||||
// bun tools/cli-key.ts print the key of the working tree (tracked files only)
|
||||
// In the key: the npm package's files (package.json "files") without the prose that ships with it (CHANGELOG.md,
|
||||
// README.md, docs/*.md: the CLI never reads storebound's own docs), package.json, bun.lock and what the acceptance
|
||||
// run itself uses (tools/acceptance.ts, tools/build-node.ts, tests/helpers, tests/build-fixtures.ts, tests/fixtures).
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { COPIES } from './desktop-version';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const PROSE = /^(CHANGELOG\.md|README\.md|docs\/[^/]+\.md)$/;
|
||||
const EXTRA = ['package.json', 'bun.lock', 'tools/acceptance.ts', 'tools/build-node.ts', 'tools/cli-key.ts', 'tools/desktop-version.ts', 'tests/helpers', 'tests/build-fixtures.ts', 'tests/fixtures'];
|
||||
const NORMAL = '0.0.0-cli-key';
|
||||
|
||||
/** The paths of the key (git pathspecs). Pure. */
|
||||
export function keyPaths(pkgFiles: string[]): string[] {
|
||||
return [...new Set([...pkgFiles.filter(f => f !== 'dist/storebound.mjs'), ...EXTRA])].sort();
|
||||
}
|
||||
|
||||
/** One file's content in the key: the version copies with the version normalized. Pure. */
|
||||
export function keyContent(file: string, text: Buffer): Buffer {
|
||||
const copy = COPIES.find(c => c.file === file);
|
||||
return copy ? Buffer.from(copy.write(text.toString('utf8'), NORMAL)) : text;
|
||||
}
|
||||
|
||||
/** sha256 over (path, content) of every tracked file of the key, sorted. Pure given the reader. */
|
||||
export function cliKeyOf(files: string[], read: (f: string) => Buffer): string {
|
||||
const h = createHash('sha256');
|
||||
for (const f of [...files].filter(f => !PROSE.test(f)).sort()) { h.update(`${f}\0`); h.update(keyContent(f, read(f))); h.update('\0'); }
|
||||
return h.digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
export function cliKey(root = ROOT): string {
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { files: string[] };
|
||||
const ls = spawnSync('git', ['ls-files', '-z', '--', ...keyPaths(pkg.files)], { cwd: root, encoding: 'utf8', maxBuffer: 64 << 20 });
|
||||
if (ls.status !== 0) throw new Error(`git ls-files: ${ls.stderr}`);
|
||||
const files = ls.stdout.split('\0').filter(f => f && existsSync(join(root, f)));
|
||||
return cliKeyOf(files, f => readFileSync(join(root, f)));
|
||||
}
|
||||
|
||||
if (import.meta.main) console.log(cliKey());
|
||||
|
|
@ -16,9 +16,12 @@
|
|||
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and
|
||||
// evidence-<linux|windows>-<version>.json (the run and commit they came from; a commit other than the one of the
|
||||
// folder's other evidence is refused: every artifact of a version comes from one commit).
|
||||
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta] [--yes]
|
||||
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta|rehearsal]
|
||||
// [--forgejo-tag v<version>] [--yes]
|
||||
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
|
||||
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run).
|
||||
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run). --forgejo-tag: the
|
||||
// server fetches the files the Forgejo release already has, and attaches the Mac's DMG and updater bundle to it
|
||||
// from its copy (one upload over the owner's line instead of three; tools/release.ts uses this).
|
||||
// The folder's version must be package.json's: the npm CLI and the app are released together, the app first,
|
||||
// then `npm publish` of the same version.
|
||||
// bun tools/desktop-release.ts upload-forgejo --out <dir> [--tag v<version>] [--yes]
|
||||
|
|
@ -40,7 +43,7 @@ import { type Download, type DownloadKey, type Manifest, manifestProblems } from
|
|||
import { verify as verifySig } from '../cli/lib/minisign';
|
||||
import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
|
||||
import { versions } from './desktop-version';
|
||||
import { upsert as forgejoUpsert } from './forgejo-release';
|
||||
import { listReleases, upsert as forgejoUpsert } from './forgejo-release';
|
||||
import { isMacArtifact, scanSignedMac } from './mac-signature-scan';
|
||||
import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan';
|
||||
|
||||
|
|
@ -165,6 +168,19 @@ function run(cmd: string[], opts: SpawnSyncOptions & { quiet?: boolean; allowFai
|
|||
return res;
|
||||
}
|
||||
|
||||
/** A command in the background (notarytool waiting for Apple, the boot check) while this process checks other
|
||||
* artifacts. Output is collected; the caller decides what a failure means. */
|
||||
function runAsync(cmd: string[], opts: { cwd?: string; env?: Record<string, string | undefined>; timeoutMs?: number; inherit?: boolean } = {}): Promise<{ code: number; out: string; err: string; ms: number }> {
|
||||
const start = Date.now();
|
||||
const p = Bun.spawn(cmd, { cwd: opts.cwd, env: (opts.env ?? process.env) as Record<string, string>, stdin: 'ignore', stdout: opts.inherit ? 'inherit' : 'pipe', stderr: opts.inherit ? 'inherit' : 'pipe' });
|
||||
const timer = opts.timeoutMs ? setTimeout(() => p.kill(), opts.timeoutMs) : null;
|
||||
const text = (s: ReadableStream<Uint8Array> | number | undefined | null) => (s && typeof s !== 'number' ? new Response(s).text() : Promise.resolve(''));
|
||||
return Promise.all([text(p.stdout as ReadableStream<Uint8Array>), text(p.stderr as ReadableStream<Uint8Array>), p.exited]).then(([out, err, code]) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
return { code, out, err, ms: Date.now() - start };
|
||||
});
|
||||
}
|
||||
|
||||
const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const mb = (n: number) => `${(n / 1e6).toFixed(1)} MB`;
|
||||
const confPubkey = (): string => JSON.parse(readFileSync(join(TAURI_DIR, 'tauri.conf.json'), 'utf8')).plugins.updater.pubkey;
|
||||
|
|
@ -442,27 +458,25 @@ async function mac(argv: string[]): Promise<void> {
|
|||
{ cwd: APP_DIR, env: unsignedEnv as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`);
|
||||
const preSignGuard = archGuard(join(targetDir, 'universal-apple-darwin/release/bundle/macos', APP_NAME), 'unsigned app (before signing)');
|
||||
say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'})`);
|
||||
const tbs = spawnSync('bunx', ['tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile],
|
||||
{ cwd: APP_DIR, env: env as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
if (tbs.status !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.status})`);
|
||||
// the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
|
||||
// in parallel: tauri bundle signs, notarizes (Apple's queue: minutes) and staples the app, while the boot check
|
||||
// runs the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
|
||||
// Object.prototype with old saved states (tools/desktop-boot-check.ts); a blank window never ships again
|
||||
say('boot check of the built window (empty, D1 with folders gone, D3 saved state; missing bundle panel)');
|
||||
const boot = spawnSync('bun', [join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, encoding: 'utf8', timeout: 600_000 });
|
||||
process.stdout.write(boot.stdout ?? '');
|
||||
if (boot.status !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${(boot.stderr ?? '').trim().split('\n').slice(-3).join(' / ')}`);
|
||||
const bootCheck = (boot.stdout ?? '').trim().split('\n').at(-1) ?? 'ok';
|
||||
say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'}), boot check of the built window at the same time`);
|
||||
const bootP = runAsync(['bun', join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, timeoutMs: 600_000 });
|
||||
const tbs = await runAsync(['bunx', 'tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile], { cwd: APP_DIR, env, inherit: true, timeoutMs: 3_600_000 });
|
||||
say(`tauri bundle done in ${(tbs.ms / 1000).toFixed(0)} s`);
|
||||
const boot = await bootP;
|
||||
process.stdout.write(boot.out);
|
||||
if (tbs.code !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.code})`);
|
||||
if (boot.code !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${boot.err.trim().split('\n').slice(-3).join(' / ')}`);
|
||||
say(`boot check passed in ${(boot.ms / 1000).toFixed(0)} s`);
|
||||
const bootCheck = boot.out.trim().split('\n').at(-1) ?? 'ok';
|
||||
const bundleDir = join(targetDir, 'universal-apple-darwin/release/bundle/macos');
|
||||
const app = join(bundleDir, APP_NAME);
|
||||
if (!existsSync(app)) fail(`no ${APP_NAME} in ${bundleDir}`);
|
||||
const dsyms = readdirSync(bundleDir).filter(n => n.endsWith('.dSYM'));
|
||||
if (dsyms.length) fail(`debug symbols next to the app: ${dsyms.join(', ')}`);
|
||||
|
||||
const arch: Record<string, ArchGuard> = { preSign: preSignGuard, app: archGuard(app, 'signed app') };
|
||||
const checks: Record<string, Gate> = { app: gatekeeper(app, signed, notarized, 'built app') };
|
||||
const tests: Record<string, SelfTest[]> = { app: selfTest(app, 'built app') };
|
||||
|
||||
// updater bundle: the app as Tauri's updater expects it (tar.gz, no macOS metadata, root-owned entries)
|
||||
const updaterName = artifactName(version, 'updater', signed);
|
||||
const updater = join(out, updaterName);
|
||||
|
|
@ -479,18 +493,38 @@ async function mac(argv: string[]): Promise<void> {
|
|||
say(`DMG ${dmgName}`);
|
||||
run(['hdiutil', 'create', '-volname', PRODUCT, '-srcfolder', stage, '-fs', 'HFS+', '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-ov', dmg]);
|
||||
rmSync(stage, { recursive: true, force: true });
|
||||
// the DMG goes to Apple now; while Apple checks it, this process checks the app and the updater bundle (they
|
||||
// carry the app's own stapled ticket and do not depend on the DMG's)
|
||||
let dmgNotary: Promise<{ code: number; out: string; err: string; ms: number }> | null = null;
|
||||
if (signed) {
|
||||
run(['codesign', '--force', '--sign', identity!, '--timestamp', dmg]);
|
||||
if (notarized) {
|
||||
say('notarytool submit (DMG), waiting for Apple');
|
||||
const n = run(['xcrun', 'notarytool', 'submit', dmg, '--key', notary!.APPLE_API_KEY_PATH!, '--key-id', notary!.APPLE_API_KEY!, '--issuer', notary!.APPLE_API_ISSUER!, '--wait', '--output-format', 'json'], { allowFail: true, timeout: 3_600_000 });
|
||||
const status = (() => { try { return (JSON.parse(n.out) as { status?: string }).status; } catch { return undefined; } })();
|
||||
if (status !== 'Accepted') fail(`notarization of the DMG: ${status ?? (n.err.trim() || n.out.trim() || `notarytool exited ${n.code} without an answer (killed or timed out): run again; xcrun notarytool history shows the submission`)}`);
|
||||
run(['xcrun', 'stapler', 'staple', dmg]);
|
||||
run(['xcrun', 'stapler', 'validate', dmg]);
|
||||
say('notarytool submit (DMG): checking the app and the updater bundle while Apple checks the DMG');
|
||||
dmgNotary = runAsync(['xcrun', 'notarytool', 'submit', dmg, '--key', notary!.APPLE_API_KEY_PATH!, '--key-id', notary!.APPLE_API_KEY!, '--issuer', notary!.APPLE_API_ISSUER!, '--wait', '--output-format', 'json'], { timeoutMs: 3_600_000 });
|
||||
}
|
||||
}
|
||||
|
||||
const arch: Record<string, ArchGuard> = { preSign: preSignGuard, app: archGuard(app, 'signed app') };
|
||||
const checks: Record<string, Gate> = { app: gatekeeper(app, signed, notarized, 'built app') };
|
||||
const tests: Record<string, SelfTest[]> = { app: selfTest(app, 'built app') };
|
||||
const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-'));
|
||||
run(['tar', '-xzf', updater, '-C', unpacked]);
|
||||
arch.updaterApp = archGuard(join(unpacked, APP_NAME), 'app in the updater bundle');
|
||||
checks.updaterApp = gatekeeper(join(unpacked, APP_NAME), signed, notarized, 'app in the updater bundle');
|
||||
tests.updater = selfTest(join(unpacked, APP_NAME), 'updater bundle');
|
||||
rmSync(unpacked, { recursive: true, force: true });
|
||||
const privacyApp = privacyGate([app, updater]);
|
||||
const updaterSig = signFile(updater);
|
||||
|
||||
if (dmgNotary) {
|
||||
const n = await dmgNotary;
|
||||
const status = (() => { try { return (JSON.parse(n.out) as { status?: string }).status; } catch { return undefined; } })();
|
||||
if (status !== 'Accepted') fail(`notarization of the DMG: ${status ?? (n.err.trim() || n.out.trim() || `notarytool exited ${n.code} without an answer (killed or timed out): run again; xcrun notarytool history shows the submission`)}`);
|
||||
say(`DMG notarized in ${(n.ms / 1000).toFixed(0)} s`);
|
||||
run(['xcrun', 'stapler', 'staple', dmg]);
|
||||
run(['xcrun', 'stapler', 'validate', dmg]);
|
||||
}
|
||||
|
||||
// the exact artifacts: mount the DMG and unpack the updater bundle, then self-test what is inside
|
||||
const mnt = mkdtempSync(join(tmpdir(), 'sb-mnt-'));
|
||||
run(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]);
|
||||
|
|
@ -512,18 +546,12 @@ async function mac(argv: string[]): Promise<void> {
|
|||
dmgStapler = 'ticket stapled, validated';
|
||||
}
|
||||
say(`DMG: spctl -t open ${dmgSpctl}; ${dmgStapler}`);
|
||||
const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-'));
|
||||
run(['tar', '-xzf', updater, '-C', unpacked]);
|
||||
arch.updaterApp = archGuard(join(unpacked, APP_NAME), 'app in the updater bundle');
|
||||
checks.updaterApp = gatekeeper(join(unpacked, APP_NAME), signed, notarized, 'app in the updater bundle');
|
||||
tests.updater = selfTest(join(unpacked, APP_NAME), 'updater bundle');
|
||||
rmSync(unpacked, { recursive: true, force: true });
|
||||
|
||||
// privacy: the app folder, the DMG (mounted) and the updater bundle (unpacked)
|
||||
const privacy = privacyGate([app, dmg, updater]);
|
||||
// privacy: the app folder and the updater bundle (unpacked) above, the DMG (mounted) now
|
||||
const privacyDmg = privacyGate([dmg]);
|
||||
const privacy = { files: privacyApp.files + privacyDmg.files, bytes: privacyApp.bytes + privacyDmg.bytes, hits: 0 };
|
||||
|
||||
// signatures with the updater key, latest.json, SHA256SUMS
|
||||
const updaterSig = signFile(updater);
|
||||
// signatures with the updater key (the updater bundle's above), latest.json, SHA256SUMS
|
||||
const dmgSig = signFile(dmg);
|
||||
const notes = a.get('notes') ?? `storebound desktop ${version}`;
|
||||
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
|
@ -684,27 +712,78 @@ export function publishSteps(o: { host: string; v: string; channel: string; out:
|
|||
];
|
||||
}
|
||||
|
||||
function publish(argv: string[]): void {
|
||||
/** The channels of storebound.dev/desktop: stable (latest.json), beta, and rehearsal (tools/release.ts --rehearsal:
|
||||
* a throwaway prerelease that no app reads). */
|
||||
export const CHANNELS = ['stable', 'beta', 'rehearsal'] as const;
|
||||
export const channelUrl = (channel: string): string => (channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/${channel}/latest.json`);
|
||||
|
||||
/**
|
||||
* Files the server can fetch from the Forgejo release instead of the owner's uplink (pure): every file of the folder
|
||||
* whose SHA-256 is in the release's SHA256SUMS under some asset name (the Windows installer is renamed -unsigned
|
||||
* here). Each is downloaded on the server into the incoming folder before rsync, which then only compares checksums.
|
||||
*/
|
||||
export function seedPlan(local: { file: string; sha256: string }[], releaseSums: string, assets: { name: string; url: string }[]): { file: string; url: string; sha256: string }[] {
|
||||
const byHash = new Map<string, string>();
|
||||
for (const l of releaseSums.split('\n')) { const m = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(l.trim()); if (m) byHash.set(m[1]!, m[2]!); }
|
||||
const out: { file: string; url: string; sha256: string }[] = [];
|
||||
for (const f of local) {
|
||||
const name = byHash.get(f.sha256);
|
||||
const asset = name ? assets.find(x => x.name === name) : undefined;
|
||||
if (asset) out.push({ file: f.file, url: asset.url, sha256: f.sha256 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function publish(argv: string[]): Promise<void> {
|
||||
const a = args(argv);
|
||||
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
|
||||
const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? fail('--host <user@host> (or STOREBOUND_PUBLISH_HOST) is required');
|
||||
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
|
||||
const channel = a.get('channel') ?? 'stable';
|
||||
if (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta');
|
||||
if (!(CHANNELS as readonly string[]).includes(channel)) fail(`--channel ${CHANNELS.join(', ')}`);
|
||||
const { manifest, files } = verifyFolder(out);
|
||||
const v = manifest.version;
|
||||
const together = releaseTogetherProblems(v, versions().version);
|
||||
if (together.length) fail(together.join('; '));
|
||||
const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file);
|
||||
const steps = publishSteps({ host, v, channel, out, files });
|
||||
console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`}):`);
|
||||
const forgejoTag = a.get('forgejo-tag');
|
||||
if (forgejoTag && forgejoTag !== `v${v}`) fail(`--forgejo-tag ${forgejoTag} is not v${v}`);
|
||||
// with --forgejo-tag: the server takes the Linux / Windows files from the Forgejo release (its own network), and
|
||||
// after the rsync attaches the Mac's files to that release from its copy, so every byte crosses the owner's
|
||||
// uplink once (the DMG and the updater bundle), not three times
|
||||
const rel = forgejoTag ? (await listReleases()).find(r => r.tag_name === forgejoTag) : undefined;
|
||||
if (forgejoTag && !rel) fail(`no Forgejo release ${forgejoTag} yet (release.yml promotes the candidate on the tag push)`);
|
||||
const relSums = rel?.assets.find(x => x.name === 'SHA256SUMS');
|
||||
const sumsText0 = relSums ? spawnSync('curl', ['-fsSL', relSums.browser_download_url], { encoding: 'utf8' }).stdout ?? '' : '';
|
||||
const seed = rel ? seedPlan(files.map(f => ({ file: f, sha256: sha256(join(out, f)) })), sumsText0, rel.assets.map(x => ({ name: x.name, url: x.browser_download_url }))) : [];
|
||||
const macFiles = forgejoTag ? forgejoMacFiles(files, v) : [];
|
||||
console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channelUrl(channel)}):`);
|
||||
for (const f of files) console.log(` ${f}`);
|
||||
if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`);
|
||||
console.log('\nsteps:');
|
||||
for (const s of steps) console.log(` ${s[0]} ${s.slice(1).map(x => (x.includes(' ') ? `"${x}"` : x.replace(out, '<out>'))).join(' ')}`);
|
||||
if (seed.length) console.log(` the server fetches from Forgejo ${forgejoTag}: ${seed.map(x => x.file).join(', ')}`);
|
||||
if (macFiles.length) console.log(` then attaches to Forgejo ${forgejoTag} from its copy: ${macFiles.join(', ')}`);
|
||||
if (!a.has('yes')) { console.log('\ndry run: nothing uploaded. Every installed app on this channel offers the update once latest.json is live; run again with --yes to publish.'); return; }
|
||||
for (const s of steps) { say(`${s[0]} ...`); run(s, { stdio: ['ignore', 'inherit', 'inherit'] }); }
|
||||
const live = spawnSync('curl', ['-fsS', channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`], { encoding: 'utf8' });
|
||||
const [mkdir, rsync, finish] = steps as [string[], string[], string[]];
|
||||
run(mkdir);
|
||||
if (seed.length) {
|
||||
const dir = `${REMOTE_INCOMING}/${v}`;
|
||||
const lines = seed.map(x => { if (!/^[A-Za-z0-9._-]+$/.test(x.file) || !/^https:\/\/[A-Za-z0-9./_%-]+$/.test(x.url)) fail(`seed: unexpected ${x.file} ${x.url}`); return `curl -fsSL --max-time 900 -o '${dir}/${x.file}' '${x.url}' && echo '${x.sha256} ${dir}/${x.file}' | sha256sum -c --quiet`; });
|
||||
say(`server: fetch ${seed.length} files from the Forgejo release`);
|
||||
// in parallel on the server; any failed download or hash fails the step
|
||||
run(['ssh', host, `pids=''; ${lines.map(l => `(${l}) & pids="$pids $!"`).join('; ')}; for p in $pids; do wait $p || exit 1; done`], { stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
}
|
||||
say(`rsync (only what the server does not have yet) ...`);
|
||||
run(rsync, { stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
if (macFiles.length) {
|
||||
say(`Forgejo ${forgejoTag}: attach ${macFiles.join(', ')} from the server's copy`);
|
||||
await forgejoUpsert({ tag: forgejoTag!, files: macFiles.map(f => join(out, f)), create: false, yes: true, remote: { host, dir: `${REMOTE_INCOMING}/${v}` } });
|
||||
}
|
||||
say('publish script ...');
|
||||
run(finish, { stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
const live = spawnSync('curl', ['-fsS', channelUrl(channel)], { encoding: 'utf8' });
|
||||
if (live.status !== 0 || (JSON.parse(live.stdout) as Manifest).version !== v) fail('latest.json on storebound.dev is not the new version yet');
|
||||
say(`live: ${BASE_URL}/ serves ${v}`);
|
||||
say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`);
|
||||
|
|
@ -741,7 +820,7 @@ if (import.meta.main) {
|
|||
if (cmd === 'mac') await mac(argv.slice(1));
|
||||
else if (cmd === 'add') add(argv);
|
||||
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>')));
|
||||
else if (cmd === 'publish') publish(argv.slice(1));
|
||||
else if (cmd === 'publish') await publish(argv.slice(1));
|
||||
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
|
||||
else if (cmd === 'exec') {
|
||||
// `exec -- <command...>`: runs a build command with the path-free environment (the Forgejo workflow uses it)
|
||||
|
|
|
|||
|
|
@ -103,27 +103,34 @@ export function mergeSums(existing: string, add: { file: string; sha256: string
|
|||
|
||||
export interface Release { id: number; tag_name: string; name: string; draft: boolean; prerelease: boolean; body: string; assets: { id: number; name: string; size: number; browser_download_url: string }[] }
|
||||
|
||||
/** Release entries `prune --keep <tag>` deletes: every other entry, the kept one must exist and be published. */
|
||||
/** Release entries `prune --keep <tag>` deletes: every other entry, the kept one must exist and be published.
|
||||
* Release candidates (rc-<sha> drafts of ci.yml, tools/release-candidate.ts) are not entries: they have their own
|
||||
* pruning (`release-candidate.ts prune`), and a newer commit's candidate must survive a release. */
|
||||
export function pruneList(releases: Release[], keep: string): { remove: Release[]; problem?: string } {
|
||||
const kept = releases.find(r => r.tag_name === keep);
|
||||
if (!kept) return { remove: [], problem: `no release for ${keep}: nothing is deleted` };
|
||||
if (kept.draft) return { remove: [], problem: `the release of ${keep} is a draft: nothing is deleted` };
|
||||
return { remove: releases.filter(r => r.id !== kept.id) };
|
||||
return { remove: releases.filter(r => r.id !== kept.id && !isCandidateTag(r.tag_name)) };
|
||||
}
|
||||
|
||||
/** Tag names of dry runs: only these may be deleted with their tag. */
|
||||
export const isTestTag = (tag: string): boolean => /^test-[0-9a-f]{7,40}$/.test(tag);
|
||||
/** Tag names of dry runs and release rehearsals (tools/release.ts --rehearsal): only these may be deleted with
|
||||
* their tag. */
|
||||
export const isTestTag = (tag: string): boolean => /^test-[0-9a-f]{7,40}$/.test(tag) || /^v0\.0\.0-rehearsal\.\d+$/.test(tag);
|
||||
|
||||
/** The draft that holds the release candidate of a commit (ci.yml's record job). */
|
||||
export const candidateTag = (sha: string): string => `rc-${sha}`;
|
||||
export const isCandidateTag = (tag: string): boolean => /^rc-[0-9a-f]{40}$/.test(tag);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// API
|
||||
|
||||
const BASE = (process.env.FORGEJO_URL ?? 'https://git.crt.fyi').replace(/\/+$/, '');
|
||||
const REPO = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'CRT/storebound';
|
||||
export const BASE = (process.env.FORGEJO_URL ?? 'https://git.crt.fyi').replace(/\/+$/, '');
|
||||
export const REPO = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'CRT/storebound';
|
||||
const API = `${BASE}/api/v1/repos/${REPO}`;
|
||||
function fail(msg: string): never { console.error(`forgejo-release: ${msg}`); process.exit(1); }
|
||||
|
||||
let authHeader: string | null = null;
|
||||
function auth(): string {
|
||||
export function auth(): string {
|
||||
if (authHeader) return authHeader;
|
||||
const token = process.env.FORGEJO_TOKEN;
|
||||
if (token) return (authHeader = `token ${token}`);
|
||||
|
|
@ -135,7 +142,7 @@ function auth(): string {
|
|||
return (authHeader = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`);
|
||||
}
|
||||
|
||||
async function api<T>(method: string, path: string, body?: unknown, raw = false): Promise<T> {
|
||||
export async function api<T>(method: string, path: string, body?: unknown, raw = false): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : `${API}${path}`;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await fetch(url, {
|
||||
|
|
@ -152,6 +159,18 @@ async function api<T>(method: string, path: string, body?: unknown, raw = false)
|
|||
}
|
||||
}
|
||||
|
||||
/** Download one asset (drafts need the token) to a file; through the origin like uploads when it is set. */
|
||||
export async function downloadAsset(url: string, dest: string): Promise<void> {
|
||||
const ip = origin();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'forgejo-dl-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'h'), `Authorization: ${auth()}\n`, { mode: 0o600 });
|
||||
const host = new URL(BASE).host;
|
||||
const r = spawnSync('curl', ['-sS', '-L', '--fail', '--max-time', '1800', ...(ip ? ['--resolve', `${host}:443:${ip.includes(':') ? `[${ip}]` : ip}`] : []), '-H', `@${join(dir, 'h')}`, '-o', dest, url], { encoding: 'utf8' });
|
||||
if (r.status !== 0) fail(`download ${basename(dest)}: curl exit ${r.status}: ${r.stderr.slice(0, 300)}`);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
export async function listReleases(): Promise<Release[]> {
|
||||
const all: Release[] = [];
|
||||
for (let page = 1; page < 50; page++) {
|
||||
|
|
@ -165,7 +184,7 @@ export async function listReleases(): Promise<Release[]> {
|
|||
/** Cloudflare's request body limit: bigger uploads need the origin. */
|
||||
export const PROXY_BODY_LIMIT = 100e6;
|
||||
|
||||
function origin(): string | null {
|
||||
export function origin(): string | null {
|
||||
const f = join(homedir(), '.storebound-keys', 'forgejo-origin');
|
||||
const v = (process.env.FORGEJO_ORIGIN ?? (existsSync(f) ? readFileSync(f, 'utf8') : '')).trim();
|
||||
if (!v) return null;
|
||||
|
|
@ -173,10 +192,25 @@ function origin(): string | null {
|
|||
return v;
|
||||
}
|
||||
|
||||
/** A host that already holds the files (the VDS after the publish rsync): its curl uploads them to Forgejo, so the
|
||||
* owner's slow uplink carries each file once (tools/release.ts). The auth header goes over ssh's stdin into a 0600
|
||||
* temp file there, removed right after; never into argv or a log. */
|
||||
export interface RemoteFiles { host: string; dir: string }
|
||||
|
||||
/** One asset upload (multipart). With an origin: curl --resolve to it (header from a 0600 file, never argv). */
|
||||
async function uploadAsset(releaseId: number, name: string, file: string | null, data: string | null): Promise<void> {
|
||||
async function uploadAsset(releaseId: number, name: string, file: string | null, data: string | null, remote?: RemoteFiles): Promise<void> {
|
||||
const url = `${API}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`;
|
||||
const ip = origin();
|
||||
if (remote && file) {
|
||||
const host = new URL(BASE).host;
|
||||
const resolveArg = ip ? `--resolve ${host}:443:${ip.includes(':') ? `[${ip}]` : ip}` : '';
|
||||
const src = `${remote.dir}/${basename(file)}`;
|
||||
if (!/^[A-Za-z0-9._\/-]+$/.test(src) || !/^[A-Za-z0-9._-]+$/.test(name)) fail(`remote upload: unexpected characters in ${src}`);
|
||||
const script = `set -e; h=$(mktemp); trap 'rm -f "$h"' EXIT; chmod 600 "$h"; cat > "$h"; curl -sS --fail-with-body --max-time 1800 ${resolveArg} -H @"$h" -F 'attachment=@${src};filename=${name}' '${url}' >/dev/null`;
|
||||
const r = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', remote.host, script], { input: `Authorization: ${auth()}\n`, encoding: 'utf8', timeout: 1_900_000 });
|
||||
if (r.status !== 0) fail(`upload ${name} (from ${remote.host}): exit ${r.status}: ${(r.stdout + r.stderr).slice(0, 300)}`);
|
||||
return;
|
||||
}
|
||||
const size = file ? statSync(file).size : Buffer.byteLength(data ?? '');
|
||||
if (!ip) {
|
||||
if (size > PROXY_BODY_LIMIT) fail(`${name} is ${(size / 1e6).toFixed(0)} MB: over Cloudflare's 100 MB limit; set FORGEJO_ORIGIN (the server's IP) or ~/.storebound-keys/forgejo-origin`);
|
||||
|
|
@ -199,7 +233,7 @@ async function uploadAsset(releaseId: number, name: string, file: string | null,
|
|||
|
||||
const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
|
||||
export interface UpsertOptions { tag: string; title?: string; notes?: string; target?: string; draft?: boolean; create?: boolean; files: string[]; yes: boolean }
|
||||
export interface UpsertOptions { tag: string; title?: string; notes?: string; target?: string; draft?: boolean; prerelease?: boolean; create?: boolean; files: string[]; yes: boolean; remote?: RemoteFiles; quiet?: boolean }
|
||||
|
||||
/** Create or update the release of a tag and attach files (same name = replaced), SHA256SUMS merged. */
|
||||
export async function upsert(o: UpsertOptions): Promise<Release | null> {
|
||||
|
|
@ -216,13 +250,13 @@ export async function upsert(o: UpsertOptions): Promise<Release | null> {
|
|||
const big = files.filter(f => statSync(f).size > PROXY_BODY_LIMIT);
|
||||
if (big.length && !origin()) fail(`${big.map(f => basename(f)).join(', ')} over 100 MB: Cloudflare refuses them; set FORGEJO_ORIGIN (the server's IP) or ~/.storebound-keys/forgejo-origin`);
|
||||
if (!rel && o.create === false) fail(`no release for ${o.tag} yet: push the tag first, the release workflow creates it`);
|
||||
const fields = { name: o.title, body: o.notes, draft: o.draft ?? false, prerelease: false };
|
||||
const fields = { name: o.title, body: o.notes, draft: o.draft ?? false, prerelease: o.prerelease };
|
||||
if (rel) rel = await api<Release>('PATCH', `/releases/${rel.id}`, Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)));
|
||||
else rel = await api<Release>('POST', '/releases', { tag_name: o.tag, target_commitish: o.target, ...fields, name: o.title ?? o.tag });
|
||||
else rel = await api<Release>('POST', '/releases', { tag_name: o.tag, target_commitish: o.target, ...fields, prerelease: o.prerelease ?? false, name: o.title ?? o.tag });
|
||||
const upload = async (name: string, file: string | null, data: string | null) => {
|
||||
const old = rel!.assets.find(a => a.name === name);
|
||||
if (old) await api('DELETE', `/releases/${rel!.id}/assets/${old.id}`);
|
||||
await uploadAsset(rel!.id, name, file, data);
|
||||
await uploadAsset(rel!.id, name, file, data, o.remote);
|
||||
console.log(` uploaded ${name}`);
|
||||
};
|
||||
for (const f of files) await upload(basename(f), f, null);
|
||||
|
|
@ -251,13 +285,14 @@ async function prune(keep: string, yes: boolean): Promise<void> {
|
|||
}
|
||||
|
||||
async function deleteTag(tag: string): Promise<void> {
|
||||
if (isCandidateTag(tag)) return; // a candidate draft never made a tag
|
||||
if (!isTestTag(tag)) fail(`refusing to delete tag ${tag}: only test-* tags of dry runs`);
|
||||
const res = await fetch(`${API}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE', headers: { Authorization: auth() } });
|
||||
if (res.status !== 204 && res.status !== 404) fail(`DELETE tag ${tag}: ${res.status}`);
|
||||
}
|
||||
|
||||
async function deleteTest(tag: string, yes: boolean): Promise<void> {
|
||||
if (!isTestTag(tag)) fail(`delete is for dry-run drafts (test-<sha>), not ${tag}`);
|
||||
export async function deleteTest(tag: string, yes: boolean): Promise<void> {
|
||||
if (!isTestTag(tag) && !isCandidateTag(tag)) fail(`delete is for dry-run drafts (test-<sha>), rehearsals (v0.0.0-rehearsal.<n>) and candidates (rc-<sha>), not ${tag}`);
|
||||
const rel = (await listReleases()).find(r => r.tag_name === tag);
|
||||
console.log(rel ? `delete draft release ${tag} (${rel.assets.length} assets) and the tag if any` : `no release ${tag}; delete the tag if any`);
|
||||
if (!yes) { console.log('dry run: nothing deleted (--yes to delete)'); return; }
|
||||
|
|
|
|||
285
tools/release-candidate.ts
Normal file
285
tools/release-candidate.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
#!/usr/bin/env bun
|
||||
// Release candidates: every push to main is one. ci.yml runs the full CI, the acceptance matrix (Linux + Windows x
|
||||
// Node 20 / Node 22 / Bun) and the Linux / Windows installer builds with their self-tests and privacy scans; its
|
||||
// record job stores the result per commit as a DRAFT release `rc-<full sha>` on git.crt.fyi: the installers,
|
||||
// SHA256SUMS, the six acceptance run files, the matrix and green.json (uploaded last: a candidate without green.json
|
||||
// is incomplete). A `v*` tag then only promotes (release.yml): no rebuild, no retest.
|
||||
//
|
||||
// bun tools/release-candidate.ts gate --sha <sha> [--wait <min>]
|
||||
// ci.yml's first job. When the commit differs from its parent only in version copies (tools/desktop-version.ts:
|
||||
// each changed copy must be exactly the parent's text with the new version written in), CHANGELOG.md and
|
||||
// docs/acceptance/*.json, and the parent is a green candidate, the full CI and the acceptance matrix of the parent
|
||||
// count for this commit (reuse=true; the parent's run is waited for while it is still running). Only the
|
||||
// installers, whose embedded version changes, are built again. Otherwise reuse=false: everything runs.
|
||||
// bun tools/release-candidate.ts record --sha <sha> --run <url> --dir <dir> [--reused-from <sha>] [--yes]
|
||||
// The record job: <dir> holds the installers and acceptance/*.json (with --reused-from, the parent's acceptance
|
||||
// files are fetched from its candidate). Writes green.json and uploads everything to the draft rc-<sha>.
|
||||
// bun tools/release-candidate.ts status --sha <sha> [--json]
|
||||
// green (with the record), red (with the failed run's link), pending or none.
|
||||
// bun tools/release-candidate.ts wait --sha <sha> [--timeout <min>]
|
||||
// Waits for green (heartbeat every 30 s); exits 1 at once when the commit is red or has no ci run.
|
||||
// bun tools/release-candidate.ts fetch --sha <sha> --dir <dir> [--only <regex>]
|
||||
// Downloads a green candidate's files (checked against its SHA256SUMS).
|
||||
// bun tools/release-candidate.ts promote --tag v<version> --sha <sha> [--wait <min>] [--yes]
|
||||
// release.yml: waits for the tagged commit's green candidate (fails fast when it is red or missing, with the
|
||||
// link), checks its version against the tag, and makes the release of the tag from the candidate's files
|
||||
// (tools/forgejo-release.ts upsert; a prerelease for versions with a "-", which is never pruned and never
|
||||
// prunes). Then the older release entries are deleted (stable versions only).
|
||||
// bun tools/release-candidate.ts prune [--keep <n>] [--yes]
|
||||
// Deletes candidate drafts except the newest n (default 12).
|
||||
//
|
||||
// Token: FORGEJO_TOKEN (ci.yml / release.yml: the RELEASE_TOKEN secret) or the git credential helper (on the Mac).
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { cliKey } from './cli-key';
|
||||
import { COPIES, SEMVER } from './desktop-version';
|
||||
import { api, BASE, candidateTag, deleteTest, downloadAsset, isCandidateTag, listReleases, type Release, REPO, shortNotes, SUMS, upsert } from './forgejo-release';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
export const GREEN = 'green.json';
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
export interface GreenRecord {
|
||||
schema: 1;
|
||||
sha: string;
|
||||
version: string;
|
||||
cliKey: string;
|
||||
run: string;
|
||||
at: string;
|
||||
/** the parent whose full CI and acceptance matrix count for this version-only commit */
|
||||
reusedFrom?: { sha: string; run: string };
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Pure parts (tests/release-candidate.test.ts)
|
||||
|
||||
export interface Change { file: string; status: 'A' | 'M' | 'D' | string; before: string | null; after: string | null }
|
||||
|
||||
/** Files a version-only commit may touch besides the version copies: the changelog and the macOS acceptance results. */
|
||||
const FREE = [/^CHANGELOG\.md$/, /^docs\/acceptance\.md$/, /^docs\/acceptance\/[A-Za-z0-9_-]+\.json$/];
|
||||
|
||||
/**
|
||||
* Why a commit is not a version-only change of its parent (empty = it is). Strict: every changed file is on the
|
||||
* allow-list, and every version copy is byte for byte the parent's text with the new version written in by the very
|
||||
* writer tools/desktop-version.ts uses (so no other line of package.json, Cargo.toml, ... can change).
|
||||
*/
|
||||
export function versionOnlyProblems(changes: Change[]): { from: string | null; to: string | null; problems: string[] } {
|
||||
const problems: string[] = [];
|
||||
const pkg = changes.find(c => c.file === 'package.json');
|
||||
const read = (t: string | null) => (t ? COPIES[0]!.read(t) : null);
|
||||
const from = read(pkg?.before ?? null);
|
||||
const to = read(pkg?.after ?? null);
|
||||
if (!pkg || !from || !to || from === to) problems.push('package.json: the version does not change');
|
||||
else if (!SEMVER.test(to)) problems.push(`package.json: ${to} is not a version`);
|
||||
for (const c of changes) {
|
||||
if (FREE.some(re => re.test(c.file))) continue;
|
||||
const copy = COPIES.find(x => x.file === c.file);
|
||||
if (!copy) { problems.push(`${c.file}: not a version copy (${c.status})`); continue; }
|
||||
if (c.status !== 'M' || c.before === null || c.after === null) { problems.push(`${c.file}: ${c.status}, not modified in place`); continue; }
|
||||
if (!to || copy.read(c.before) !== from || copy.write(c.before, to) !== c.after) problems.push(`${c.file}: changes more than the version`);
|
||||
}
|
||||
return { from, to, problems };
|
||||
}
|
||||
|
||||
export type Status =
|
||||
| { state: 'green'; record: GreenRecord; release: Release }
|
||||
| { state: 'red'; run: string; failed: string[] }
|
||||
| { state: 'pending'; run: string; running: string[] }
|
||||
| { state: 'none' };
|
||||
|
||||
export interface Task { id: number; name: string; run_number: number; workflow_id: string; head_sha: string; status: string }
|
||||
|
||||
/** The state of a commit from its ci.yml jobs (the newest run of that commit) and whether its record exists. Pure. */
|
||||
export function taskState(tasks: Task[], sha: string, runUrl: (n: number) => string): Exclude<Status, { state: 'green' }> {
|
||||
const mine = tasks.filter(t => t.head_sha === sha && t.workflow_id === 'ci.yml');
|
||||
if (!mine.length) return { state: 'none' };
|
||||
const latest = Math.max(...mine.map(t => t.run_number));
|
||||
const jobs = mine.filter(t => t.run_number === latest);
|
||||
const failed = jobs.filter(t => ['failure', 'cancelled'].includes(t.status)).map(t => t.name);
|
||||
if (failed.length) return { state: 'red', run: runUrl(latest), failed };
|
||||
const running = jobs.filter(t => !['success', 'skipped'].includes(t.status)).map(t => `${t.name} (${t.status})`);
|
||||
// every job done and green but no green.json yet: the record job is still uploading, or ran in an older run
|
||||
return { state: 'pending', run: runUrl(latest), running };
|
||||
}
|
||||
|
||||
/** The version of a tag (v0.8.5 -> 0.8.5), else null. */
|
||||
export const tagVersion = (tag: string): string | null => (/^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag)?.[1] ?? null);
|
||||
/** Versions with a pre-release part (0.0.0-rehearsal.1) are prereleases: never the stable release, never pruning. */
|
||||
export const isPrerelease = (version: string): boolean => version.includes('-');
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function fail(msg: string): never { console.error(`release-candidate: ${msg}`); process.exit(1); }
|
||||
const t0 = Date.now();
|
||||
const say = (m: string) => console.log(`[${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s] ${m}`);
|
||||
const sha256 = (f: string) => createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
const runUrl = (n: number) => `${BASE}/${REPO}/actions/runs/${n}`;
|
||||
|
||||
function git(args: string[], allowFail = false): string {
|
||||
const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 });
|
||||
if (r.status !== 0 && !allowFail) fail(`git ${args.join(' ')}: ${r.stderr.trim()}`);
|
||||
return r.status === 0 ? r.stdout : '';
|
||||
}
|
||||
|
||||
export function changesBetween(base: string, head: string): Change[] {
|
||||
const out = git(['diff', '--name-status', '--no-renames', base, head]).trim();
|
||||
if (!out) return [];
|
||||
return out.split('\n').map(l => {
|
||||
const [status, file] = l.split('\t') as [string, string];
|
||||
const show = (rev: string) => { const r = spawnSync('git', ['show', `${rev}:${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 }); return r.status === 0 ? r.stdout : null; };
|
||||
return { file, status, before: status === 'A' ? null : show(base), after: status === 'D' ? null : show(head) };
|
||||
});
|
||||
}
|
||||
|
||||
async function tasks(): Promise<Task[]> {
|
||||
const all: Task[] = [];
|
||||
for (let page = 1; page <= 3; page++) {
|
||||
const r = await api<{ workflow_runs?: Task[] }>('GET', `/actions/tasks?limit=50&page=${page}`);
|
||||
all.push(...(r.workflow_runs ?? []));
|
||||
if ((r.workflow_runs ?? []).length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function record(release: Release): Promise<GreenRecord | null> {
|
||||
const a = release.assets.find(x => x.name === GREEN);
|
||||
if (!a) return null;
|
||||
return JSON.parse(await api<string>('GET', a.browser_download_url, undefined, true)) as GreenRecord;
|
||||
}
|
||||
|
||||
export async function status(sha: string): Promise<Status> {
|
||||
const rel = (await listReleases()).find(r => r.tag_name === candidateTag(sha));
|
||||
const rec = rel ? await record(rel) : null;
|
||||
if (rel && rec) return { state: 'green', record: rec, release: rel };
|
||||
return taskState(await tasks(), sha, runUrl);
|
||||
}
|
||||
|
||||
/** Waits for a commit's candidate: green returns, red and none (after `graceMin`) fail at once. */
|
||||
export async function waitGreen(sha: string, timeoutMin: number, graceMin = 3): Promise<Extract<Status, { state: 'green' }>> {
|
||||
const end = Date.now() + timeoutMin * 60_000;
|
||||
let last = 0;
|
||||
for (;;) {
|
||||
const s = await status(sha);
|
||||
if (s.state === 'green') { say(`candidate ${sha.slice(0, 10)} is green (run ${s.record.run}${s.record.reusedFrom ? `, CI and acceptance of ${s.record.reusedFrom.sha.slice(0, 10)}` : ''})`); return s; }
|
||||
if (s.state === 'red') fail(`commit ${sha.slice(0, 10)} is red: ${s.failed.join(', ')} failed in ${s.run}. Fix it and push; nothing is released from a red commit.`);
|
||||
if (s.state === 'none' && Date.now() - t0 > graceMin * 60_000) fail(`commit ${sha.slice(0, 10)} has no ci run on ${BASE}/${REPO}/actions: push it to main (ci.yml makes the candidate)`);
|
||||
if (Date.now() > end) fail(`timeout after ${timeoutMin} min waiting for the candidate of ${sha.slice(0, 10)}${s.state === 'pending' ? `: ${s.run} still has ${s.running.join(', ') || 'the record upload'}` : ''}`);
|
||||
if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(`waiting for candidate ${sha.slice(0, 10)}: ${s.state === 'pending' ? `${s.run}: ${s.running.join(', ') || 'record upload'}` : 'no run yet'}`); }
|
||||
await Bun.sleep(10_000);
|
||||
}
|
||||
}
|
||||
|
||||
/** Download a green candidate's files (green.json excluded), each checked against its SHA256SUMS. */
|
||||
export async function fetchCandidate(s: Extract<Status, { state: 'green' }>, dir: string, only?: RegExp): Promise<string[]> {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const got: string[] = [];
|
||||
const assets = s.release.assets.filter(a => a.name !== GREEN && a.name !== SUMS && (!only || only.test(a.name)));
|
||||
await Promise.all(assets.map(async a => {
|
||||
const dest = join(dir, a.name);
|
||||
if (existsSync(dest) && s.record.files[a.name] && sha256(dest) === s.record.files[a.name]) { got.push(dest); return; }
|
||||
await downloadAsset(a.browser_download_url, dest);
|
||||
const want = s.record.files[a.name];
|
||||
if (want && sha256(dest) !== want) fail(`${a.name}: SHA-256 differs from the candidate's record`);
|
||||
got.push(dest);
|
||||
}));
|
||||
return got.sort();
|
||||
}
|
||||
|
||||
function output(values: Record<string, string>): void {
|
||||
for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`);
|
||||
const f = process.env.GITHUB_OUTPUT;
|
||||
if (f) appendFileSync(f, Object.entries(values).map(([k, v]) => `${k}=${v}\n`).join(''));
|
||||
}
|
||||
|
||||
async function gate(sha: string, waitMin: number): Promise<void> {
|
||||
const parent = git(['rev-parse', `${sha}^`], true).trim();
|
||||
if (!parent) { output({ reuse: 'false', from: '' }); say('no parent in this checkout (fetch-depth 2): everything runs'); return; }
|
||||
const { from, to, problems } = versionOnlyProblems(changesBetween(parent, sha));
|
||||
if (problems.length) { say(`not a version-only commit: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? ' ...' : ''}: everything runs`); output({ reuse: 'false', from: '' }); return; }
|
||||
say(`version-only commit: ${from} -> ${to} (every copy exactly re-written, plus changelog / acceptance files); the parent ${parent.slice(0, 10)}'s CI and acceptance matrix count if it is green`);
|
||||
const first = await status(parent);
|
||||
if (first.state === 'none') { say(`the parent has no ci run (pushed together with this commit?): everything runs`); output({ reuse: 'false', from: '' }); return; }
|
||||
const s = await waitGreen(parent, waitMin);
|
||||
if (s.record.cliKey !== cliKey(ROOT)) { say(`the parent's CLI key ${s.record.cliKey} is not this commit's: everything runs`); output({ reuse: 'false', from: '' }); return; }
|
||||
output({ reuse: 'true', from: parent });
|
||||
}
|
||||
|
||||
async function recordCmd(sha: string, run: string, dir: string, reusedFrom: string | undefined, yes: boolean): Promise<void> {
|
||||
const version = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!;
|
||||
const acc = join(dir, 'acceptance');
|
||||
let reused: GreenRecord['reusedFrom'];
|
||||
if (reusedFrom) {
|
||||
const p = await status(reusedFrom);
|
||||
if (p.state !== 'green') fail(`--reused-from ${reusedFrom.slice(0, 10)} is ${p.state}, not green`);
|
||||
await fetchCandidate(p, acc, /^acceptance-.*\.json$/);
|
||||
reused = { sha: reusedFrom, run: p.record.run };
|
||||
}
|
||||
const files = readdirSync(dir, { recursive: true }).map(String).filter(f => statSync(join(dir, f)).isFile() && basename(f) !== SUMS && basename(f) !== GREEN);
|
||||
const flat = files.map(f => ({ path: join(dir, f), name: f.startsWith('acceptance/') && !basename(f).startsWith('acceptance-') ? `acceptance-${basename(f)}` : basename(f) }));
|
||||
const names = new Set<string>();
|
||||
for (const f of flat) { if (names.has(f.name)) fail(`two files named ${f.name}`); names.add(f.name); }
|
||||
for (const f of flat) if (!f.name.startsWith('acceptance-') && !f.name.includes(`_${version}_`)) fail(`${f.name} is not version ${version}`);
|
||||
const rec: GreenRecord = { schema: 1, sha, version, cliKey: cliKey(ROOT), run, at: new Date().toISOString(), reusedFrom: reused, files: Object.fromEntries(flat.map(f => [f.name, sha256(f.path)])) };
|
||||
// assets keep flat names: copy acceptance files under their asset name
|
||||
const stage = join(dir, '.stage');
|
||||
rmSync(stage, { recursive: true, force: true });
|
||||
mkdirSync(stage);
|
||||
const paths = flat.map(f => { const p = join(stage, f.name); writeFileSync(p, readFileSync(f.path)); return p; });
|
||||
const tag = candidateTag(sha);
|
||||
await upsert({ tag, target: sha, title: `candidate ${sha.slice(0, 10)} (${version})`, notes: `Release candidate of ${sha} (${version}): ${run}${reused ? `; CI and acceptance matrix of ${reused.sha} (version-only parent): ${reused.run}` : ''}. Promoted by a v${version} tag (release.yml); deleted by \`release-candidate.ts prune\`.`, draft: true, files: paths, yes });
|
||||
if (!yes) return;
|
||||
// green.json last: a candidate is complete only with it
|
||||
writeFileSync(join(stage, GREEN), `${JSON.stringify(rec, null, 2)}\n`);
|
||||
await upsert({ tag, draft: true, files: [join(stage, GREEN)], yes: true });
|
||||
say(`candidate ${tag} recorded: ${flat.length} files${reused ? `, CI + acceptance reused from ${reused.sha.slice(0, 10)}` : ''}`);
|
||||
}
|
||||
|
||||
async function promote(tag: string, sha: string, waitMin: number, yes: boolean): Promise<void> {
|
||||
const version = tagVersion(tag) ?? fail(`${tag} is not v<version>`);
|
||||
const s = await waitGreen(sha, waitMin);
|
||||
if (s.record.version !== version) fail(`the candidate of ${sha.slice(0, 10)} is version ${s.record.version}, the tag says ${version}`);
|
||||
const pre = isPrerelease(version);
|
||||
const dir = join(ROOT, '.promote');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
const files = await fetchCandidate(s, dir, /^(?!acceptance-).+/);
|
||||
say(`fetched ${files.length} files of the candidate (${files.map(f => basename(f)).join(', ')})`);
|
||||
const notes = shortNotes(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, { tag });
|
||||
const pick = (re: RegExp) => files.filter(f => re.test(basename(f)));
|
||||
const ship = [...pick(/\.(AppImage|deb|exe)$/), ...pick(/_acceptance\.md$/)];
|
||||
if (!pick(/\.AppImage$/).length || !pick(/\.deb$/).length || !pick(/\.exe$/).length) fail('the candidate lacks the AppImage, the deb or the Windows installer');
|
||||
await upsert({ tag, target: sha, title: pre ? `storebound ${version} (prerelease)` : `storebound ${version}`, notes, prerelease: pre, files: ship, yes });
|
||||
if (!yes) { say('dry run: nothing released (--yes)'); return; }
|
||||
say(`released ${tag} from candidate ${sha.slice(0, 10)}: ${BASE}/${REPO}/releases/tag/${tag}`);
|
||||
}
|
||||
|
||||
async function prune(keep: number, yes: boolean): Promise<void> {
|
||||
const rcs = (await listReleases()).filter(r => isCandidateTag(r.tag_name)).sort((a, b) => b.id - a.id);
|
||||
const drop = rcs.slice(keep);
|
||||
say(`${rcs.length} candidates; deleting ${drop.length} older than the newest ${keep}`);
|
||||
for (const r of drop) await deleteTest(r.tag_name, yes);
|
||||
}
|
||||
|
||||
function args(argv: string[]) {
|
||||
const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
|
||||
return { get, has: (n: string) => argv.includes(`--${n}`) };
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [cmd, ...argv] = process.argv.slice(2);
|
||||
const a = args(argv);
|
||||
const sha = () => { const s = a.get('sha') ?? fail('--sha <full commit sha>'); if (!/^[0-9a-f]{40}$/.test(s)) fail('--sha: a full 40-character sha'); return s; };
|
||||
if (cmd === 'gate') await gate(sha(), Number(a.get('wait') ?? 40));
|
||||
else if (cmd === 'record') await recordCmd(sha(), a.get('run') ?? fail('--run <url>'), resolve(a.get('dir') ?? fail('--dir <dir>')), a.get('reused-from') || undefined, a.has('yes'));
|
||||
else if (cmd === 'status') {
|
||||
const s = await status(sha());
|
||||
if (a.has('json')) console.log(JSON.stringify(s.state === 'green' ? { state: s.state, record: s.record } : s));
|
||||
else console.log(s.state === 'green' ? `green: ${s.record.run}` : s.state === 'none' ? 'none' : `${s.state}: ${s.run} ${'failed' in s ? s.failed.join(', ') : s.running.join(', ')}`);
|
||||
} else if (cmd === 'wait') await waitGreen(sha(), Number(a.get('timeout') ?? 40));
|
||||
else if (cmd === 'fetch') { const s = await waitGreen(sha(), 1); const only = a.get('only'); for (const f of await fetchCandidate(s, resolve(a.get('dir') ?? fail('--dir')), only ? new RegExp(only) : undefined)) console.log(f); }
|
||||
else if (cmd === 'promote') await promote(a.get('tag') ?? fail('--tag v<version>'), sha(), Number(a.get('wait') ?? 40), a.has('yes'));
|
||||
else if (cmd === 'prune') await prune(Number(a.get('keep') ?? 12), a.has('yes'));
|
||||
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
|
||||
}
|
||||
361
tools/release.ts
Normal file
361
tools/release.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
#!/usr/bin/env bun
|
||||
// One command for a whole storebound release on the owner's Mac (docs/desktop-release.md, "The release in one
|
||||
// command"). Everything but `npm publish` (the owner's step; the commands are printed at the end):
|
||||
//
|
||||
// bun tools/release.ts <version> [--yes] e.g. bun tools/release.ts 0.8.5 --yes
|
||||
// bun tools/release.ts --rehearsal [<n>] [--yes] the same end to end as version 0.0.0-rehearsal.<n> on the branch
|
||||
// rehearsal/<n>: a prerelease on git.crt.fyi and the rehearsal
|
||||
// channel of storebound.dev (no app reads it), never npm and never
|
||||
// /Applications; everything it made is deleted at the end
|
||||
// bun tools/release.ts mac-acceptance the macOS acceptance runs for this checkout's CLI key (cached)
|
||||
//
|
||||
// Without --yes: the checks and the plan only. With --yes, in order:
|
||||
// 1. preflight: a clean checkout of origin/main whose candidate is green or still running (tools/release-candidate.ts),
|
||||
// the updater key, the publish host; the macOS acceptance matrix starts in the background unless its results for
|
||||
// this CLI key are cached (~/.storebound-releases/acceptance-cache/<key>/; a version bump keeps the key);
|
||||
// 2. the version in package.json and every copy, the CHANGELOG's Unreleased section cut as "## <version> · <date>";
|
||||
// 3. the fast gate (bun run ci:quick), the commit (as CRT), push to main, push the tag v<version>;
|
||||
// 4. at once, in parallel: the Mac build (tools/desktop-release.ts mac: universal app, sign, notarize app and DMG,
|
||||
// every check), Forgejo's ci run of the bump commit (version-only: the installers only, the parent's CI and
|
||||
// matrix count) and the promotion of the tag (release.yml); this waits for the candidate (heartbeat, timeout)
|
||||
// and downloads its Linux / Windows installers while the Mac builds;
|
||||
// 5. add (updater signatures of the Linux / Windows files), the full acceptance matrix (9 runs), verify;
|
||||
// 6. publish: the VDS fetches the Linux / Windows files from Forgejo itself, rsync sends only the Mac's files,
|
||||
// the VDS attaches them to the Forgejo release from its copy, then storebound.dev's latest.json;
|
||||
// 7. /Applications/storebound.app: the running app is quit (gracefully, then its exact PID), the new one copied
|
||||
// from the DMG, reopened in the background;
|
||||
// 8. prints `npm publish` and `npm deprecate`, and the time of every phase (also in
|
||||
// ~/.storebound-releases/timings/).
|
||||
// Every wait has a timeout and a heartbeat; child processes are killed by PID when this exits.
|
||||
import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { cliKey } from './cli-key';
|
||||
import { remoteSessionReason } from './desktop-release';
|
||||
import { COPIES, SEMVER, setVersion } from './desktop-version';
|
||||
import { candidateTag, deleteTest, listReleases, upsert } from './forgejo-release';
|
||||
import { fetchCandidate, status, waitGreen } from './release-candidate';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const HOME = homedir();
|
||||
const RELEASES = join(HOME, '.storebound-releases');
|
||||
const KEYS = process.env.STOREBOUND_KEYS_DIR ?? join(HOME, '.storebound-keys');
|
||||
const ACC_CACHE = join(RELEASES, 'acceptance-cache');
|
||||
const APP_ID = 'dev.storebound.desktop';
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Pure parts (tests/release.test.ts)
|
||||
|
||||
/** The CHANGELOG with its Unreleased section turned into the version's section and a new empty Unreleased on top. */
|
||||
export function cutChangelog(text: string, version: string, date: string): string {
|
||||
const lines = text.split('\n');
|
||||
const i = lines.findIndex(l => /^## Unreleased\s*$/.test(l));
|
||||
if (i < 0) throw new Error('CHANGELOG.md has no "## Unreleased" section');
|
||||
const next = lines.findIndex((l, j) => j > i && l.startsWith('## '));
|
||||
const body = lines.slice(i + 1, next < 0 ? lines.length : next).join('\n').trim();
|
||||
if (!body) throw new Error('the Unreleased section of CHANGELOG.md is empty: write the release summary first');
|
||||
if (lines.some(l => l.startsWith('## ') && l.slice(3).trim().split(/\s/)[0] === version)) throw new Error(`CHANGELOG.md already has a section for ${version}`);
|
||||
return [...lines.slice(0, i), '## Unreleased', '', `## ${version} · ${date}`, ...lines.slice(i + 1)].join('\n');
|
||||
}
|
||||
|
||||
/** a > b for x.y.z versions (pre-release parts lower than the plain version). */
|
||||
export function newer(a: string, b: string): boolean {
|
||||
const p = (v: string) => { const [core, pre] = v.split('-', 2); return { n: core!.split('.').map(Number), pre: pre ?? null }; };
|
||||
const x = p(a); const y = p(b);
|
||||
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i]! > y.n[i]!;
|
||||
if (x.pre === y.pre) return false;
|
||||
if (x.pre === null) return true;
|
||||
if (y.pre === null) return false;
|
||||
return x.pre > y.pre;
|
||||
}
|
||||
|
||||
export interface Phase { name: string; start: number; end?: number }
|
||||
|
||||
/** The timing table: each phase's start offset and duration, the critical path is the total. */
|
||||
export function timingTable(phases: Phase[], t0: number): string {
|
||||
const f = (ms: number) => { const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
||||
const rows = phases.map(p => ` ${p.name.padEnd(46)} start +${f(p.start - t0).padStart(5)} took ${p.end ? f(p.end - p.start).padStart(5) : ' open'}`);
|
||||
const end = Math.max(...phases.map(p => p.end ?? p.start));
|
||||
return [...rows, ` ${'total (tag push to everything published)'.padEnd(46)} ${''.padStart(13)} ${f(end - t0).padStart(10)}`].join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
const t0 = Date.now();
|
||||
const phases: Phase[] = [];
|
||||
const children = new Set<ChildProcess>();
|
||||
const say = (m: string) => console.log(`[${new Date().toISOString().slice(11, 19)} +${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s] ${m}`);
|
||||
function fail(msg: string): never {
|
||||
console.error(`\nrelease: ${msg}`);
|
||||
if (phases.length) console.error(`\n${timingTable(phases, t0)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.on('exit', () => { for (const c of children) if (c.exitCode === null && c.pid) { try { process.kill(c.pid, 'SIGTERM'); } catch { /* gone */ } } });
|
||||
process.on('SIGINT', () => fail('interrupted'));
|
||||
|
||||
function phase<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
||||
const p: Phase = { name, start: Date.now() };
|
||||
phases.push(p);
|
||||
say(`>> ${name}`);
|
||||
return fn().then(r => { p.end = Date.now(); say(`<< ${name} (${Math.round((p.end - p.start) / 1000)} s)`); return r; });
|
||||
}
|
||||
|
||||
function sh(cmd: string[], opts: { cwd?: string; allowFail?: boolean; env?: Record<string, string | undefined>; input?: string } = {}): string {
|
||||
const r = spawnSync(cmd[0]!, cmd.slice(1), { cwd: opts.cwd ?? ROOT, encoding: 'utf8', maxBuffer: 256 << 20, env: (opts.env ?? process.env) as NodeJS.ProcessEnv, input: opts.input });
|
||||
if (r.status !== 0 && !opts.allowFail) fail(`${cmd.slice(0, 3).join(' ')} failed (exit ${r.status}): ${(r.stderr || r.stdout).trim().split('\n').slice(-8).join('\n')}`);
|
||||
return r.status === 0 ? r.stdout : '';
|
||||
}
|
||||
|
||||
/** A child process with its output in a log file; resolves with the exit code. Heartbeat: the log's last line. */
|
||||
function bg(name: string, cmd: string[], log: string, opts: { env?: Record<string, string | undefined>; timeoutMs: number }): Promise<number> {
|
||||
mkdirSync(resolve(log, '..'), { recursive: true });
|
||||
const fd = Bun.file(log).writer();
|
||||
const c = spawn(cmd[0]!, cmd.slice(1), { cwd: ROOT, env: (opts.env ?? process.env) as NodeJS.ProcessEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
children.add(c);
|
||||
let lastLine = '';
|
||||
const onData = (d: Buffer) => { fd.write(d); const t = d.toString().trim().split('\n').at(-1); if (t) lastLine = t; };
|
||||
c.stdout!.on('data', onData); c.stderr!.on('data', onData);
|
||||
const beat = setInterval(() => say(` ${name} (pid ${c.pid}): ${lastLine.slice(0, 150)}`), HEARTBEAT_MS);
|
||||
const timer = setTimeout(() => { say(`${name}: timeout after ${opts.timeoutMs / 60_000} min, killing pid ${c.pid}`); c.kill('SIGTERM'); }, opts.timeoutMs);
|
||||
return new Promise(res => c.on('close', code => { clearInterval(beat); clearTimeout(timer); children.delete(c); void fd.end(); res(code ?? 1); }));
|
||||
}
|
||||
|
||||
function publishHost(): string {
|
||||
const f = join(KEYS, 'publish-host');
|
||||
const h = process.env.STOREBOUND_PUBLISH_HOST ?? (existsSync(f) ? readFileSync(f, 'utf8').trim() : '');
|
||||
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(h)) fail(`the publish host (user@host of the VDS) is not set: STOREBOUND_PUBLISH_HOST or ${f.replace(HOME, '~')}`);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Node binaries for the macOS acceptance runs: fnm's Node 20 and 22 (installed from nodejs.org once if missing). */
|
||||
function nodeBin(major: 20 | 22): string {
|
||||
const find = () => sh(['fnm', 'exec', `--using=${major}`, '--', 'node', '-p', 'process.execPath'], { allowFail: true }).trim();
|
||||
let p = find();
|
||||
if (!p) { say(`fnm install ${major} (Node ${major} for the macOS acceptance run)`); sh(['fnm', 'install', String(major)]); p = find(); }
|
||||
if (!p || !existsSync(p)) fail(`no Node ${major}: fnm install ${major}`);
|
||||
return p;
|
||||
}
|
||||
|
||||
const RUNTIMES = [
|
||||
{ file: 'macOS-bun.json', args: ['--runtime', 'bun'] },
|
||||
{ file: 'macOS-node20.json', args: ['--runtime', 'node', '--node', '@20'] },
|
||||
{ file: 'macOS-node22.json', args: ['--runtime', 'node', '--node', '@22'] },
|
||||
];
|
||||
|
||||
function cachedMacAcceptance(key: string): string | null {
|
||||
const dir = join(ACC_CACHE, key);
|
||||
const ok = RUNTIMES.every(r => {
|
||||
const f = join(dir, r.file);
|
||||
if (!existsSync(f)) return false;
|
||||
const run = JSON.parse(readFileSync(f, 'utf8')) as { cliKey?: string; quick?: boolean; results: { ok: boolean }[] };
|
||||
return run.cliKey === key && !run.quick && run.results.length > 0 && run.results.every(x => x.ok);
|
||||
});
|
||||
return ok ? dir : null;
|
||||
}
|
||||
|
||||
/** The macOS acceptance matrix for this checkout's CLI key: from the cache, else the three runs in parallel from one
|
||||
* packed tarball (parallel `npm pack` in one checkout would race on dist/). */
|
||||
async function macAcceptance(key: string, logDir: string): Promise<string> {
|
||||
const hit = cachedMacAcceptance(key);
|
||||
if (hit) { say(`macOS acceptance: cached for CLI key ${key.slice(0, 12)} (${hit.replace(HOME, '~')})`); return hit; }
|
||||
const dir = join(ACC_CACHE, key);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const packDir = mkdtempSync(join(tmpdir(), 'sb-pack-'));
|
||||
sh(['npm', 'pack', '--silent', '--pack-destination', packDir]);
|
||||
const tgz = join(packDir, readdirSync(packDir).find(f => f.endsWith('.tgz')) ?? fail('npm pack made no tarball'));
|
||||
const codes = await Promise.all(RUNTIMES.map(r => {
|
||||
const args = r.args.map(a => (a === '@20' ? nodeBin(20) : a === '@22' ? nodeBin(22) : a));
|
||||
return bg(`acceptance ${r.file}`, ['bun', 'tools/acceptance.ts', 'run', ...args, '--tgz', tgz, '--platform', 'macOS', '--jobs', '3', '--out', join(dir, r.file)], join(logDir, `acceptance-${r.file}.log`), { timeoutMs: 30 * 60_000 });
|
||||
}));
|
||||
rmSync(packDir, { recursive: true, force: true });
|
||||
if (codes.some(c => c !== 0)) fail(`macOS acceptance has red cells (logs in ${logDir.replace(HOME, '~')}); nothing is released`);
|
||||
if (!cachedMacAcceptance(key)) fail('macOS acceptance: results missing after the run');
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function waitRelease(tag: string, timeoutMin: number): Promise<void> {
|
||||
const end = Date.now() + timeoutMin * 60_000;
|
||||
let last = 0;
|
||||
for (;;) {
|
||||
const rel = (await listReleases()).find(r => r.tag_name === tag);
|
||||
if (rel && !rel.draft && rel.assets.some(a => a.name.endsWith('.AppImage')) && rel.assets.some(a => a.name.endsWith('.exe'))) return;
|
||||
if (Date.now() > end) fail(`timeout after ${timeoutMin} min: release.yml has not released ${tag} (Actions > release on git.crt.fyi)`);
|
||||
if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(` waiting for release.yml to release ${tag}${rel ? ` (${rel.assets.length} assets so far)` : ''}`); }
|
||||
await Bun.sleep(5_000);
|
||||
}
|
||||
}
|
||||
|
||||
/** Quit the running app (gracefully, then its exact PID), copy the new one from the DMG, reopen it in the
|
||||
* background. In a rehearsal the copy goes to a scratch folder and nothing is quit or opened. */
|
||||
function installApp(dmg: string, target: string, rehearsal: boolean): string {
|
||||
const pids = rehearsal ? [] : sh(['pgrep', '-f', `${target}/Contents/MacOS/storebound-desktop`], { allowFail: true }).trim().split('\n').filter(Boolean).map(Number);
|
||||
if (pids.length) {
|
||||
say(`quitting storebound (pid ${pids.join(', ')}) gracefully`);
|
||||
sh(['osascript', '-e', `tell application id "${APP_ID}" to quit`], { allowFail: true });
|
||||
const gone = (pid: number) => { try { process.kill(pid, 0); return false; } catch { return true; } };
|
||||
const until = Date.now() + 15_000;
|
||||
while (Date.now() < until && !pids.every(gone)) Bun.sleepSync(250);
|
||||
for (const pid of pids.filter(p => !gone(p))) { say(`pid ${pid} still running: SIGTERM`); try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
|
||||
const until2 = Date.now() + 5_000;
|
||||
while (Date.now() < until2 && !pids.every(gone)) Bun.sleepSync(250);
|
||||
for (const pid of pids.filter(p => !gone(p))) { say(`pid ${pid} still running: SIGKILL`); try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
|
||||
}
|
||||
const mnt = mkdtempSync(join(tmpdir(), 'sb-inst-'));
|
||||
sh(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]);
|
||||
try {
|
||||
mkdirSync(resolve(target, '..'), { recursive: true });
|
||||
const tmp = `${target}.new`;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
sh(['ditto', join(mnt, 'storebound.app'), tmp]);
|
||||
sh(['codesign', '--verify', '--deep', '--strict', tmp]);
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
sh(['mv', tmp, target]);
|
||||
} finally { sh(['hdiutil', 'detach', mnt, '-force'], { allowFail: true }); rmSync(mnt, { recursive: true, force: true }); }
|
||||
const v = JSON.parse(sh(['plutil', '-convert', 'json', '-o', '-', join(target, 'Contents/Info.plist')])).CFBundleShortVersionString as string;
|
||||
if (pids.length) { sh(['open', '-g', target]); say(`reopened ${target} in the background`); }
|
||||
return v;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
const has = (f: string) => argv.includes(f);
|
||||
const yes = has('--yes');
|
||||
if (argv[0] === 'mac-acceptance') { const key = cliKey(ROOT); console.log(await macAcceptance(key, join(RELEASES, 'logs', `acceptance-${key}`))); return; }
|
||||
const rehearsal = has('--rehearsal');
|
||||
const positional = argv.filter(a => !a.startsWith('--'));
|
||||
const current = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!;
|
||||
const n = rehearsal ? Number(positional[0] ?? Math.floor(Date.now() / 1000)) : 0;
|
||||
const version = rehearsal ? `0.0.0-rehearsal.${n}` : positional[0] ?? fail('usage: bun tools/release.ts <version> [--yes] | --rehearsal [<n>] [--yes]');
|
||||
if (!SEMVER.test(version)) fail(`${version} is not a version`);
|
||||
if (!rehearsal && !newer(version, current)) fail(`${version} is not newer than ${current}`);
|
||||
const tag = `v${version}`;
|
||||
const branch = rehearsal ? `rehearsal/${n}` : 'main';
|
||||
const out = join(RELEASES, 'desktop', version);
|
||||
const logDir = join(RELEASES, 'logs', version);
|
||||
const host = publishHost();
|
||||
|
||||
// ---------------------------------------------------------------- 1. preflight
|
||||
const remote = remoteSessionReason(process.env, sh(['launchctl', 'managername'], { allowFail: true }).trim());
|
||||
if (remote) fail(`${remote}: signing and notarization need the owner's logged-in macOS session`);
|
||||
if (!existsSync(join(KEYS, 'updater.key'))) fail(`no updater key in ${KEYS.replace(HOME, '~')}`);
|
||||
if (sh(['git', 'status', '--porcelain']).trim()) fail('the checkout has uncommitted changes');
|
||||
sh(['git', 'fetch', '--quiet', 'origin', 'main', '--tags']);
|
||||
const head = sh(['git', 'rev-parse', 'HEAD']).trim();
|
||||
if (head !== sh(['git', 'rev-parse', 'origin/main']).trim()) fail(`HEAD ${head.slice(0, 10)} is not origin/main: release from main as it is on git.crt.fyi (pull / push first), so the bump's parent is a candidate`);
|
||||
if (!rehearsal && sh(['git', 'rev-parse', '-q', '--verify', `refs/tags/${tag}`], { allowFail: true }).trim()) fail(`tag ${tag} exists already`);
|
||||
const parent = await status(head);
|
||||
if (parent.state === 'red') fail(`origin/main ${head.slice(0, 10)} is red (${parent.failed.join(', ')}): ${parent.run}`);
|
||||
if (parent.state === 'none') fail(`origin/main ${head.slice(0, 10)} has no ci run: its candidate is needed (push to main runs ci.yml)`);
|
||||
say(`storebound ${current} -> ${version}${rehearsal ? ' (rehearsal: prerelease, rehearsal channel, deleted at the end)' : ''}; parent ${head.slice(0, 10)} is ${parent.state}${parent.state === 'pending' ? ` (${parent.run})` : ''}`);
|
||||
// the release commit's author is the one of the commits already on main (the project's identity)
|
||||
const identity = sh(['git', 'log', '-1', '--format=%an <%ae>', 'origin/main']).trim();
|
||||
const key = cliKey(ROOT);
|
||||
const accCached = cachedMacAcceptance(key);
|
||||
console.log(`plan:
|
||||
bump ${current} -> ${version} (package.json + ${COPIES.length - 1} copies)${rehearsal ? '' : ', CHANGELOG Unreleased -> ' + version}; bun run ci:quick; commit as ${identity}
|
||||
push ${branch}, push ${tag}; then in parallel: Mac build + notarize -> ${out.replace(HOME, '~')}; macOS acceptance (${accCached ? 'cached' : 'runs now'}, key ${key.slice(0, 12)});
|
||||
Forgejo ci (candidate of the bump commit) + release.yml (promotion); add, verify, publish to ${rehearsal ? 'the rehearsal channel' : 'stable'} via ${host};
|
||||
${rehearsal ? 'app copied to a scratch folder; then the tag, release, candidates, branch and storebound.dev files of the rehearsal are deleted' : 'update /Applications/storebound.app; print npm publish + npm deprecate'}`);
|
||||
if (!yes) { console.log('\ndry run: nothing changed (--yes to release)'); return; }
|
||||
|
||||
const accP = phase('macOS acceptance (cache or 3 runs)', () => macAcceptance(key, logDir));
|
||||
accP.catch(() => undefined);
|
||||
|
||||
// ---------------------------------------------------------------- 2. + 3. bump, gate, commit, push
|
||||
const startBranch = sh(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
||||
let sha = '';
|
||||
await phase('bump + fast gate + commit + push', async () => {
|
||||
if (rehearsal) sh(['git', 'checkout', '-q', '-b', branch]);
|
||||
setVersion(version);
|
||||
if (!rehearsal) writeFileSync(join(ROOT, 'CHANGELOG.md'), cutChangelog(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, new Date().toISOString().slice(0, 10)));
|
||||
const code = await bg('ci:quick', ['bun', 'run', 'ci:quick'], join(logDir, 'ci-quick.log'), { timeoutMs: 15 * 60_000 });
|
||||
if (code !== 0) fail(`bun run ci:quick failed (${join(logDir, 'ci-quick.log').replace(HOME, '~')}); the bump is not committed (git checkout -- . to undo)`);
|
||||
const me = `${sh(['git', 'config', 'user.name']).trim()} <${sh(['git', 'config', 'user.email']).trim()}>`;
|
||||
if (me !== identity) fail(`git identity is ${me}; the commits on main are by ${identity} (git config user.name / user.email)`);
|
||||
sh(['git', 'add', '-A']);
|
||||
sh(['git', 'commit', '-q', '-m', rehearsal ? `release rehearsal ${version} (deleted after the run)` : `storebound ${version}: version in package.json and every copy, CHANGELOG ${version} section`]);
|
||||
sha = sh(['git', 'rev-parse', 'HEAD']).trim();
|
||||
sh(['git', 'push', '-q', 'origin', `HEAD:refs/heads/${branch}`]);
|
||||
sh(['git', 'tag', tag]);
|
||||
sh(['git', 'push', '-q', 'origin', `refs/tags/${tag}`]);
|
||||
say(`pushed ${sha.slice(0, 10)} to ${branch} and ${tag}`);
|
||||
});
|
||||
const tPush = Date.now();
|
||||
phases.push({ name: '== tag pushed: clock for "tag -> published" ==', start: tPush, end: tPush });
|
||||
|
||||
// ---------------------------------------------------------------- 4. in parallel
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
const macP = phase('Mac build + sign + notarize (app, DMG)', async () => {
|
||||
const code = await bg('mac build', ['bun', 'tools/desktop-release.ts', 'mac', '--out', out], join(logDir, 'mac.log'), { timeoutMs: 60 * 60_000 });
|
||||
if (code !== 0) fail(`the Mac build failed: ${join(logDir, 'mac.log').replace(HOME, '~')}`);
|
||||
});
|
||||
const dl = join(RELEASES, 'candidates', sha);
|
||||
const candP = phase('Forgejo: candidate of the bump commit (installers)', async () => {
|
||||
const s = await waitGreen(sha, 45);
|
||||
return s;
|
||||
});
|
||||
const fetchP = candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl)));
|
||||
const relP = candP.then(() => phase('Forgejo: release.yml promotes the tag', () => waitRelease(tag, 15)));
|
||||
await Promise.all([macP, fetchP, relP, accP]);
|
||||
const cand = await candP;
|
||||
|
||||
// ---------------------------------------------------------------- 5. add, acceptance matrix, verify
|
||||
await phase('add Linux / Windows + acceptance matrix + verify', async () => {
|
||||
const files = readdirSync(dl).filter(f => /\.(AppImage|deb|exe)$/.test(f)).map(f => join(dl, f));
|
||||
sh(['bun', 'tools/desktop-release.ts', 'add', '--out', out, '--commit', sha, '--run', cand.record.run, ...files]);
|
||||
const accDir = mkdtempSync(join(tmpdir(), 'sb-acc-'));
|
||||
for (const f of readdirSync(dl).filter(f => /^acceptance-.*\.json$/.test(f))) writeFileSync(join(accDir, f), readFileSync(join(dl, f)));
|
||||
const macDir = await accP;
|
||||
for (const r of RUNTIMES) writeFileSync(join(accDir, r.file), readFileSync(join(macDir, r.file)));
|
||||
const md = join(logDir, `storebound_${version}_acceptance.md`);
|
||||
sh(['bun', 'tools/acceptance.ts', 'report', accDir, '--md', md, '--expect', '9', '--strict']);
|
||||
rmSync(accDir, { recursive: true, force: true });
|
||||
sh(['bun', 'tools/desktop-release.ts', 'verify', '--out', out]);
|
||||
await upsert({ tag, files: [md], create: false, yes: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 6. publish
|
||||
await phase('publish (VDS seeds from Forgejo, Mac files once, latest.json)', async () => {
|
||||
const code = await bg('publish', ['bun', 'tools/desktop-release.ts', 'publish', '--out', out, '--host', host, '--channel', rehearsal ? 'rehearsal' : 'stable', '--forgejo-tag', tag, '--yes'], join(logDir, 'publish.log'), { timeoutMs: 45 * 60_000 });
|
||||
if (code !== 0) fail(`publish failed: ${join(logDir, 'publish.log').replace(HOME, '~')}`);
|
||||
});
|
||||
const tPublished = Date.now();
|
||||
phases.push({ name: '== published ==', start: tPublished, end: tPublished });
|
||||
|
||||
// ---------------------------------------------------------------- 7. the app on this Mac
|
||||
const dmg = readdirSync(out).find(f => f.endsWith('.dmg')) ?? fail('no DMG');
|
||||
const target = rehearsal ? join(RELEASES, 'rehearsal-install', 'storebound.app') : '/Applications/storebound.app';
|
||||
const installed = await phase(`install ${target.replace(HOME, '~')}`, async () => installApp(join(out, dmg), target, rehearsal));
|
||||
say(`installed storebound ${installed} at ${target.replace(HOME, '~')}`);
|
||||
|
||||
// ---------------------------------------------------------------- 8. the end
|
||||
if (rehearsal) {
|
||||
await phase('rehearsal cleanup', async () => {
|
||||
await deleteTest(tag, true);
|
||||
for (const s of [sha]) await deleteTest(candidateTag(s), true);
|
||||
sh(['git', 'push', '-q', 'origin', '--delete', branch], { allowFail: true });
|
||||
sh(['git', 'checkout', '-q', startBranch === 'HEAD' ? head : startBranch]);
|
||||
sh(['git', 'branch', '-q', '-D', branch], { allowFail: true });
|
||||
sh(['git', 'tag', '-d', tag], { allowFail: true });
|
||||
if (!/^0\.0\.0-rehearsal\.\d+$/.test(version)) fail('cleanup: not a rehearsal version');
|
||||
sh(['ssh', '-o', 'BatchMode=yes', host, `rm -rf /var/www/storebound-desktop/dl/${version} /var/www/storebound-desktop/rehearsal/latest.json /var/lib/storebound-desktop/incoming/${version}`]);
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
rmSync(dl, { recursive: true, force: true });
|
||||
rmSync(resolve(target, '..'), { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
const table = timingTable(phases, tPush);
|
||||
mkdirSync(join(RELEASES, 'timings'), { recursive: true });
|
||||
const tf = join(RELEASES, 'timings', `${version}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
||||
writeFileSync(tf, `${JSON.stringify({ version, sha, tPush, tPublished, phases }, null, 2)}\n`);
|
||||
console.log(`\nstorebound ${version}: ${rehearsal ? 'rehearsal done and cleaned up' : 'released'}. Times (from the tag push):\n${table}\n start of this command to tag push: ${Math.round((tPush - t0) / 1000)} s; timings in ${tf.replace(HOME, '~')}`);
|
||||
if (!rehearsal) {
|
||||
console.log(`\nThe owner's step (npm), from this checkout at ${tag}:
|
||||
npm publish
|
||||
npm deprecate storebound@"<${version}" "storebound ${version} is out: npx storebound@${version}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
# document root, because the site's publish rsync deletes everything that is not in the repository.
|
||||
Alias /desktop/latest.json /var/www/storebound-desktop/latest.json
|
||||
Alias /desktop/beta/latest.json /var/www/storebound-desktop/beta/latest.json
|
||||
Alias /desktop/rehearsal/latest.json /var/www/storebound-desktop/rehearsal/latest.json
|
||||
Alias /desktop/dl/ /var/www/storebound-desktop/dl/
|
||||
<Directory /var/www/storebound-desktop>
|
||||
Options -Indexes -FollowSymLinks
|
||||
|
|
@ -15,7 +16,7 @@ Alias /desktop/dl/ /var/www/storebound-desktop/dl/
|
|||
Header set Content-Disposition "attachment"
|
||||
</FilesMatch>
|
||||
</Directory>
|
||||
<LocationMatch "^/desktop/(beta/)?latest\.json$">
|
||||
<LocationMatch "^/desktop/((beta|rehearsal)/)?latest\.json$">
|
||||
Header set Cache-Control "no-cache"
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
</LocationMatch>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
# Root-owned publish step for storebound desktop releases (install as /usr/local/sbin/storebound-desktop-publish,
|
||||
# root:root 755; see docs/desktop-release.md). tools/desktop-release.ts publish uploads one version folder to
|
||||
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta]
|
||||
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta|rehearsal]
|
||||
# (rehearsal: tools/release.ts --rehearsal, a throwaway prerelease at /desktop/rehearsal/latest.json that no app reads)
|
||||
#
|
||||
# It checks the upload (regular files with plain names only, SHA256SUMS, latest.json of that version whose every URL
|
||||
# points at an uploaded file of /desktop/dl/<version>/), copies the files into /var/www/storebound-desktop/dl/<version>/
|
||||
|
|
@ -20,8 +21,10 @@ ROOT=/var/www/storebound-desktop
|
|||
SITE=https://storebound.dev/desktop
|
||||
|
||||
die() { echo "storebound-desktop-publish: $*" >&2; exit 1; }
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta]"
|
||||
[[ "$CHANNEL" == stable || "$CHANNEL" == beta ]] || die "channel: stable or beta"
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta|rehearsal]"
|
||||
[[ "$CHANNEL" == stable || "$CHANNEL" == beta || "$CHANNEL" == rehearsal ]] || die "channel: stable, beta or rehearsal"
|
||||
# a stable or beta version is never a rehearsal, and a rehearsal never reaches stable or beta
|
||||
if [[ "$VERSION" == *-rehearsal.* ]]; then [ "$CHANNEL" = rehearsal ] || die "a rehearsal version goes to the rehearsal channel only"; else [ "$CHANNEL" != rehearsal ] || die "the rehearsal channel takes rehearsal versions only"; fi
|
||||
IN="$INCOMING/$VERSION"
|
||||
DL="$ROOT/dl/$VERSION"
|
||||
[ -d "$IN" ] && [ ! -L "$IN" ] || die "nothing uploaded at $IN"
|
||||
|
|
@ -51,7 +54,7 @@ for e in entries:
|
|||
assert e.get("signature"), url
|
||||
PY
|
||||
|
||||
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta"
|
||||
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta" "$ROOT/rehearsal"
|
||||
if [ ! -e "$DL" ]; then
|
||||
TMP="$(mktemp -d "$ROOT/dl/.tmp-$VERSION.XXXXXX")"
|
||||
for f in "$IN"/*; do n="$(basename "$f")"; [ "$n" = latest.json ] || install -m 644 "$f" "$TMP/$n"; done
|
||||
|
|
@ -76,7 +79,7 @@ fi
|
|||
install -m 644 "$IN/latest.json" "$DL/.latest.json.tmp" && mv -f "$DL/.latest.json.tmp" "$DL/latest.json"
|
||||
|
||||
# latest.json last: installed apps see the new version only once every file it names is in place
|
||||
if [ "$CHANNEL" = stable ]; then LJ="$ROOT/latest.json"; URL="$SITE/latest.json"; else LJ="$ROOT/beta/latest.json"; URL="$SITE/beta/latest.json"; fi
|
||||
if [ "$CHANNEL" = stable ]; then LJ="$ROOT/latest.json"; URL="$SITE/latest.json"; else LJ="$ROOT/$CHANNEL/latest.json"; URL="$SITE/$CHANNEL/latest.json"; fi
|
||||
install -m 644 "$IN/latest.json" "$LJ.tmp"
|
||||
mv -f "$LJ.tmp" "$LJ"
|
||||
rm -rf "$IN"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue