Compare commits

..
2 Commits
Author SHA1 Message Date
jknapp b21a568bf5 Merge pull request 'Install from the lockfile, so CI cannot be broken by someone else's release' (#53) from fix/ci-npm-lockfile into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m42s
Build App / build-windows (push) Successful in 4m53s
Build App / build-linux (push) Successful in 5m0s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 13s
2026-09-03 16:41:15 +00:00
shadowdaoandClaude Opus 5 f41b1d9054 Install from the lockfile, so CI cannot be broken by someone else's release
Secret Scan / scan (push) Successful in 4s
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 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 5m0s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
`build-linux` fails before `tauri build` runs, on every workflow, at "Install
frontend dependencies":

    npm error Cannot read properties of null (reading 'edgesOut')

Reproduced exactly on the first attempt by running the step's own commands
locally on the same Node 22.23.2 the runner installs. The debug log gives the
frame the CI output omits:

    at #loadPeerSet (.../@npmcli/arborist/lib/arborist/build-ideal-tree.js:1289:38)

It is a null dereference in npm 10.9.8's peer-set resolver, reached through
vite → @vitejs/devtools → @vitejs/devtools-vitest → vitest@* →
@vitest/browser-playwright → vitest@4.1.11 → jsdom@* → canvas.

**Nothing in this repo changed to cause it.** The step deleted
`package-lock.json` before installing, so every build re-resolved the entire
tree against the registry against ranges like `vitest@*`. A dependency
published a version that produces a peer graph npm cannot resolve, and our CI
broke — the same command succeeded fifteen hours earlier for 0.4.21. That is
the real defect: the build was never reproducible, and the crash is only how we
found out.

So Linux installs with `npm ci`, from the committed lockfile, like Windows
already did. macOS moves too — it kept the lockfile but still ran `npm
install`, which is free to re-resolve; all three platforms now install
identically and none can re-resolve mid-release.

**The reason the lockfile was being deleted is obsolete, not ignored.** 2d4fce9
removed it "to ensure correct platform-specific bindings", which was a real
problem once. The committed lockfile now records 25 rollup platform variants,
and `npm ci` on Linux installs precisely rollup-linux-x64-{gnu,musl} and
@esbuild/linux-x64 — checked directly. A comment on the step says so, and says
not to reach for deleting the lockfile again: if `npm ci` refuses, package.json
and the lockfile have genuinely diverged and the fix is to commit an updated
lockfile.

