Files
Triple-C/.gitea/workflows/build-app.yml
shadow-test 997e1ab3a9
Build App / compute-version (pull_request) Successful in 9s
Build App / build-macos (pull_request) Successful in 2m36s
Build App / build-windows (pull_request) Successful in 2m50s
Build App / build-linux (pull_request) Successful in 6m26s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Fix Windows release upload: idempotent get-or-create + fail-loud
The cmd-batch upload step POSTed to /releases unconditionally. On a
re-run the v{VERSION}-win tag already exists, so Gitea returns 409, the
findstr id parse yields an empty RELEASE_ID, and uploads go to a
malformed .../releases//assets URL -- all silently swallowed by cmd and
`curl -s`, so the step reported success while attaching no assets.

Rewrite in PowerShell mirroring the macOS job: look the release up by
tag first and create only on 404, throw if the id can't be resolved,
delete same-named assets left over from partial runs before re-upload,
and fail loudly (ErrorActionPreference=Stop, curl.exe -fsS with retries,
$LASTEXITCODE check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:22:55 -07:00

607 lines
23 KiB
YAML

name: Build App
on:
push:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
pull_request:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
workflow_dispatch:
env:
GITEA_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }}
jobs:
compute-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch all tags
run: git fetch --tags
- name: Compute version from VERSION file and tags
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
# `|| true` so an empty grep result doesn't fail the step under pipefail.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
if [ -n "$LATEST_TAG" ]; then
echo "Latest matching tag: ${LATEST_TAG}"
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
else
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
PATCH=$(git rev-list --count HEAD)
fi
VERSION="${MAJOR_MINOR}.${PATCH}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version]
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
echo "Node.js at: $(which node)"
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${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
echo "Rust already installed: $(rustc --version)"
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
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/
- name: Upload to Gitea release
if: gitea.event_name == 'push'
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="v${{ needs.compute-version.outputs.version }}"
# Create release
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
echo "Release ID: ${RELEASE_ID}"
# Upload each artifact
for file in artifacts/*; do
[ -f "$file" ] || continue
filename=$(basename "$file")
echo "Uploading ${filename}..."
curl -s -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]
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/')
echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..."
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
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: |
VERSION="${{ needs.compute-version.outputs.version }}"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${VERSION}"
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
echo "Rust already installed: $(rustc --version)"
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
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/
- name: Upload to Gitea release
if: gitea.event_name == 'push'
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}-mac"
# Idempotent get-or-create. macOS upload has historically failed
# mid-stream (curl exit 92, exit 28), leaving the release record
# with empty assets. A naive POST /releases on the next run hits
# 409 from Gitea for the duplicate tag, the JSON parse below
# then yields an empty RELEASE_ID, and pipefail aborts with an
# opaque exit 1. Look the release up by tag first; create only
# if it doesn't exist; reuse the existing id otherwise.
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 "Release ${TAG} not found, creating"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${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}"
# Upload each artifact. If an asset with the same name already
# exists on the release (left over from a partial prior run),
# delete it first so the upload is replace-not-conflict.
# Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
# the macOS runner has hit, retries with backoff for transient
# drops, and -f so HTTP errors stop being silently swallowed.
for file in artifacts/*; do
[ -f "$file" ] || continue
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 "Deleting existing asset ${filename} (id ${EXISTING_ID})"
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]
defaults:
run:
shell: cmd
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
shell: powershell
run: |
$version = "${{ needs.compute-version.outputs.version }}"
(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\":\"\"}}"
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\
- name: Upload to Gitea release
if: gitea.event_name == 'push'
shell: powershell
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
$ErrorActionPreference = "Stop"
$tag = "v$env:VERSION-win"
$headers = @{ Authorization = "token $env:TOKEN" }
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
# Idempotent get-or-create. The old cmd-batch version swallowed
# curl errors and parsed the release id with findstr, so a 409 on
# a pre-existing tag yielded an empty RELEASE_ID and uploads went to
# a malformed .../releases//assets URL while the step still reported
# success. Look the release up by tag first; create only on 404.
try {
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
Write-Host "Release $tag already exists, reusing"
} catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host "Release $tag not found, creating"
$body = @{
tag_name = $tag
name = "Triple-C v$env:VERSION (Windows)"
body = "Automated build from commit $env:COMMIT_SHA"
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers `
-ContentType "application/json" -Body $body -Uri "$api/releases"
} else {
throw
}
}
$releaseId = $release.id
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
Write-Host "Release ID: $releaseId"
# Upload each artifact. Delete any same-named asset left over from a
# partial prior run first, so the upload replaces rather than 409s.
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
$name = $file.Name
$dupe = $existing | Where-Object { $_.name -eq $name }
if ($dupe) {
Write-Host "Deleting existing asset $name (id $($dupe.id))"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
}
Write-Host "Uploading $name..."
$uploadUri = "$api/releases/$releaseId/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)" }
}
create-tag:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create version tag
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
TAG="v${VERSION}"
echo "Creating tag ${TAG}..."
# Create annotated tag via Gitea API
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
echo "Tag ${TAG} created successfully"
sync-to-github:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
steps:
- name: Download artifacts from Gitea releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set -e
mkdir -p artifacts
# Download assets from all 3 platform releases
for TAG_SUFFIX in "" "-mac" "-win"; do
TAG="v${VERSION}${TAG_SUFFIX}"
echo "==> Fetching assets for release ${TAG}..."
RELEASE_JSON=$(curl -sf \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do
[ -z "$NAME" ] && continue
echo " Downloading ${NAME}..."
curl -sfL \
-H "Authorization: token ${TOKEN}" \
-o "artifacts/${NAME}" \
"$URL"
done
done
echo "==> All downloaded artifacts:"
ls -la artifacts/
- name: Create GitHub release and upload artifacts
env:
VERSION: ${{ needs.compute-version.outputs.version }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -e
TAG="v${VERSION}"
echo "==> Creating unified release ${TAG} on GitHub..."
# Delete existing release if present (idempotent re-runs)
EXISTING=$(curl -sf \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
if [ -n "$EXISTING_ID" ]; then
echo " Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..."
curl -sf -X DELETE \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}"
fi
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"Triple-C ${TAG}\",
\"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\",
\"draft\": false,
\"prerelease\": false
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "==> Upload URL: ${UPLOAD_URL}"
for file in artifacts/*; do
[ -f "$file" ] || continue
FILENAME=$(basename "$file")
MIME="application/octet-stream"
echo "==> Uploading ${FILENAME}..."
curl -sf -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: ${MIME}" \
--data-binary "@${file}" \
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")"
done
echo "==> GitHub release sync complete."