Secret Scan / scan (push) Successful in 4s
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 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / prune-previews (pull_request) Skipped
An adversarial review of the previous commit found six real problems and corrected one of my claims. Taking all of it. **The anchoring could kill the channel it exists to protect.** It did DELETE-then-POST so the tag would name the current build. If the POST failed for any transient reason the script aborted having already deleted the anchor a previous run put there, and the next mirror run pruned GitHub's copy — a transient Gitea error converting a healthy channel into a dead one, which is strictly worse than the step not existing. There was also a real window between the two calls with no tag at all. The DELETE bought nothing. The update string resolves the tag by *name* and the assets hang off the release object, so nothing about the channel depends on which commit the tag points at; moving it changes only the source-zip link. It existed solely to get past a 409, since Gitea's POST /tags has no force semantics. Now the tag is created if absent and otherwise left alone, which removes the window too. **My "no window where the two disagree" claim was wrong, and it is the third time in this area I have asserted something I had not established.** The release POST sets no `target_commitish`, so GitHub creates its tag at its own default-branch HEAD, not at `GITEA_SHA`; the two agree only because `sync_on_commit` pushes main minutes earlier. And the DELETE actively created the window. What the ordering genuinely buys is narrower: if anchoring fails, the script aborts before creating a GitHub release that would be orphaned. **Orphaned drafts were invisible to the release lookup.** GitHub demotes a release to a draft when its tag is deleted, and `/releases/tags/` never returns drafts — precisely the state every mirror run left behind. The by-tag lookup reported "absent" while 86 MB drafts accumulated, one per release. The lookup now reads the authenticated list, republishes the newest, and deletes the rest. **A guard that could not catch what it named.** The update-info assertion was a substring match on the tag, so it passed for a wrong host, path, filename or transport — verified: an `evil.example.com/.../linux-latest/...` string passes the old check and fails the new one. Now a fixed full-string match. Also from the review: an absent bundled library no longer exits early, because that skipped the metadata *and* left `update-channel/` uncreated, killing the publish step on a missing directory and taking the tag and mirror jobs with it; the Categories guard asserts the absence of an empty value rather than the presence of any filled one; the channel directory is cleared before use so a stale zsync cannot satisfy an existence check while describing the previous build; the AppImage count uses a glob array, since `ls | wc -l` aborted under pipefail before the message it promised could print; uploads carry the retry/http1.1 hardening this repo's other upload steps already learned to need; verification compares served size against built size, because a status code only proves something is served; and the release workflow now fails on empty artifacts instead of publishing a release with no AppImage. The metainfo file is installed as `Triple-C.appdata.xml`. appimagetool derives the name it looks for from the .desktop basename, so under the id-based name it warned the metadata was missing on every build while this script reported it present. Now it prints "AppStream upstream metadata found in usr/share/metainfo/Triple-C.appdata.xml" — the AppStream id inside the file is unchanged and is what identifies the component. Two review hypotheses did not hold and nothing was changed for them: `set -e` does not abort on a failing `&&` list mid-script, and my claim of a `trap` reassignment was wrong — there is one trap, installed once. Verified against the real 0.4.19 artifact: exit 0, one AppImage beside the release, channel pair in its own directory, appimagetool reporting the metadata found, and the wayland fallback intact. Guards exercised individually — the duplicate one bites, the exact-match one rejects an impostor carrying the tag, the empty directory reports cleanly, and all four publisher preconditions refuse rather than half-publishing. Header parsing for the size check was tested against a real redirecting GitHub asset URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
813 lines
34 KiB
YAML
813 lines
34 KiB
YAML
name: Build App
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- "app/**"
|
|
- "VERSION"
|
|
- ".gitea/workflows/build-app.yml"
|
|
workflow_dispatch:
|
|
|
|
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
|
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
|
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
|
# the same three and publishes them. Six OS builds per push, one set of which
|
|
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
|
|
|
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}"
|
|
|
|
# The patch number is **one past the highest patch already used**, and
|
|
# never a distance.
|
|
#
|
|
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
|
|
# not a counter at all: it measures how far HEAD has drifted from
|
|
# whichever tag sorts highest, and that resets to zero every time a
|
|
# tag is cut. The published history is the proof — each of these is
|
|
# exactly what the old formula returned at the time:
|
|
#
|
|
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
|
|
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
|
|
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
|
|
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
|
|
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
|
|
#
|
|
# Reusing a version is worse than failing to publish one: the macOS
|
|
# and Windows steps replace assets in place, so a duplicate silently
|
|
# rewrote a release that had been public for three days. Monotonic
|
|
# numbering is what stops that at the source.
|
|
#
|
|
# Suffixed tags count too. `create-tag` is skipped when any platform
|
|
# job fails, so a run can publish v0.4.7-mac and never create the
|
|
# plain v0.4.7 — reading only unsuffixed tags would then hand the
|
|
# same number out twice.
|
|
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)
|
|
|
|
# A re-run of a commit that already released must not mint a new
|
|
# version just because its own tag now exists.
|
|
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} — reusing it"
|
|
PATCH="${EXISTING}"
|
|
elif [ -n "$HIGHEST" ]; then
|
|
echo "Highest patch already used on this line: ${HIGHEST}"
|
|
PATCH=$((HIGHEST + 1))
|
|
else
|
|
# A minor line nobody has tagged yet is a *new* line, and a new line
|
|
# starts at .0 — that is what "we are moving to 0.4.x" means. The
|
|
# old fallback here counted every commit in the repository, which
|
|
# would have made the first 0.4 build 0.4.234.
|
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
|
PATCH=0
|
|
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"
|
|
# 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
|
|
# The versioned AppImage only. The update channel's copy lives in
|
|
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
|
# it up and publish an 80 MB duplicate under a second name.
|
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
|
ls -la artifacts/
|
|
|
|
# A green job that published nothing is the worst outcome available:
|
|
# the release exists, carries no AppImage, and nobody is told. The
|
|
# `|| true` above is there so a missing bundle does not mask the real
|
|
# error, which makes this check the thing that catches it.
|
|
shopt -s nullglob
|
|
collected=(artifacts/*)
|
|
if [ ${#collected[@]} -eq 0 ]; then
|
|
echo "No artifacts collected — the bundler produced nothing." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- 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 }}"
|
|
|
|
# Idempotent get-or-create, matching build-macos. This step used to
|
|
# POST /releases unconditionally: against a tag that already existed
|
|
# Gitea answered 409, the grep below found no id, and the run died
|
|
# with a bare "exitcode '1'" and not one line of output explaining
|
|
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
|
|
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
|
|
# pipefail so a failure cannot be stepped over.
|
|
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}"
|
|
curl -fsS -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
|
|
;;
|
|
*)
|
|
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
|
|
cat release.json >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
|
|
if [ -z "${RELEASE_ID}" ]; then
|
|
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
|
|
cat release.json >&2
|
|
exit 1
|
|
fi
|
|
echo "Release ID: ${RELEASE_ID}"
|
|
|
|
# Replace-not-conflict, so a retry after a partial upload succeeds.
|
|
# Versions are monotonic now (see compute-version), so this can only
|
|
# ever be replacing an asset from a failed run of this same commit —
|
|
# never one belonging to an already-published version.
|
|
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
|
|
|
|
# The fixed tag every installed AppImage checks for updates. Separate
|
|
# from the versioned release above because the updater's URL must never
|
|
# move, and `releases/latest` does.
|
|
- name: Publish the Linux update channel
|
|
if: gitea.event_name == 'push'
|
|
env:
|
|
GH_PAT: ${{ secrets.GH_PAT }}
|
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
GITEA_SHA: ${{ gitea.sha }}
|
|
run: |
|
|
bash scripts/publish-update-channel.sh \
|
|
app/src-tauri/target/release/bundle/appimage/update-channel
|
|
|
|
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 MSVC C++ build tools
|
|
shell: cmd
|
|
run: |
|
|
rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK.
|
|
rem This job previously assumed a hand-provisioned runner; a runner
|
|
rem without them registers fine, advertises windows-latest, accepts the
|
|
rem job, downloads the whole crate graph and only then fails at link
|
|
rem time with "linker `link.exe` not found".
|
|
rem
|
|
rem rustc finds MSVC via vswhere and the registry rather than PATH, so
|
|
rem installing is enough - no dev-shell activation needed here.
|
|
rem
|
|
rem Delayed expansion is required: %VAR% inside a parenthesised block
|
|
rem is substituted when the block is PARSED, not when it runs, so both
|
|
rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values.
|
|
setlocal enabledelayedexpansion
|
|
set "VCPATH="
|
|
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
|
if exist "%VSWHERE%" (
|
|
for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i"
|
|
)
|
|
if defined VCPATH (
|
|
echo MSVC build tools already present at !VCPATH!
|
|
) else (
|
|
echo MSVC build tools not found - installing Visual Studio Build Tools
|
|
curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1
|
|
"%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended
|
|
set "VSEXIT=!ERRORLEVEL!"
|
|
del "%TEMP%\vs_BuildTools.exe" 2>nul
|
|
rem 3010 means installed, reboot pending - a success for our purposes.
|
|
if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" (
|
|
echo Visual Studio Build Tools installer failed with exit code !VSEXIT!
|
|
exit /b 1
|
|
)
|
|
echo Visual Studio Build Tools installed
|
|
)
|
|
endlocal
|
|
|
|
- name: Work around WOW64 redirection for 32-bit bundlers
|
|
shell: cmd
|
|
run: |
|
|
rem Tauri downloads its bundlers - candle.exe, light.exe and
|
|
rem makensis.exe - and every one of them is 32-bit. When the runner
|
|
rem runs as SYSTEM its %LOCALAPPDATA% is under
|
|
rem C:\Windows\System32\config\systemprofile, and WOW64 redirection
|
|
rem serves any 32-bit process reading System32 from SysWOW64 instead -
|
|
rem where those directories do not exist. The bundlers then cannot see
|
|
rem their own folder: candle exits 0x80131700 and makensis reports
|
|
rem "Unable to start child process, error 0x2". Tauri surfaces neither,
|
|
rem only "failed to run candle.exe", which is why this is worth a
|
|
rem comment this long.
|
|
rem
|
|
rem Junctioning the SysWOW64 view onto the System32 originals makes the
|
|
rem redirected path resolve to the same files. A runner running as a
|
|
rem normal user has a profile outside System32 and skips all of this.
|
|
echo.%LOCALAPPDATA%| find /I "\system32\" >nul
|
|
if errorlevel 1 goto skipwow
|
|
|
|
if not exist "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri" mkdir "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local" mkdir "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local"
|
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
|
|
|
|
if not exist "%WINDIR%\System32\config\systemprofile\.cache" mkdir "%WINDIR%\System32\config\systemprofile\.cache"
|
|
if not exist "%WINDIR%\SysWOW64\config\systemprofile\.cache" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\.cache" "%WINDIR%\System32\config\systemprofile\.cache"
|
|
|
|
echo WOW64 junctions in place for the SYSTEM profile
|
|
goto :eof
|
|
|
|
:skipwow
|
|
echo Runner profile is outside System32 - WOW64 junctions not needed
|
|
|
|
- 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%"
|
|
rem Every Tauri bundler it downloads - candle.exe, light.exe and
|
|
rem makensis.exe - is 32-bit. A runner running as SYSTEM has
|
|
rem %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, and
|
|
rem WOW64 redirection sends 32-bit processes reading System32 to
|
|
rem SysWOW64, so they cannot see their own directory: candle exits
|
|
rem 0x80131700 and makensis reports "Unable to start child process,
|
|
rem error 0x2".
|
|
rem
|
|
rem The build VM carries junctions from the SysWOW64 view of
|
|
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
|
|
rem System32 originals, which makes the redirected view resolve. A
|
|
rem runner running as a normal user needs no such patch.
|
|
cargo tauri build --bundles msi,nsis
|
|
|
|
- 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\ || exit /b 1
|
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1
|
|
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."
|