Verified from the resulting tree: `tsc --noEmit` clean, `npm run build`
successful, 752 tests across 62 files passing. The `npx tauri --version ||
npm install @tauri-apps/cli` fallback in the next step cannot reintroduce a
fresh resolution — the CLI is a pinned devDependency that `npm ci` installs, so
the fallback is unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 09:28:09 -07:00
4 changed files with 117 additions and 300 deletions
+33 -4
View File
@@ -299,8 +299,34 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
# `npm ci` — from the lockfile, never resolving afresh.
#
# This used to be `rm -rf node_modules package-lock.json && npm
# install`, which deleted the lockfile "to ensure correct
# platform-specific bindings" (2d4fce9). That made every build
# re-resolve the whole tree against the registry, so a dependency
# publishing a new version could break CI with no change to this
# repo — and one did. Deleting the lockfile then hit a null
# dereference in npm 10.9.8's arborist peer-set resolver:
#
# npm error Cannot read properties of null (reading 'edgesOut')
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
#
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
# Reproduced exactly by removing the lockfile locally on the same
# Node 22.23.2 the runner installs.
#
# The binding worry is obsolete: the committed lockfile records 25
# rollup platform variants, and `npm ci` on Linux installs precisely
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
# with a clean tsc, a successful build and 752 passing tests from the
# resulting tree.
#
# Do not "fix" a future dependency error by deleting the lockfile
# again. If `npm ci` refuses, package.json and the lockfile have
# genuinely diverged, and the fix is to commit an updated lockfile.
npm ci
- name: Install Tauri CLI
working-directory: ./app
@@ -335,6 +361,7 @@ jobs:
run: |
mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file.
@@ -426,8 +453,10 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
# `npm ci` here too, so all three platforms install identically and
# none of them can re-resolve the tree mid-release. Windows already
# did. See the Linux job for what a fresh resolution cost us.
npm ci
- name: Install Tauri CLI
working-directory: ./app
+34 -23
View File
@@ -172,8 +172,34 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
# `npm ci` — from the lockfile, never resolving afresh.
#
# This used to be `rm -rf node_modules package-lock.json && npm
# install`, which deleted the lockfile "to ensure correct
# platform-specific bindings" (2d4fce9). That made every build
# re-resolve the whole tree against the registry, so a dependency
# publishing a new version could break CI with no change to this
# repo — and one did. Deleting the lockfile then hit a null
# dereference in npm 10.9.8's arborist peer-set resolver:
#
# npm error Cannot read properties of null (reading 'edgesOut')
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
#
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
# Reproduced exactly by removing the lockfile locally on the same
# Node 22.23.2 the runner installs.
#
# The binding worry is obsolete: the committed lockfile records 25
# rollup platform variants, and `npm ci` on Linux installs precisely
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
# with a clean tsc, a successful build and 752 passing tests from the
# resulting tree.
#
# Do not "fix" a future dependency error by deleting the lockfile
# again. If `npm ci` refuses, package.json and the lockfile have
# genuinely diverged, and the fix is to commit an updated lockfile.
npm ci
- name: Install Tauri CLI
working-directory: ./app
@@ -200,23 +226,10 @@ jobs:
- 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
cp app/src-tauri/target/release/bundle/appimage/*.zsync 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:
@@ -299,11 +312,7 @@ jobs:
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
run: bash scripts/publish-update-channel.sh artifacts
build-macos:
runs-on: macos-latest
@@ -360,8 +369,10 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
# `npm ci` here too, so all three platforms install identically and
# none of them can re-resolve the tree mid-release. Windows already
# did. See the Linux job for what a fresh resolution cost us.
npm ci
- name: Install Tauri CLI
working-directory: ./app
+31 -91
View File
@@ -91,11 +91,6 @@ HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
APP_ID="com.triple-c.desktop"
# The channel pair lives in its own directory. Left beside the versioned image
# they are picked up by the release job's `*.AppImage` glob, and every release
# then carries an eighty-megabyte byte-identical duplicate under a second name
# — which is exactly as confusing on a downloads page as it sounds.
CHANNEL_DIR="update-channel"
STABLE_NAME="Triple-C_x86_64.AppImage"
UPDATE_TAG="linux-latest"
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
@@ -103,13 +98,8 @@ CATEGORIES="Development;Utility;"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
# appimagetool looks for `<desktop basename>.appdata.xml` and warns the
# metadata is missing under any other name — while the script cheerfully
# reported it present. The AppStream id inside the file is unchanged and is
# what actually identifies the component; only the filename follows the tool.
appdata_installed_as="Triple-C.appdata.xml"
dir="${1:?usage: finalize-appimage.sh <bundle/appimage directory>}"
dir="${1:?usage: unbundle-wayland-client.sh <bundle/appimage directory>}"
cd "$dir"
shopt -s nullglob
@@ -119,14 +109,6 @@ if [ ${#images[@]} -eq 0 ]; then
echo "No .AppImage in $dir — nothing to do." >&2
exit 0
fi
# Refused here rather than after the repack: with two present the old position
# let the script download appimagetool, repack, overwrite the versioned
# artifact and write the channel pair, *then* fail — and it silently picked
# images[0], which is glob order, i.e. the older version.
if [ ${#images[@]} -ne 1 ]; then
echo "Expected 1 AppImage in $dir, found ${#images[@]}: ${images[*]}" >&2
exit 1
fi
appimage="${images[0]}"
here="$PWD"
@@ -138,15 +120,15 @@ echo "Inspecting $appimage"
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
root="$work/squashfs-root"
# The demotion and the metadata are independent jobs, and an absent library
# must not skip the second. An early exit here also left `update-channel/`
# uncreated, which killed the publish step on a missing directory and took the
# tag and mirror jobs down with it — a half-published release.
demoted=false
if [ -e "$root/usr/lib/$LIB" ]; then
if [ ! -e "$root/usr/lib/$LIB" ]; then
# Not a failure: linuxdeploy may have stopped bundling it, which is the
# outcome this script exists to produce.
echo "$LIB is not bundled — leaving $appimage alone."
exit 0
fi
mkdir -p "$root/$FALLBACK_DIR"
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
mkdir -p "$root/$FALLBACK_DIR"
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
cat > "$root/$HOOK" <<'HOOK_EOF'
#! /usr/bin/env bash
@@ -195,11 +177,6 @@ src = src.replace(
)
open(path, "w").write(src)
PATCH_EOF
fi
demoted=true
echo "Demoted $LIB to $FALLBACK_DIR."
else
echo "$LIB is not bundled — nothing to demote."
fi
# --- metadata -------------------------------------------------------------
@@ -211,29 +188,22 @@ version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
if [ -f "$appdata_src" ]; then
mkdir -p "$root/usr/share/metainfo"
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
"$appdata_src" > "$root/usr/share/metainfo/$appdata_installed_as"
"$appdata_src" > "$root/usr/share/metainfo/$APP_ID.appdata.xml"
echo "Added AppStream metadata for $version."
else
echo "No AppStream source at $appdata_src — skipping." >&2
fi
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
#
# The AppDir root entry is a **symlink** into usr/share/applications, so a
# plain `sed -i` replaces the link with a regular file and leaves the real entry
# untouched — two divergent copies, of which the empty one is the one that
# actually ships and the filled one is the only one a root-only guard can see.
# `--follow-symlinks` writes through. Both locations are globbed because the
# layout is linuxdeploy's, not ours, and it is free to stop symlinking.
for desktop in "$root"/*.desktop "$root"/usr/share/applications/*.desktop; do
for desktop in "$root"/*.desktop; do
[ -e "$desktop" ] || continue
if grep -q "^Categories=$" "$desktop"; then
sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
echo "Filled in Categories for ${desktop#"$root"/}."
sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
echo "Filled in Categories for $(basename "$desktop")."
fi
done
echo "Repacking."
echo "Demoted $LIB to $FALLBACK_DIR; repacking."
tool="$work/appimagetool"
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
@@ -241,19 +211,13 @@ chmod +x "$tool"
# --appimage-extract-and-run: CI runners generally have no FUSE.
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
rm -rf "$CHANNEL_DIR"
mkdir -p "$CHANNEL_DIR"
ARCH=x86_64 "$tool" --appimage-extract-and-run \
-u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null
chmod +x "$CHANNEL_DIR/$STABLE_NAME"
-u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null
chmod +x "$STABLE_NAME"
# The versioned name is what the per-version release publishes; the stable one
# and its .zsync go to the rolling tag. Same bytes, two names, two places.
# 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.
[ -e "$STABLE_NAME.zsync" ] && mv "$STABLE_NAME.zsync" "$CHANNEL_DIR/"
cp "$CHANNEL_DIR/$STABLE_NAME" "$appimage"
# and its .zsync go to the rolling tag. Same bytes, two names.
cp "$STABLE_NAME" "$appimage"
chmod +x "$appimage"
# The guards are the test. Each one is a way the repack could look like it
@@ -263,28 +227,16 @@ out="$check/squashfs-root"
fail() { echo "FAILED: $1" >&2; exit 1; }
if [ "$demoted" = true ]; then
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
fi
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
# An empty Categories or missing metadata ships an image a manager cannot file
# or describe, and both fail silently at runtime rather than at build time.
# Asserted positively, over every entry: the earlier form checked only that no
# *root* file held an empty value, which passed while the real entry under
# usr/share/applications shipped empty, and also passed on a missing key.
desktops=0
for desktop in "$out"/*.desktop "$out"/usr/share/applications/*.desktop; do
[ -e "$desktop" ] || continue
desktops=$((desktops + 1))
grep -q "^Categories=$CATEGORIES$" "$desktop" \
|| fail "${desktop#"$out"/} does not carry Categories=$CATEGORIES."
done
[ "$desktops" -gt 0 ] || fail "the image contains no .desktop entry at all."
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$appdata_installed_as" ] \
grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty."
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \
|| fail "AppStream metadata did not make it into the image."; }
# The update string is the difference between adoptable and updatable. It
@@ -293,25 +245,13 @@ done
# the URL it fetched the .zsync from. That is exactly why the output is named
# for the fixed tag: a versioned name here resolves to the build the client
# already has.
[ -e "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing."
[ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync."
[ -e "$STABLE_NAME" ] || fail "the stable-named image is missing."
[ -e "$STABLE_NAME.zsync" ] || fail "appimagetool wrote no $STABLE_NAME.zsync."
readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -qF "$UPDATE_INFO" \
|| fail "the image does not carry exactly the expected update information."
grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \
readelf -p .upd_info "$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \
|| fail "the image carries no update information for the $UPDATE_TAG tag."
grep -aq "^Filename: $STABLE_NAME$" "$STABLE_NAME.zsync" \
|| fail "the .zsync names something other than $STABLE_NAME."
# The versioned release must carry one AppImage, not two. This is the guard
# for the duplicate that shipped in 0.4.20 and 0.4.21.
shopt -s nullglob
beside=(*.AppImage)
shopt -u nullglob
[ "${#beside[@]}" -eq 1 ] \
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
if [ "$demoted" = true ]; then
echo "OK: $appimage prefers the host $LIB (fallback kept) and carries"
else
echo "OK: $appimage had no bundled $LIB to demote, and carries"
fi
echo " AppStream metadata. Channel pair in $CHANNEL_DIR/, updating from $UPDATE_TAG."
echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream"
echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync."
+19 -182
View File
@@ -17,22 +17,7 @@
# It writes to GitHub rather than Gitea because that mirror is where updates
# are pulled from. Needs GH_PAT with contents write on the mirror.
#
# **The tag has to exist in Gitea, not just on GitHub, and that is the whole
# reason this script touches Gitea at all.** Gitea push-mirrors this repo to
# GitHub, and a mirror push deletes remote refs that have no local counterpart.
# A tag created only by GitHub's release API therefore survives until the next
# mirror run and then vanishes — which is exactly what happened to 0.4.20 and
# 0.4.21: the release was created and both URLs verified 200 at 00:38, and the
# 13:04 mirror deleted the tag, leaving every installed copy checking a 404.
# Versioned tags never had this problem because `create-tag` creates them in
# Gitea first. So does this one, now, and before the GitHub release rather than
# after, so there is no window where the two disagree.
#
# Note what this means for verification: publishing correctly is not evidence
# the channel still works hours later. The Gitea tag is what makes it durable,
# so its absence is treated as a failure rather than a warning.
#
# Usage: GH_PAT=... GITEA_TOKEN=... GITEA_SHA=... publish-update-channel.sh <dir>
# Usage: GH_PAT=... publish-update-channel.sh <directory holding the artifacts>
set -euo pipefail
@@ -41,12 +26,7 @@ TAG="linux-latest"
API="https://api.github.com/repos/$REPO"
ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync")
GITEA_API="${GITEA_API:-https://repo.anhonesthost.net/api/v1}"
GITEA_REPO="${GITEA_REPO:-CyberCoveLLC/Triple-C}"
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
: "${GITEA_TOKEN:?GITEA_TOKEN is required to anchor the $TAG tag against the mirror}"
: "${GITEA_SHA:?GITEA_SHA is required to point the $TAG tag at this build}"
dir="${1:?usage: publish-update-channel.sh <artifacts directory>}"
cd "$dir"
@@ -55,179 +35,46 @@ for asset in "${ASSETS[@]}"; do
done
gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; }
tea() { curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; }
# Status, not a boolean. `curl -sf` fails identically for "404, the tag is
# genuinely absent" and "503, Gitea is briefly unreachable", and treating the
# second as the first means POSTing over a tag that already exists, taking a
# 409, and aborting the last step of build-linux — which `create-tag` and
# `sync-to-github` both depend on. A transient blip would cost the release, not
# just the channel update. Same `case`-on-code idiom as `Upload to Gitea
# release` two steps above in the workflow. A refused connection reports 000
# and lands in the catch-all.
tea_code() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@"; }
# Anchor the tag in Gitea — see the header. **Created if absent, never moved.**
#
# An earlier version deleted and recreated it so the tag would name the current
# build. That was worse than useless: nothing about the channel depends on
# which commit the tag points at — the update string resolves the tag by *name*
# and the assets hang off the release object — while a DELETE followed by a
# failed POST destroys a working anchor and leaves a window in which a mirror
# run prunes GitHub's copy. A transient Gitea error would have converted a
# healthy channel into a dead one, which is strictly worse than this step not
# existing. Gitea's POST /tags has no force semantics, so the DELETE was only
# ever there to get around a 409; asking first removes the need.
echo "==> Anchoring the $TAG tag in Gitea"
anchor_probe="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
case "$anchor_probe" in
200)
echo " already anchored — left alone"
;;
404)
echo " creating it at ${GITEA_SHA:0:9}"
tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \
-d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \
>/dev/null
;;
*)
echo "FAILED: Gitea answered $anchor_probe asking whether the $TAG tag exists." >&2
echo " Refusing to guess — creating it blindly would 409 over an" >&2
echo " existing tag and abort the release." >&2
exit 1
;;
esac
# Not best-effort. Without this tag the mirror removes GitHub's and the
# channel dies silently somewhere between now and four hours from now. Reported
# by code, so "Gitea was unreachable" cannot masquerade as "the tag is gone".
anchor_code="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
[ "$anchor_code" = "200" ] || {
echo "FAILED: the $TAG tag is not readable in Gitea (HTTP $anchor_code);" >&2
echo " without it the mirror would delete GitHub's copy." >&2
exit 1
}
# Look through the authenticated list rather than /releases/tags/, which never
# returns drafts. That matters here specifically: GitHub demotes a published
# release to a draft when its tag is deleted, which is the state every mirror
# run left behind, so the by-tag lookup reports "absent" while orphaned drafts
# sit there holding 86 MB each. Reuse the newest and delete the rest, or they
# accumulate one per release forever.
echo "==> Looking for the $TAG release (drafts included)"
all_releases="$(gh "$API/releases?per_page=100")"
mapfile -t existing < <(printf '%s' "$all_releases" | python3 -c '
import sys, json
tag = sys.argv[1]
rs = [r for r in json.load(sys.stdin) if r.get("tag_name") == tag]
rs.sort(key=lambda r: r.get("created_at",""), reverse=True)
for r in rs:
print(r["id"])
' "$TAG")
release_id="${existing[0]:-}"
for stale in "${existing[@]:1}"; do
echo " deleting orphaned duplicate release $stale"
gh -X DELETE "$API/releases/$stale" >/dev/null || true
done
if [ -n "$release_id" ]; then
# A draft has no tag and serves no download URL, so it has to be republished.
echo " reusing release $release_id"
# `make_latest` is not optional here even though this release already exists.
# Publishing a draft is a publish transition, where the API's documented
# default is `true` — so omitting it would quietly promote this channel to
# the repository's "Latest release" and bury the versioned release a person
# actually wants from the releases page.
#
# `tag_name` is re-sent deliberately, and must be: the API removes the tag
# when a PATCH omits it. Given this whole change exists because a tag
# disappeared, that is an expensive line to tidy away.
gh -X PATCH "$API/releases/$release_id" \
-d "{\"tag_name\": \"$TAG\", \"draft\": false, \"make_latest\": \"false\"}" >/dev/null
release="$(gh "$API/releases/$release_id")"
fi
echo "==> Looking for the $TAG release"
release="$(gh "$API/releases/tags/$TAG" 2>/dev/null || true)"
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)"
if [ -z "$release_id" ]; then
echo "==> Creating it"
# Not a prerelease, but deliberately not the "latest" release either: this
# tag is a channel, and it must never displace the versioned release a
# person lands on from the releases page.
body_json="$(python3 -c '
release="$(gh -X POST "$API/releases" -d "$(python3 -c '
import json
print(json.dumps({
"tag_name": "'"$TAG"'",
"name": "Linux update channel",
"body": "Rolling AppImage build that Triple-C\u2019s in-app updater reads. "
"body": "Rolling AppImage build that Triple-Cs in-app updater reads. "
"The two files here are replaced on every release; for a specific "
"version, use the versioned releases instead.",
"draft": False,
"prerelease": False,
"make_latest": "false",
}))')"
# `already_exists` is a benign, recoverable answer, not a reason to abort the
# last step of build-linux and lose the release with it. It means a release
# for this tag exists but the listing above did not show it — a draft that has
# sunk past the first page, since a draft's created_at is frozen while newer
# releases push it down. Re-ask by tag and carry on.
create_body="$(mktemp)"
create_code="$(curl -s -o "$create_body" -w '%{http_code}' \
-H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" \
-X POST "$API/releases" -d "$body_json")"
case "$create_code" in
201)
release="$(cat "$create_body")"
;;
422)
if grep -q "already_exists" "$create_body"; then
echo " a release for $TAG already exists but was not listed — reusing it"
release="$(gh "$API/releases/tags/$TAG")"
else
echo "FAILED: GitHub rejected the release (422):" >&2
cat "$create_body" >&2
rm -f "$create_body"
exit 1
fi
;;
*)
echo "FAILED: creating the $TAG release returned $create_code:" >&2
cat "$create_body" >&2
rm -f "$create_body"
exit 1
;;
esac
rm -f "$create_body"
}))')")"
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
fi
# One asset at a time, delete immediately followed by upload. Deleting both up
# front leaves the channel holding a fresh AppImage and no .zsync if the second
# upload fails, and a client that cannot fetch the .zsync simply stops updating
# — no error anyone here would see.
asset_ids="$(printf '%s' "$release" | python3 -c '
echo "==> Removing superseded assets from release $release_id"
printf '%s' "$release" | python3 -c '
import sys, json
keep = set(sys.argv[1:])
out = {}
for a in json.load(sys.stdin).get("assets", []):
if a["name"] in keep:
out[a["name"]] = a["id"]
print(json.dumps(out))
' "${ASSETS[@]}")"
print(a["id"])
' "${ASSETS[@]}" | while read -r asset_id; do
[ -n "$asset_id" ] || continue
gh -X DELETE "$API/releases/assets/$asset_id" >/dev/null || true
done
# --retry/--max-time/--http1.1 for the reason the Gitea upload steps in this
# repo carry them: real mid-stream failures on large assets (curl 92 and 28).
for asset in "${ASSETS[@]}"; do
stale_id="$(printf '%s' "$asset_ids" | python3 -c 'import sys,json;print(json.load(sys.stdin).get(sys.argv[1],""))' "$asset")"
if [ -n "$stale_id" ]; then
echo "==> Replacing $asset (dropping superseded asset $stale_id)"
gh -X DELETE "$API/releases/assets/$stale_id" >/dev/null || true
fi
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
curl -sf --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \
-X POST \
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \
@@ -237,22 +84,12 @@ done
# The updater is only as good as this URL, and a silent failure here means
# every installed copy quietly stops updating. Confirm both are actually
# fetchable at the address the AppImage was built to check.
# Size as well as status: a 200 only proves something is served at the
# address, not that it is this build. GitHub accepting a truncated upload
# would pass a status-only check and then fail every client's checksum.
echo "==> Verifying the published URLs"
for asset in "${ASSETS[@]}"; do
url="https://github.com/$REPO/releases/download/$TAG/$asset"
local_size="$(stat -c %s "$asset")"
headers="$(curl -sIL "$url" | tr -d '\r')"
code="$(printf '%s\n' "$headers" | awk '/^HTTP\//{c=$2} END{print c}')"
served="$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:"{n=$2} END{print n}')"
[ "$code" = "200" ] || { echo "FAILED: $url returned ${code:-no status}" >&2; exit 1; }
[ "$served" = "$local_size" ] \
|| { echo "FAILED: $url serves ${served:-unknown} bytes, built $local_size." >&2; exit 1; }
echo " $code $served bytes $url"
code="$(curl -s -o /dev/null -w '%{http_code}' -L "$url")"
[ "$code" = "200" ] || { echo "FAILED: $url returned $code" >&2; exit 1; }
echo " $code $url"
done
echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it."
echo "OK: $TAG updated."