Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / test (pull_request) Successful in 5m25s
Build App (Preview) / build-macos (pull_request) Successful in 4m15s
Build App (Preview) / build-windows (pull_request) Successful in 6m11s
Build App (Preview) / build-linux (pull_request) Successful in 14m11s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
Artifact Signing is metered at about 1000 signatures a month, and there were 67 Windows builds last month. - windows-sign.ps1 signs only what reaches users: the app binary, the MSI, the NSIS installer and the uninstaller. It skips the WiX extension DLLs and NSIS plugins that Tauri also offers, and any file that is already validly signed. That is 4 signatures per release. - Previews are no longer signed, so the preview workflow no longer references the signing secrets. A PR's workflow runs the PR's own code. The inline TAURI_CONFIG there, which the v2 CLI never read, becomes a real `--config`. - Tauri reports a failed sign command only as "failed to run powershell". The script now keeps a transcript, signtool /debug included, and the release job prints it on failure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
799 lines
35 KiB
YAML
799 lines
35 KiB
YAML
name: Build App (Preview)
|
|
|
|
# Builds the Tauri app for branches other than main and publishes the bundles as
|
|
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
|
|
# sync.
|
|
#
|
|
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
|
# a push that breaks any of them fails here. Its `test` job runs vitest and
|
|
# `cargo test` too, so a push that breaks either suite fails here as well.
|
|
# Previews are not code-signed (releases are, in build-app.yml): see the
|
|
# comment on the Windows job's "Build Tauri app" step.
|
|
# build-app.yml used to do the build-check job in parallel and publish nothing,
|
|
# which meant six OS builds per push and one unreachable set of bundles; it is
|
|
# now releases-only.
|
|
#
|
|
# The cost of the swap, stated plainly: one prerelease per PR commit that
|
|
# touches `app/**` — so the workflow prunes its own, keeping the newest
|
|
# KEEP_PREVIEWS (see Lifecycle).
|
|
#
|
|
# ## Why not workflow artifacts
|
|
#
|
|
# Two attempts failed before this one, and both failure modes are worth knowing:
|
|
#
|
|
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
|
|
# `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that
|
|
# is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and
|
|
# throws before making a single request. act_runner sets that variable to this
|
|
# Gitea instance, so every platform died with "GHESNotSupportedError" — after
|
|
# the whole Tauri build had been paid for (run #265).
|
|
# * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but
|
|
# Gitea does not **list** them: `/api/v1/…/runs/<id>/artifacts` reports
|
|
# `total_count: 0` and the run page shows nothing (verified on run #267).
|
|
# A build nobody can find is not a build.
|
|
#
|
|
# So previews publish the same way every other workflow here does: curl to the
|
|
# Gitea releases API. One release per preview, tagged `preview-<sha>`.
|
|
#
|
|
# ## Lifecycle
|
|
#
|
|
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
|
|
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
|
|
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
|
|
# release list, and a manual cleanup sweeps any this workflow missed.
|
|
#
|
|
# But that cleanup is a manual, dry-run-by-default action, and one prerelease per
|
|
# pushed commit accumulates faster than anyone runs it. So the last job here
|
|
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
|
# release; the point of a preview is the build you are testing now.
|
|
#
|
|
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
|
|
# mirror never sees one (it only runs for its own `push`-triggered release),
|
|
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
|
|
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
|
|
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
|
|
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
|
|
# (never valid semver) already blocks it independently. (The previous
|
|
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
|
|
# `gitea.event.release.*` fields that are only ever populated by a `release`
|
|
# trigger, so it could never have actually run; deleted rather than fixed,
|
|
# since build-app.yml's inline mirror already does what it was meant to do
|
|
# for real releases. See triple-c#32.)
|
|
|
|
env:
|
|
GITEA_URL: ${{ gitea.server_url }}
|
|
REPO: ${{ gitea.repository }}
|
|
# How many preview releases survive a run, newest first — including the one
|
|
# just published.
|
|
KEEP_PREVIEWS: "2"
|
|
|
|
on:
|
|
# Every push to an open PR: this *is* the branch's build check — it compiles
|
|
# Linux, macOS and Windows — and publishing the result costs nothing extra
|
|
# once they are built. build-app.yml deliberately no longer runs on PRs.
|
|
pull_request:
|
|
branches: [main]
|
|
paths:
|
|
- "app/**"
|
|
- "VERSION"
|
|
- ".gitea/workflows/build-app-preview.yml"
|
|
workflow_dispatch:
|
|
|
|
jobs:
|
|
compute-version:
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
version: ${{ steps.version.outputs.VERSION }}
|
|
sha: ${{ steps.version.outputs.SHA }}
|
|
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
|
|
# The bundle version fields never see this — see "Set app version" in
|
|
# each build job — but it is baked into the binary as
|
|
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
|
|
# An installed preview otherwise reports the same bare number a
|
|
# production build would, indistinguishable in the About panel and to
|
|
# `check_for_updates`. See triple-c#32.
|
|
suffix: ${{ steps.version.outputs.SUFFIX }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Fetch all tags
|
|
run: git fetch --tags
|
|
|
|
- name: Compute preview version
|
|
id: version
|
|
run: |
|
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
|
# From the checkout, not from `gitea.sha`: on a pull_request event
|
|
# that variable can be the merge ref, which is not the commit anyone
|
|
# is testing and not something to hang a tag on.
|
|
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
|
|
|
# The patch number must be the same "one past the highest patch
|
|
# already used" build-app.yml computes for a real release — not a
|
|
# distance from the latest tag. It used to be
|
|
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
|
# own history section documents as broken for exactly this reason:
|
|
# it resets to zero on every tag cut, so previews went *backwards*
|
|
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
|
# a preview number from later colliding with a real release's.
|
|
#
|
|
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
|
|
# / `-win` suffixed ones a partially-published release can leave
|
|
# behind) means a preview built right before a release computes the
|
|
# exact number that release is about to take — e.g. `0.4.13` for
|
|
# both. That makes the two numerically *equal*, not "preview less
|
|
# than release" — plain semver ordering does not make a
|
|
# `-preview.<sha>` suffix sort lower on its own here, because
|
|
# `check_for_updates` compares against the bare, stripped
|
|
# `CARGO_PKG_VERSION`, never the suffixed display string. What
|
|
# closes the loop is `update_commands.rs`'s `is_preview_build`
|
|
# check, which relaxes that one comparison to `>=` specifically so
|
|
# "a release exists at my own number" reads as an update. See
|
|
# triple-c#32.
|
|
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
|
| sort -n | tail -1 || true)
|
|
|
|
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
|
|
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
|
|
# HEAD can be a commit a release was already cut from. Without this,
|
|
# dispatching a preview there would compute `HIGHEST + 1` — one past
|
|
# that release — and produce exactly the "preview outranks
|
|
# production" failure triple-c#32 was filed over, just reintroduced
|
|
# through the manual-dispatch door instead of the automatic one.
|
|
EXISTING=$(git tag --points-at HEAD \
|
|
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
|
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
|
| sort -n | tail -1 || true)
|
|
|
|
if [ -n "$EXISTING" ]; then
|
|
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
|
|
PATCH="${EXISTING}"
|
|
elif [ -n "$HIGHEST" ]; then
|
|
echo "Highest patch already used on this line: ${HIGHEST}"
|
|
PATCH=$((HIGHEST + 1))
|
|
else
|
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
|
PATCH=0
|
|
fi
|
|
|
|
SUFFIX="preview.${SHORT_SHA}"
|
|
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
|
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
|
echo "Computed preview version: ${VERSION}"
|
|
|
|
# One release, created once. The three build jobs run concurrently, so
|
|
# get-or-create in each of them would race on the same tag: whoever loses gets
|
|
# a 409 and (the way the old build-app.yml parsed it) an empty release id that
|
|
# still reported success. Creating it in a job they all depend on removes the
|
|
# race rather than handling it.
|
|
create-release:
|
|
runs-on: ubuntu-latest
|
|
needs: [compute-version]
|
|
outputs:
|
|
release_id: ${{ steps.release.outputs.RELEASE_ID }}
|
|
tag: ${{ steps.release.outputs.TAG }}
|
|
steps:
|
|
- name: Create the preview release
|
|
id: release
|
|
env:
|
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
|
SHA: ${{ needs.compute-version.outputs.sha }}
|
|
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="preview-${VERSION##*.}"
|
|
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
|
|
|
|
# Idempotent: re-dispatching the same commit must update the existing
|
|
# release rather than fail on the duplicate tag.
|
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
|
case "${HTTP_CODE}" in
|
|
200) echo "Release ${TAG} already exists, reusing" ;;
|
|
404)
|
|
echo "Creating release ${TAG}"
|
|
# prerelease: true keeps it off "latest" — this is a branch build,
|
|
# not something anyone should install by accident.
|
|
curl -fsS -X POST \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
|
;;
|
|
*)
|
|
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
|
cat release.json >&2 || true
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
|
if [ -z "${RELEASE_ID}" ]; then
|
|
echo "Failed to parse release id; response was:" >&2
|
|
cat release.json >&2
|
|
exit 1
|
|
fi
|
|
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
|
echo "Release ${TAG} is id ${RELEASE_ID}"
|
|
|
|
# The test suites. Before this job CI ran neither: every check below lived on
|
|
# a developer's machine. The one that matters most is the app-command ACL
|
|
# census — `cargo test` is what re-checks the committed capability files and
|
|
# `gen/schemas/acl-manifests.json` against `generate_handler!`, and vitest's
|
|
# `capabilities.test.ts` is what keeps each window's code to the wrappers its
|
|
# capability grants. A command left ungranted builds fine and only fails at
|
|
# runtime ("not allowed by ACL"), so these tests are the merge-time guard.
|
|
#
|
|
# Independent of the release: no `needs`, so it runs alongside the three
|
|
# platform builds rather than in front of them, and a red test fails the PR
|
|
# check without holding up a preview someone may want to try anyway.
|
|
#
|
|
# Setup mirrors build-linux on purpose — the same Node, the same apt set
|
|
# (`cargo test` compiles the whole Tauri crate, so it needs WebKitGTK like
|
|
# a real build) and `npm ci` from the lockfile for the reasons given there.
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Install Node.js 22
|
|
run: |
|
|
NEED_INSTALL=false
|
|
if command -v node >/dev/null 2>&1; then
|
|
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
|
OLD_NODE_DIR=$(dirname "$(which node)")
|
|
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
|
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
|
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
|
|
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
|
|
hash -r
|
|
NEED_INSTALL=true
|
|
fi
|
|
else
|
|
echo "Node.js not found, installing 22..."
|
|
NEED_INSTALL=true
|
|
fi
|
|
if [ "$NEED_INSTALL" = true ]; then
|
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
|
sudo apt-get install -y nodejs
|
|
hash -r
|
|
fi
|
|
node --version
|
|
npm --version
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Install system dependencies
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y \
|
|
libgtk-3-dev \
|
|
libwebkit2gtk-4.1-dev \
|
|
libayatana-appindicator3-dev \
|
|
librsvg2-dev \
|
|
libsoup-3.0-dev \
|
|
libssl-dev \
|
|
libxdo-dev \
|
|
pkg-config \
|
|
build-essential \
|
|
curl
|
|
|
|
- name: Install Rust stable
|
|
run: |
|
|
if command -v rustup >/dev/null 2>&1; then
|
|
rustup update stable
|
|
rustup default stable
|
|
else
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
|
fi
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
rustc --version
|
|
cargo --version
|
|
|
|
- name: Install frontend dependencies
|
|
working-directory: ./app
|
|
run: npm ci
|
|
|
|
# `npm run build` is `tsc && vite build`: the type check, and the
|
|
# `dist/` that `tauri::generate_context!` needs to exist before the Rust
|
|
# crate — and so `cargo test` — will compile at all.
|
|
- name: Type-check and build the frontend
|
|
working-directory: ./app
|
|
run: npm run build
|
|
|
|
- name: Frontend tests (vitest)
|
|
working-directory: ./app
|
|
run: npx vitest run
|
|
|
|
# `--locked`: test against the committed Cargo.lock, never a re-resolved
|
|
# one, for the same reason the frontend uses `npm ci`.
|
|
- name: Backend tests (cargo test)
|
|
working-directory: ./app/src-tauri
|
|
run: |
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
cargo test --locked
|
|
|
|
build-linux:
|
|
runs-on: ubuntu-latest
|
|
needs: [compute-version, create-release]
|
|
steps:
|
|
- name: Install Node.js 22
|
|
run: |
|
|
NEED_INSTALL=false
|
|
if command -v node >/dev/null 2>&1; then
|
|
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
|
OLD_NODE_DIR=$(dirname "$(which node)")
|
|
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
|
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
|
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
|
|
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
|
|
hash -r
|
|
NEED_INSTALL=true
|
|
fi
|
|
else
|
|
echo "Node.js not found, installing 22..."
|
|
NEED_INSTALL=true
|
|
fi
|
|
if [ "$NEED_INSTALL" = true ]; then
|
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
|
sudo apt-get install -y nodejs
|
|
hash -r
|
|
fi
|
|
node --version
|
|
npm --version
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Set app version
|
|
run: |
|
|
# Tauri / Cargo require a strict semver; strip the preview suffix for
|
|
# the bundle version but keep it in the artifact filename.
|
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
|
sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
|
echo "Patched version to ${BASE_VERSION}"
|
|
|
|
- name: Install system dependencies
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y \
|
|
libgtk-3-dev \
|
|
libwebkit2gtk-4.1-dev \
|
|
libayatana-appindicator3-dev \
|
|
librsvg2-dev \
|
|
libsoup-3.0-dev \
|
|
libssl-dev \
|
|
libxdo-dev \
|
|
patchelf \
|
|
pkg-config \
|
|
build-essential \
|
|
curl \
|
|
wget \
|
|
file \
|
|
xdg-utils
|
|
|
|
- name: Install Rust stable
|
|
run: |
|
|
if command -v rustup >/dev/null 2>&1; then
|
|
rustup update stable
|
|
rustup default stable
|
|
else
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
|
fi
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
rustc --version
|
|
cargo --version
|
|
|
|
- name: Install frontend dependencies
|
|
working-directory: ./app
|
|
run: |
|
|
# `npm ci` — from the lockfile, never resolving afresh.
|
|
#
|
|
# This used to be `rm -rf node_modules package-lock.json && npm
|
|
# install`, which deleted the lockfile "to ensure correct
|
|
# platform-specific bindings" (2d4fce9). That made every build
|
|
# re-resolve the whole tree against the registry, so a dependency
|
|
# publishing a new version could break CI with no change to this
|
|
# repo — and one did. Deleting the lockfile then hit a null
|
|
# dereference in npm 10.9.8's arborist peer-set resolver:
|
|
#
|
|
# npm error Cannot read properties of null (reading 'edgesOut')
|
|
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
|
#
|
|
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
|
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
|
# Reproduced exactly by removing the lockfile locally on the same
|
|
# Node 22.23.2 the runner installs.
|
|
#
|
|
# The binding worry is obsolete: the committed lockfile records 25
|
|
# rollup platform variants, and `npm ci` on Linux installs precisely
|
|
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
|
# with a clean tsc, a successful build and 752 passing tests from the
|
|
# resulting tree.
|
|
#
|
|
# Do not "fix" a future dependency error by deleting the lockfile
|
|
# again. If `npm ci` refuses, package.json and the lockfile have
|
|
# genuinely diverged, and the fix is to commit an updated lockfile.
|
|
npm ci
|
|
|
|
- name: Install Tauri CLI
|
|
working-directory: ./app
|
|
run: |
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
npx tauri --version || npm install @tauri-apps/cli
|
|
|
|
- name: Build Tauri app
|
|
working-directory: ./app
|
|
env:
|
|
# Baked into the binary via `option_env!` in `get_app_version()` —
|
|
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
|
# no room for a suffix), so this is the only place a preview build
|
|
# can still tell itself apart from a production one. See
|
|
# triple-c#32.
|
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
|
run: |
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
|
# artifact that runs everywhere, and building them is pure cost.
|
|
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
|
npx tauri build --bundles appimage
|
|
|
|
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
|
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
|
# window comes up blank. It has to come from the host; see the script
|
|
# header for the evidence and the trade.
|
|
- name: Finalize the AppImage
|
|
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
|
|
|
- name: Collect artifacts
|
|
run: |
|
|
mkdir -p artifacts
|
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
|
ls -la artifacts/
|
|
|
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
|
- name: Upload Linux bundles to the preview release
|
|
shell: bash
|
|
env:
|
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
files=(artifacts/*)
|
|
if [ ${#files[@]} -eq 0 ]; then
|
|
echo "No Linux bundles were produced" >&2
|
|
exit 1
|
|
fi
|
|
for file in "${files[@]}"; do
|
|
filename=$(basename "$file")
|
|
EXISTING_ID=$(curl -sS \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
|
if [ -n "${EXISTING_ID}" ]; then
|
|
echo "Replacing existing asset ${filename}"
|
|
curl -fsS -X DELETE \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
|
fi
|
|
echo "Uploading ${filename}..."
|
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
|
-X POST \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@${file}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
|
done
|
|
|
|
build-macos:
|
|
runs-on: macos-latest
|
|
needs: [compute-version, create-release]
|
|
steps:
|
|
- name: Install Node.js 22
|
|
run: |
|
|
NEED_INSTALL=false
|
|
if command -v node >/dev/null 2>&1; then
|
|
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
|
NEED_INSTALL=true
|
|
fi
|
|
else
|
|
NEED_INSTALL=true
|
|
fi
|
|
if [ "$NEED_INSTALL" = true ]; then
|
|
brew install node@22
|
|
brew link --overwrite node@22
|
|
fi
|
|
node --version
|
|
npm --version
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Set app version
|
|
run: |
|
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
|
sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
|
echo "Patched version to ${BASE_VERSION}"
|
|
|
|
- name: Install Rust stable
|
|
run: |
|
|
if command -v rustup >/dev/null 2>&1; then
|
|
rustup update stable
|
|
rustup default stable
|
|
else
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
|
fi
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
|
rustc --version
|
|
cargo --version
|
|
|
|
- name: Install frontend dependencies
|
|
working-directory: ./app
|
|
run: |
|
|
# `npm ci` here too, so all three platforms install identically and
|
|
# none of them can re-resolve the tree mid-release. Windows already
|
|
# did. See the Linux job for what a fresh resolution cost us.
|
|
npm ci
|
|
|
|
- name: Install Tauri CLI
|
|
working-directory: ./app
|
|
run: |
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
npx tauri --version || npm install @tauri-apps/cli
|
|
|
|
- name: Build Tauri app (universal)
|
|
working-directory: ./app
|
|
env:
|
|
# See the matching comment on the Linux job's "Build Tauri app" step.
|
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
|
run: |
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
npx tauri build --target universal-apple-darwin
|
|
|
|
- name: Collect artifacts
|
|
run: |
|
|
mkdir -p artifacts
|
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
|
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
|
ls -la artifacts/
|
|
|
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
|
- name: Upload macOS bundles to the preview release
|
|
shell: bash
|
|
env:
|
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
files=(artifacts/*)
|
|
if [ ${#files[@]} -eq 0 ]; then
|
|
echo "No macOS bundles were produced" >&2
|
|
exit 1
|
|
fi
|
|
for file in "${files[@]}"; do
|
|
filename=$(basename "$file")
|
|
EXISTING_ID=$(curl -sS \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
|
if [ -n "${EXISTING_ID}" ]; then
|
|
echo "Replacing existing asset ${filename}"
|
|
curl -fsS -X DELETE \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
|
fi
|
|
echo "Uploading ${filename}..."
|
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
|
-X POST \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@${file}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
|
done
|
|
|
|
build-windows:
|
|
runs-on: windows-latest
|
|
needs: [compute-version, create-release]
|
|
defaults:
|
|
run:
|
|
shell: cmd
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Set app version
|
|
shell: powershell
|
|
run: |
|
|
$raw = "${{ needs.compute-version.outputs.version }}"
|
|
$version = $raw.Split('-')[0]
|
|
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
|
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
|
Write-Host "Patched version to $version"
|
|
|
|
- name: Install Rust stable
|
|
run: |
|
|
where rustup >nul 2>&1 && (
|
|
rustup update stable
|
|
rustup default stable
|
|
) || (
|
|
curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
|
|
rustup-init.exe -y --default-toolchain stable
|
|
del rustup-init.exe
|
|
)
|
|
|
|
- name: Install Node.js
|
|
run: |
|
|
where node >nul 2>&1 && (
|
|
node --version
|
|
) || (
|
|
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
|
|
msiexec /i node-install.msi /quiet /norestart
|
|
del node-install.msi
|
|
)
|
|
|
|
- name: Verify tools
|
|
run: |
|
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
|
rustc --version
|
|
cargo --version
|
|
node --version
|
|
npm --version
|
|
|
|
- name: Install Tauri CLI via cargo
|
|
run: |
|
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
|
rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which
|
|
rem the Linux and macOS jobs run, and kept identical to build-app.yml so
|
|
rem a preview is built by the same bundler as the release it previews.
|
|
cargo install tauri-cli --version "=2.11.0" --locked
|
|
|
|
- name: Fix npm platform detection
|
|
run: |
|
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
|
npm config set os win32
|
|
npm config list
|
|
|
|
- name: Install frontend dependencies
|
|
working-directory: ./app
|
|
run: |
|
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
|
if exist node_modules rmdir /s /q node_modules
|
|
npm ci
|
|
|
|
- name: Build frontend
|
|
working-directory: ./app
|
|
run: |
|
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
|
npm run build
|
|
|
|
- name: Build Tauri app
|
|
working-directory: ./app
|
|
# Previews are not code-signed: signing is metered, previews are built
|
|
# on every PR push, and a PR's workflow runs the PR's own code - so the
|
|
# signing secrets stay out of this workflow entirely. Releases are
|
|
# signed in build-app.yml.
|
|
#
|
|
# beforeBuildCommand is blanked through --config because the frontend
|
|
# was built in the step above. Not TAURI_CONFIG: the v2 CLI never
|
|
# reads that variable, and the inline one this step used to set was a
|
|
# no-op.
|
|
env:
|
|
# See the matching comment on the Linux job's "Build Tauri app" step.
|
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
|
run: |
|
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
|
cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
|
|
|
- name: Collect artifacts
|
|
run: |
|
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
|
mkdir artifacts
|
|
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
|
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
|
dir artifacts\
|
|
|
|
# PowerShell, because this job's default shell is cmd. Same
|
|
# delete-then-upload shape as the other two.
|
|
- name: Upload Windows bundles to the preview release
|
|
shell: powershell
|
|
env:
|
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
|
run: |
|
|
$ErrorActionPreference = "Stop"
|
|
$headers = @{ Authorization = "token $env:TOKEN" }
|
|
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
|
$files = @(Get-ChildItem -File -Path artifacts\*)
|
|
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
|
|
|
|
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
|
|
foreach ($file in $files) {
|
|
$name = $file.Name
|
|
$dupe = $existing | Where-Object { $_.name -eq $name }
|
|
if ($dupe) {
|
|
Write-Host "Replacing existing asset $name"
|
|
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
|
|
}
|
|
Write-Host "Uploading $name..."
|
|
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
|
|
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
|
-X POST -H "Authorization: token $env:TOKEN" `
|
|
-H "Content-Type: application/octet-stream" `
|
|
--data-binary "@$($file.FullName)" $uploadUri
|
|
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
|
}
|
|
|
|
# Keep the preview list short. Runs after the builds and only if all three
|
|
# succeeded: a half-published run must not be what evicts a good older build.
|
|
prune-previews:
|
|
runs-on: ubuntu-latest
|
|
needs: [create-release, build-linux, build-macos, build-windows]
|
|
steps:
|
|
- name: Delete all but the newest preview releases
|
|
env:
|
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
curl -fsS -H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
|
|
|
|
# Newest first by creation time, `preview-` only, and never the one
|
|
# this run just published — a clock skew must not delete it.
|
|
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
|
|
import json, sys
|
|
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
|
|
previews = [r for r in json.load(open("releases.json"))
|
|
if r["tag_name"].startswith("preview-")]
|
|
previews.sort(key=lambda r: r["created_at"], reverse=True)
|
|
for r in previews[keep:]:
|
|
if r["tag_name"] != keep_tag:
|
|
print(r["id"], r["tag_name"])
|
|
PY
|
|
)
|
|
|
|
if [ -z "${DOOMED}" ]; then
|
|
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
|
|
exit 0
|
|
fi
|
|
|
|
echo "${DOOMED}" | while read -r ID TAG; do
|
|
[ -z "${ID}" ] && continue
|
|
echo "Deleting ${TAG} (id ${ID})"
|
|
# Best effort: a preview someone deleted by hand mid-run is not a
|
|
# reason to fail a build that otherwise succeeded.
|
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
|
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
|
|
done
|