From d561ce03d5879b2686f85d67c77afb86c373220b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 3 Sep 2026 08:29:58 -0700 Subject: [PATCH 1/3] Anchor the update channel tag, and stop shipping a duplicate AppImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the update channel, both visible in 0.4.20 and 0.4.21. **The channel tag does not survive.** `publish-update-channel.sh` created the GitHub release, uploaded both assets and verified each URL returned 200 — the job log shows it succeeding at 00:38. By 13:04 the tag was gone and every installed copy was checking a 404. Gitea push-mirrors this repo to GitHub every four hours, and a mirror push deletes remote refs with no local counterpart. `linux-latest` was created by GitHub's release API and never existed as a Gitea tag, so the mirror removed it. Versioned tags were never affected because `create-tag` creates them in Gitea first. So the tag is now anchored in Gitea, and before the GitHub release rather than after, so there is no window where the two disagree. Its absence fails the step instead of warning, because it is the only thing keeping the channel alive. Worth stating plainly: publishing correctly is not evidence the channel still works, and the verification that passed at 00:38 could not have caught a failure that arrives twelve hours later. **Every release carried the AppImage twice.** The channel's stable-named copy sat beside the versioned one, where the release job's `*.AppImage` glob picked it up — so v0.4.21 published `Triple-C_0.4.21_amd64.AppImage` and `Triple-C_x86_64.AppImage`, byte-identical at 86,686,200 bytes each, and `sync-to-github` copied both to the mirror. 80 MB of duplicate per release, under a name that reads like a different build. That is how it was noticed. The channel pair now lives in `bundle/appimage/update-channel/`, out of the glob's reach, and a guard fails the build if more than one AppImage is left beside the release. Verified by planting a second one: it fails. One appimagetool quirk found while moving it — zsyncmake writes the .zsync into the working directory, not beside the image it describes, so it has to be collected rather than assumed in place. The existing guard caught that too. Verified against the real 0.4.19 artifact: exactly one AppImage at top level, the channel pair in its own directory, update string still resolving to the fixed tag, and the wayland fallback intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm --- .gitea/workflows/build-app-preview.yml | 1 - .gitea/workflows/build-app.yml | 10 +++++-- scripts/finalize-appimage.sh | 35 ++++++++++++++++------- scripts/publish-update-channel.sh | 39 ++++++++++++++++++++++++-- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 371d8a5..5c5c432 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -335,7 +335,6 @@ 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. diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 0d4aa02..224b63b 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -200,8 +200,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/ - name: Upload to Gitea release @@ -286,7 +288,11 @@ jobs: if: gitea.event_name == 'push' env: GH_PAT: ${{ secrets.GH_PAT }} - run: bash scripts/publish-update-channel.sh artifacts + 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 diff --git a/scripts/finalize-appimage.sh b/scripts/finalize-appimage.sh index 502bc87..06ea5e9 100755 --- a/scripts/finalize-appimage.sh +++ b/scripts/finalize-appimage.sh @@ -91,6 +91,11 @@ 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" @@ -211,13 +216,18 @@ 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. +mkdir -p "$CHANNEL_DIR" ARCH=x86_64 "$tool" --appimage-extract-and-run \ - -u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null -chmod +x "$STABLE_NAME" + -u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null +chmod +x "$CHANNEL_DIR/$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. -cp "$STABLE_NAME" "$appimage" +# 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" chmod +x "$appimage" # The guards are the test. Each one is a way the repack could look like it @@ -245,13 +255,18 @@ grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty." # 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 "$STABLE_NAME" ] || fail "the stable-named image is missing." -[ -e "$STABLE_NAME.zsync" ] || fail "appimagetool wrote no $STABLE_NAME.zsync." +[ -e "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing." +[ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync." -readelf -p .upd_info "$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \ +readelf -p .upd_info "$CHANNEL_DIR/$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" \ +grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \ || fail "the .zsync names something other than $STABLE_NAME." -echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream" -echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync." +# 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. +count="$(ls -1 *.AppImage 2>/dev/null | wc -l)" +[ "$count" = "1" ] || fail "expected 1 AppImage beside the release, found $count." + +echo "OK: $appimage prefers the host $LIB (fallback kept) and carries AppStream" +echo " metadata. Channel pair in $CHANNEL_DIR/, updating from the $UPDATE_TAG tag." diff --git a/scripts/publish-update-channel.sh b/scripts/publish-update-channel.sh index 8126bcc..2d07298 100755 --- a/scripts/publish-update-channel.sh +++ b/scripts/publish-update-channel.sh @@ -17,7 +17,22 @@ # 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. # -# Usage: GH_PAT=... publish-update-channel.sh +# **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 set -euo pipefail @@ -26,7 +41,12 @@ 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 }" cd "$dir" @@ -35,6 +55,21 @@ 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" "$@"; } + +# Anchor the tag in Gitea first — see the header. Moved rather than left +# alone: it has to name this build, and the mirror will carry whatever Gitea +# holds over the top of GitHub's copy. +echo "==> Anchoring the $TAG tag in Gitea at ${GITEA_SHA:0:9}" +tea -X DELETE "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1 || true +tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \ + -d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \ + >/dev/null + +# Not best-effort. Without this tag the mirror removes GitHub's and the +# channel dies silently somewhere between now and four hours from now. +tea "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1 \ + || { echo "FAILED: the $TAG tag does not exist in Gitea; the mirror would delete GitHub's copy." >&2; exit 1; } echo "==> Looking for the $TAG release" release="$(gh "$API/releases/tags/$TAG" 2>/dev/null || true)" @@ -92,4 +127,4 @@ for asset in "${ASSETS[@]}"; do echo " $code $url" done -echo "OK: $TAG updated." +echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it." -- 2.52.0 From 63f282bef62e8d313b4e2442adcc883cfee1113f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 3 Sep 2026 08:53:13 -0700 Subject: [PATCH 2/3] Fix the review findings: never destroy a working anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm --- .gitea/workflows/build-app.yml | 11 ++++ scripts/finalize-appimage.sh | 58 +++++++++++++-------- scripts/publish-update-channel.sh | 83 +++++++++++++++++++++++++------ 3 files changed, 116 insertions(+), 36 deletions(-) diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 224b63b..c9454d5 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -206,6 +206,17 @@ jobs: 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: diff --git a/scripts/finalize-appimage.sh b/scripts/finalize-appimage.sh index 06ea5e9..2f8630b 100755 --- a/scripts/finalize-appimage.sh +++ b/scripts/finalize-appimage.sh @@ -103,8 +103,13 @@ CATEGORIES="Development;Utility;" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml" +# appimagetool looks for `.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: unbundle-wayland-client.sh }" +dir="${1:?usage: finalize-appimage.sh }" cd "$dir" shopt -s nullglob @@ -125,15 +130,15 @@ echo "Inspecting $appimage" ( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null ) root="$work/squashfs-root" -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 +# 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 -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 @@ -182,6 +187,11 @@ 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 ------------------------------------------------------------- @@ -193,7 +203,7 @@ 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/$APP_ID.appdata.xml" + "$appdata_src" > "$root/usr/share/metainfo/$appdata_installed_as" echo "Added AppStream metadata for $version." else echo "No AppStream source at $appdata_src — skipping." >&2 @@ -208,7 +218,7 @@ for desktop in "$root"/*.desktop; do fi done -echo "Demoted $LIB to $FALLBACK_DIR; repacking." +echo "Repacking." tool="$work/appimagetool" curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" @@ -216,6 +226,7 @@ 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 @@ -237,16 +248,18 @@ out="$check/squashfs-root" fail() { echo "FAILED: $1" >&2; exit 1; } -[ -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." +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 [ -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. -grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty." -[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \ +! grep -q "^Categories=$" "$out"/*.desktop || fail "a desktop file still has an empty Categories." +[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$appdata_installed_as" ] \ || fail "AppStream metadata did not make it into the image."; } # The update string is the difference between adoptable and updatable. It @@ -258,15 +271,18 @@ grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty." [ -e "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing." [ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync." -readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \ - || fail "the image carries no update information for the $UPDATE_TAG tag." +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" \ || 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. -count="$(ls -1 *.AppImage 2>/dev/null | wc -l)" -[ "$count" = "1" ] || fail "expected 1 AppImage beside the release, found $count." +shopt -s nullglob +beside=(*.AppImage) +shopt -u nullglob +[ "${#beside[@]}" -eq 1 ] \ + || fail "expected 1 AppImage beside the release, found ${#beside[@]}." echo "OK: $appimage prefers the host $LIB (fallback kept) and carries AppStream" echo " metadata. Channel pair in $CHANNEL_DIR/, updating from the $UPDATE_TAG tag." diff --git a/scripts/publish-update-channel.sh b/scripts/publish-update-channel.sh index 2d07298..198a125 100755 --- a/scripts/publish-update-channel.sh +++ b/scripts/publish-update-channel.sh @@ -57,23 +57,63 @@ 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" "$@"; } -# Anchor the tag in Gitea first — see the header. Moved rather than left -# alone: it has to name this build, and the mirror will carry whatever Gitea -# holds over the top of GitHub's copy. -echo "==> Anchoring the $TAG tag in Gitea at ${GITEA_SHA:0:9}" -tea -X DELETE "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1 || true -tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \ - -d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \ - >/dev/null +# 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" +if tea "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1; then + echo " already anchored — left alone" +else + 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 +fi # Not best-effort. Without this tag the mirror removes GitHub's and the # channel dies silently somewhere between now and four hours from now. tea "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1 \ || { echo "FAILED: the $TAG tag does not exist in Gitea; the mirror would delete GitHub's copy." >&2; exit 1; } -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)" +# 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" + gh -X PATCH "$API/releases/$release_id" \ + -d "{\"tag_name\": \"$TAG\", \"draft\": false}" >/dev/null + release="$(gh "$API/releases/$release_id")" +fi if [ -z "$release_id" ]; then echo "==> Creating it" @@ -107,9 +147,12 @@ for a in json.load(sys.stdin).get("assets", []): 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 echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))" - curl -sf -X POST \ + curl -sf --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \ + -X POST \ -H "Authorization: Bearer $GH_PAT" \ -H "Content-Type: application/octet-stream" \ --data-binary "@$asset" \ @@ -119,12 +162,22 @@ 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" - code="$(curl -s -o /dev/null -w '%{http_code}' -L "$url")" - [ "$code" = "200" ] || { echo "FAILED: $url returned $code" >&2; exit 1; } - echo " $code $url" + 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" done echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it." -- 2.52.0 From d38736007fa709bf462a9d77257584f6fba01cc4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 3 Sep 2026 09:17:34 -0700 Subject: [PATCH 3/3] Take the re-review: distinguish "absent" from "unreachable" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review of this branch. Two blockers and one real defect I had papered over with a true-but-misleading claim. **`make_latest` was missing from the republish path.** The create path sends `"make_latest": "false"` so the channel cannot displace the versioned release on the releases page. The reuse path — taken on every run after the first — omitted it, and the API's documented default for a publish transition is `true`. So the second release would have quietly promoted `linux-latest` to the repository's Latest release: a release whose own body says "for a specific version, use the versioned releases instead". Now sent on both paths. `tag_name` is re-sent deliberately and now says so in a comment — the API removes the tag when a PATCH omits it, and this branch exists because a tag disappeared. **A transient Gitea error would have cost the whole release.** `curl -sf` fails identically for "404, the tag is genuinely absent" and "503, Gitea is briefly unreachable", and both landed in the create branch. Creating a tag that already exists returns 409, which aborted the last step of `build-linux` — and `create-tag` and `sync-to-github` both depend on it, so no version tag and no GitHub sync at all. The failure message also read "the tag does not exist" when Gitea had merely been unreachable. Now a `case` on the HTTP code — 200 leave alone, 404 create, anything else fail loudly with the real code — the same idiom `Upload to Gitea release` already uses two steps above. `422 already_exists` on the release POST is likewise a recoverable answer, not a reason to lose a release. **The empty `Categories=` was still shipping, and my claim hid it.** I wrote that the guard "asserts the absence of an empty value rather than the presence of any filled one" — true of the regex, false of the artifact. The AppDir root `.desktop` is a *symlink* into usr/share/applications, so `sed -i` replaced the link with a regular file and left the real entry empty; the guard globbed the root only, so it saw the copy it had just written and passed. Verified on the real artifact: two divergent entries, and the one that shipped was empty. Fixed with `--follow-symlinks`, both locations globbed, and the guard turned into a positive assertion over every entry — which also closes its missing-key and unmatched-glob holes. Both entries now read `Categories=Development;Utility;`. Also taken: the duplicate-AppImage check moves to a precondition, since as a post-mortem it let the script repack and overwrite the versioned artifact before failing, and it silently selected by glob order, i.e. the older version — it now refuses in under a second; assets are deleted and re-uploaded one at a time, because deleting both up front left a fresh AppImage with no .zsync if the second upload failed, which silently stops every client; and the success line no longer claims a fallback was kept when there was nothing to demote. Left as informational, with the reasoning recorded rather than acted on: `--retry-all-errors` retries permanent 4xx (fail-closed, matches the repo's other upload steps); the release list is unpaginated (a GraphQL lookup by pending tag name is the durable fix, but 7 releases is decades from the cliff, and the 422 handling above covers the failure mode); process-substitution failure is invisible to `mapfile` (fail-closed downstream). Verified against the real 0.4.19 artifact — happy path, no AppImage, two AppImages, and an AppDir rebuilt with the bundled library removed. shellcheck clean at warning level on both scripts. appimagetool now reports the AppStream metadata found. Nothing here is CI-proven, and that is worth stating plainly: `build-linux` fails on this branch before `tauri build` even runs, at "Install frontend dependencies" with `npm error Cannot read properties of null (reading 'edgesOut')` — confirmed in the logs of jobs 5644 and 5636. Unrelated to this change and tracked separately, but it means the finalizer has never executed in CI on either commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm --- scripts/finalize-appimage.sh | 41 ++++++++-- scripts/publish-update-channel.sh | 119 ++++++++++++++++++++++++------ 2 files changed, 132 insertions(+), 28 deletions(-) diff --git a/scripts/finalize-appimage.sh b/scripts/finalize-appimage.sh index 2f8630b..c928bd7 100755 --- a/scripts/finalize-appimage.sh +++ b/scripts/finalize-appimage.sh @@ -119,6 +119,14 @@ 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" @@ -210,11 +218,18 @@ else fi # linuxdeploy emits `Categories=` empty, which files the app nowhere. -for desktop in "$root"/*.desktop; do +# +# 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 [ -e "$desktop" ] || continue if grep -q "^Categories=$" "$desktop"; then - sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop" - echo "Filled in Categories for $(basename "$desktop")." + sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop" + echo "Filled in Categories for ${desktop#"$root"/}." fi done @@ -258,7 +273,17 @@ fi # 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. -! grep -q "^Categories=$" "$out"/*.desktop || fail "a desktop file still has an empty Categories." +# 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" ] \ || fail "AppStream metadata did not make it into the image."; } @@ -284,5 +309,9 @@ shopt -u nullglob [ "${#beside[@]}" -eq 1 ] \ || fail "expected 1 AppImage beside the release, found ${#beside[@]}." -echo "OK: $appimage prefers the host $LIB (fallback kept) and carries AppStream" -echo " metadata. Channel pair in $CHANNEL_DIR/, updating from the $UPDATE_TAG tag." +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." diff --git a/scripts/publish-update-channel.sh b/scripts/publish-update-channel.sh index 198a125..4016d40 100755 --- a/scripts/publish-update-channel.sh +++ b/scripts/publish-update-channel.sh @@ -56,6 +56,15 @@ 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.** # @@ -69,19 +78,34 @@ tea() { curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: applic # 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" -if tea "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1; then - echo " already anchored — left alone" -else - 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 -fi +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. -tea "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1 \ - || { echo "FAILED: the $TAG tag does not exist in Gitea; the mirror would delete GitHub's copy." >&2; exit 1; } +# 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 @@ -110,8 +134,17 @@ 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}" >/dev/null + -d "{\"tag_name\": \"$TAG\", \"draft\": false, \"make_latest\": \"false\"}" >/dev/null release="$(gh "$API/releases/$release_id")" fi @@ -120,36 +153,78 @@ if [ -z "$release_id" ]; then # 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. - release="$(gh -X POST "$API/releases" -d "$(python3 -c ' + body_json="$(python3 -c ' import json print(json.dumps({ "tag_name": "'"$TAG"'", "name": "Linux update channel", - "body": "Rolling AppImage build that Triple-C’s in-app updater reads. " + "body": "Rolling AppImage build that Triple-C\u2019s 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 -echo "==> Removing superseded assets from release $release_id" -printf '%s' "$release" | python3 -c ' +# 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 ' import sys, json keep = set(sys.argv[1:]) +out = {} for a in json.load(sys.stdin).get("assets", []): if a["name"] in keep: - 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 + out[a["name"]] = a["id"] +print(json.dumps(out)) +' "${ASSETS[@]}")" # --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 \ -- 2.52.0