Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 5m19s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An AppImage manager can adopt the current build but never update it: the image carries no update information, which is the string that tells such a tool where to look for a newer one. It also carries no AppStream metadata, so a manager has nothing to show but a filename — appimagetool has been warning about that on every build — and linuxdeploy leaves `Categories=` empty, which files the app nowhere in a desktop menu. All three are fixed while the image is already unpacked for the wayland fix, so the cost is a few lines rather than a second pass. `unbundle-wayland-client.sh` is now `finalize-appimage.sh`, since it does more than unbundle. The update URL is a **fixed** `linux-latest` tag on the GitHub mirror, which is where updates are pulled from — deliberately not `releases/latest`. `latest` follows whichever release is newest, and the Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including the `-win` and `-mac` tags that carry no AppImage. A URL that can resolve to a release with no AppImage in it fails on users' machines and nowhere else. The output is named for that tag too, and that is not cosmetic: zsync records a *relative* filename which a client resolves against the .zsync URL it fetched, so a versioned name would send every client after the build it already has. Verified by reading the generated header — `Filename: Triple-C_x86_64 .AppImage` — and the image's own `.upd_info` section, which is where the tag actually lives. My first guard checked the .zsync for the tag and failed correctly, which is how that distinction got found rather than shipped. Range requests were confirmed against the mirror before building on them: 206 with a correct content-range, so updates are real deltas rather than an 85 MB re-download. The .deb and .rpm go. They are two more artifacts to build, publish and keep working for an audience already served by the one file that runs on every distribution, and neither could ever self-update — which is now the difference that matters. Older releases keep theirs. The Linux job passes `--bundles appimage` rather than changing `tauri.conf.json`, so macOS and Windows are untouched. Verified against the real 0.4.19 artifact: it repacks, the AppStream file and filled-in Categories land inside the image, the update string resolves to the fixed tag, and the wayland fallback still holds. Both publisher failure paths refuse rather than half-publishing — no token, and missing artifacts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
661 lines
28 KiB
YAML
661 lines
28 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. build-app.yml used to do that 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}"
|
|
|
|
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: |
|
|
rm -rf node_modules package-lock.json
|
|
npm install
|
|
|
|
- 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
|
|
cp app/src-tauri/target/release/bundle/appimage/*.zsync 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: |
|
|
rm -rf node_modules
|
|
npm install
|
|
|
|
- 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%"
|
|
cargo install tauri-cli --version "^2"
|
|
|
|
- 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
|
|
env:
|
|
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
|
# 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
|
|
|
|
- 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
|