Files
Triple-C/.gitea/workflows/build-app-preview.yml
T
shadowdaoandClaude Opus 5 d561ce03d5
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Failing after 1m49s
Build App (Preview) / build-macos (pull_request) Successful in 2m57s
Build App (Preview) / build-windows (pull_request) Successful in 16m16s
Build App (Preview) / prune-previews (pull_request) Skipped
Anchor the update channel tag, and stop shipping a duplicate AppImage
Two defects in the update channel, both visible in 0.4.20 and 0.4.21.

**The channel tag does not survive.** `publish-update-channel.sh` created the
GitHub release, uploaded both assets and verified each URL returned 200 — the
job log shows it succeeding at 00:38. By 13:04 the tag was gone and every
installed copy was checking a 404.

Gitea push-mirrors this repo to GitHub every four hours, and a mirror push
deletes remote refs with no local counterpart. `linux-latest` was created by
GitHub's release API and never existed as a Gitea tag, so the mirror removed
it. Versioned tags were never affected because `create-tag` creates them in
Gitea first.

So the tag is now anchored in Gitea, and before the GitHub release rather than
after, so there is no window where the two disagree. Its absence fails the
step instead of warning, because it is the only thing keeping the channel
alive. Worth stating plainly: publishing correctly is not evidence the channel
still works, and the verification that passed at 00:38 could not have caught a
failure that arrives twelve hours later.

**Every release carried the AppImage twice.** The channel's stable-named copy
sat beside the versioned one, where the release job's `*.AppImage` glob picked
it up — so v0.4.21 published `Triple-C_0.4.21_amd64.AppImage` and
`Triple-C_x86_64.AppImage`, byte-identical at 86,686,200 bytes each, and
`sync-to-github` copied both to the mirror. 80 MB of duplicate per release,
under a name that reads like a different build. That is how it was noticed.

The channel pair now lives in `bundle/appimage/update-channel/`, out of the
glob's reach, and a guard fails the build if more than one AppImage is left
beside the release. Verified by planting a second one: it fails.

One appimagetool quirk found while moving it — zsyncmake writes the .zsync
into the working directory, not beside the image it describes, so it has to be
collected rather than assumed in place. The existing guard caught that too.

Verified against the real 0.4.19 artifact: exactly one AppImage at top level,
the channel pair in its own directory, update string still resolving to the
fixed tag, and the wayland fallback intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 08:29:58 -07:00

660 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
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