Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m26s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
build-app-preview.yml computed its patch number as `git rev-list --count <latest tag>..HEAD` — the exact formula build-app.yml itself documents as broken and replaced (#26): a distance from whichever tag sorts highest, not a counter, so it resets to zero on every release and previews went backwards (0.4.62 -> 0.4.0) the moment one landed. Ported the same "one past the highest patch already used" computation build-app.yml uses for real releases, reading the same tags (including -mac/-win suffixes), so a preview built right before a release now computes the exact number that release is about to take — semver already orders `0.4.12-preview.<sha> < 0.4.12`, so a preview user is offered the release the moment it ships instead of being silently pinned forever. The installed preview's reported version was also indistinguishable from production: the bundle's own version field strips the `-preview.<sha>` suffix before touching tauri.conf.json/Cargo.toml/package.json, since the Windows MSI's ProductVersion has no room for one. Rather than risk that (unverifiable without an actual Windows build), preview builds now bake the suffix into the binary separately via a TRIPLE_C_BUILD_SUFFIX build-time env var, and get_app_version() appends it when present — a production build sets nothing, so this is a no-op there. Also: added `prerelease` to `GitHubRelease` and filter on it in check_for_updates (currently a no-op against real data — nothing mirrored to GitHub is ever prerelease:true — but the updater is no longer structurally incapable of enforcing a channel split if one is ever made explicit). And deleted sync-release.yml: workflow_dispatch-only, reading gitea.event.release.* fields a manual dispatch never populates, so it could never have actually run; build-app.yml's inline mirror already does the same job. Refactored check_for_updates' filtering into a pure, testable pick_update helper (this file had no tests before), and added tests for it and the new get_app_version suffix handling. Fixes #32. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
625 lines
26 KiB
YAML
625 lines
26 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.
|
|
#
|
|
# Nothing here reaches GitHub: `build-app.yml` is the only workflow that
|
|
# mirrors a release there, and it does so inline, gated on `prerelease: false`
|
|
# — a preview release is never a candidate. (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 its job. 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 — so semver already
|
|
# orders `0.4.12-preview.<sha> < 0.4.12`, and a preview user is
|
|
# offered the real release the moment it ships. 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)
|
|
|
|
if [ -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"
|
|
npx tauri build
|
|
|
|
- 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/deb/*.deb artifacts/ 2>/dev/null || true
|
|
cp app/src-tauri/target/release/bundle/rpm/*.rpm 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
|