Compare commits

..
Author SHA1 Message Date
jknapp 3aec2998d8 Merge pull request 'Anchor the update channel tag, and stop shipping a duplicate AppImage' (#52) from fix/update-channel-durability into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m44s
Build App / build-linux (push) Successful in 4m48s
Build App / build-windows (push) Successful in 4m52s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 7s
2026-09-03 16:52:47 +00:00
shadowdao 019fb403d5 Merge remote-tracking branch 'origin/main' into fix/update-channel-durability
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 2m42s
Build App (Preview) / build-linux (pull_request) Successful in 4m51s
Build App (Preview) / build-windows (pull_request) Successful in 4m54s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
2026-09-03 09:41:22 -07:00
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
shadowdaoandClaude Opus 5 d38736007f Take the re-review: distinguish "absent" from "unreachable"
Secret Scan / scan (push) Successful in 3s
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 2m3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / prune-previews (pull_request) Skipped
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 09:17:34 -07:00
shadowdaoandClaude Opus 5 63f282bef6 Fix the review findings: never destroy a working anchor
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
2026-09-03 08:53:13 -07:00
shadowdaoandClaude Opus 5 d561ce03d5 Anchor the update channel tag, and stop shipping a duplicate AppImage
Secret Scan / scan (push) Successful in 6s
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 2m57s
Build App (Preview) / build-windows (pull_request) Successful in 16m16s
Build App (Preview) / prune-previews (pull_request) Skipped
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 08:29:58 -07:00
4 changed files with 356 additions and 61 deletions
+32 -5
View File
@@ -299,8 +299,34 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules package-lock.json # `npm ci` — from the lockfile, never resolving afresh.
npm install #
# 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 - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
@@ -335,7 +361,6 @@ jobs:
run: | run: |
mkdir -p artifacts 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/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file. # Assets, not workflow artifacts — see the note at the top of this file.
@@ -427,8 +452,10 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules # `npm ci` here too, so all three platforms install identically and
npm install # 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 - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
+51 -6
View File
@@ -172,8 +172,34 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules package-lock.json # `npm ci` — from the lockfile, never resolving afresh.
npm install #
# 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 - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
@@ -200,10 +226,23 @@ jobs:
- name: Collect artifacts - name: Collect artifacts
run: | run: |
mkdir -p artifacts 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/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
ls -la artifacts/ 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 - name: Upload to Gitea release
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
env: env:
@@ -286,7 +325,11 @@ jobs:
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
env: env:
GH_PAT: ${{ secrets.GH_PAT }} 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: build-macos:
runs-on: macos-latest runs-on: macos-latest
@@ -343,8 +386,10 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules # `npm ci` here too, so all three platforms install identically and
npm install # 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 - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
+91 -31
View File
@@ -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" APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
APP_ID="com.triple-c.desktop" 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" STABLE_NAME="Triple-C_x86_64.AppImage"
UPDATE_TAG="linux-latest" UPDATE_TAG="linux-latest"
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync" UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
@@ -98,8 +103,13 @@ CATEGORIES="Development;Utility;"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml" 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: unbundle-wayland-client.sh <bundle/appimage directory>}" dir="${1:?usage: finalize-appimage.sh <bundle/appimage directory>}"
cd "$dir" cd "$dir"
shopt -s nullglob shopt -s nullglob
@@ -109,6 +119,14 @@ if [ ${#images[@]} -eq 0 ]; then
echo "No .AppImage in $dir — nothing to do." >&2 echo "No .AppImage in $dir — nothing to do." >&2
exit 0 exit 0
fi 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]}" appimage="${images[0]}"
here="$PWD" here="$PWD"
@@ -120,15 +138,15 @@ echo "Inspecting $appimage"
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null ) ( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
root="$work/squashfs-root" root="$work/squashfs-root"
if [ ! -e "$root/usr/lib/$LIB" ]; then # The demotion and the metadata are independent jobs, and an absent library
# Not a failure: linuxdeploy may have stopped bundling it, which is the # must not skip the second. An early exit here also left `update-channel/`
# outcome this script exists to produce. # uncreated, which killed the publish step on a missing directory and took the
echo "$LIB is not bundled — leaving $appimage alone." # tag and mirror jobs down with it — a half-published release.
exit 0 demoted=false
fi if [ -e "$root/usr/lib/$LIB" ]; then
mkdir -p "$root/$FALLBACK_DIR" mkdir -p "$root/$FALLBACK_DIR"
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB" mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
cat > "$root/$HOOK" <<'HOOK_EOF' cat > "$root/$HOOK" <<'HOOK_EOF'
#! /usr/bin/env bash #! /usr/bin/env bash
@@ -177,6 +195,11 @@ src = src.replace(
) )
open(path, "w").write(src) open(path, "w").write(src)
PATCH_EOF PATCH_EOF
fi
demoted=true
echo "Demoted $LIB to $FALLBACK_DIR."
else
echo "$LIB is not bundled — nothing to demote."
fi fi
# --- metadata ------------------------------------------------------------- # --- metadata -------------------------------------------------------------
@@ -188,22 +211,29 @@ version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
if [ -f "$appdata_src" ]; then if [ -f "$appdata_src" ]; then
mkdir -p "$root/usr/share/metainfo" mkdir -p "$root/usr/share/metainfo"
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \ 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." echo "Added AppStream metadata for $version."
else else
echo "No AppStream source at $appdata_src — skipping." >&2 echo "No AppStream source at $appdata_src — skipping." >&2
fi fi
# linuxdeploy emits `Categories=` empty, which files the app nowhere. # 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 [ -e "$desktop" ] || continue
if grep -q "^Categories=$" "$desktop"; then if grep -q "^Categories=$" "$desktop"; then
sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop" sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
echo "Filled in Categories for $(basename "$desktop")." echo "Filled in Categories for ${desktop#"$root"/}."
fi fi
done done
echo "Demoted $LIB to $FALLBACK_DIR; repacking." echo "Repacking."
tool="$work/appimagetool" tool="$work/appimagetool"
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
@@ -211,13 +241,19 @@ chmod +x "$tool"
# --appimage-extract-and-run: CI runners generally have no FUSE. # --appimage-extract-and-run: CI runners generally have no FUSE.
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image. # -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 \ ARCH=x86_64 "$tool" --appimage-extract-and-run \
-u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null -u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null
chmod +x "$STABLE_NAME" chmod +x "$CHANNEL_DIR/$STABLE_NAME"
# The versioned name is what the per-version release publishes; the stable one # 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. # and its .zsync go to the rolling tag. Same bytes, two names, two places.
cp "$STABLE_NAME" "$appimage" # 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" chmod +x "$appimage"
# The guards are the test. Each one is a way the repack could look like it # The guards are the test. Each one is a way the repack could look like it
@@ -227,16 +263,28 @@ out="$check/squashfs-root"
fail() { echo "FAILED: $1" >&2; exit 1; } fail() { echo "FAILED: $1" >&2; exit 1; }
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path." if [ "$demoted" = true ]; then
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing." [ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing." [ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook." [ -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." [ -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 # 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. # or describe, and both fail silently at runtime rather than at build time.
grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty." # Asserted positively, over every entry: the earlier form checked only that no
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \ # *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."; } || fail "AppStream metadata did not make it into the image."; }
# The update string is the difference between adoptable and updatable. It # The update string is the difference between adoptable and updatable. It
@@ -245,13 +293,25 @@ 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 # 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 # for the fixed tag: a versioned name here resolves to the build the client
# already has. # already has.
[ -e "$STABLE_NAME" ] || fail "the stable-named image is missing." [ -e "$CHANNEL_DIR/$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.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 -qF "$UPDATE_INFO" \
|| fail "the image carries no update information for the $UPDATE_TAG tag." || fail "the image does not carry exactly the expected update information."
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." || fail "the .zsync names something other than $STABLE_NAME."
echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream" # The versioned release must carry one AppImage, not two. This is the guard
echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync." # 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."
+182 -19
View File
@@ -17,7 +17,22 @@
# It writes to GitHub rather than Gitea because that mirror is where updates # 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. # are pulled from. Needs GH_PAT with contents write on the mirror.
# #
# Usage: GH_PAT=... publish-update-channel.sh <directory holding the artifacts> # **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>
set -euo pipefail set -euo pipefail
@@ -26,7 +41,12 @@ TAG="linux-latest"
API="https://api.github.com/repos/$REPO" API="https://api.github.com/repos/$REPO"
ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync") 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}" : "${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>}" dir="${1:?usage: publish-update-channel.sh <artifacts directory>}"
cd "$dir" cd "$dir"
@@ -35,46 +55,179 @@ for asset in "${ASSETS[@]}"; do
done done
gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; } 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" "$@"; }
echo "==> Looking for the $TAG release" # Anchor the tag in Gitea — see the header. **Created if absent, never moved.**
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)" # 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
if [ -z "$release_id" ]; then if [ -z "$release_id" ]; then
echo "==> Creating it" echo "==> Creating it"
# Not a prerelease, but deliberately not the "latest" release either: this # Not a prerelease, but deliberately not the "latest" release either: this
# tag is a channel, and it must never displace the versioned release a # tag is a channel, and it must never displace the versioned release a
# person lands on from the releases page. # person lands on from the releases page.
release="$(gh -X POST "$API/releases" -d "$(python3 -c ' body_json="$(python3 -c '
import json import json
print(json.dumps({ print(json.dumps({
"tag_name": "'"$TAG"'", "tag_name": "'"$TAG"'",
"name": "Linux update channel", "name": "Linux update channel",
"body": "Rolling AppImage build that Triple-Cs 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 " "The two files here are replaced on every release; for a specific "
"version, use the versioned releases instead.", "version, use the versioned releases instead.",
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
"make_latest": "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"])')" release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
fi fi
echo "==> Removing superseded assets from release $release_id" # One asset at a time, delete immediately followed by upload. Deleting both up
printf '%s' "$release" | python3 -c ' # 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 import sys, json
keep = set(sys.argv[1:]) keep = set(sys.argv[1:])
out = {}
for a in json.load(sys.stdin).get("assets", []): for a in json.load(sys.stdin).get("assets", []):
if a["name"] in keep: if a["name"] in keep:
print(a["id"]) out[a["name"]] = a["id"]
' "${ASSETS[@]}" | while read -r asset_id; do print(json.dumps(out))
[ -n "$asset_id" ] || continue ' "${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 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))" 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 "Authorization: Bearer $GH_PAT" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \ --data-binary "@$asset" \
@@ -84,12 +237,22 @@ done
# The updater is only as good as this URL, and a silent failure here means # 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 # every installed copy quietly stops updating. Confirm both are actually
# fetchable at the address the AppImage was built to check. # 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" echo "==> Verifying the published URLs"
for asset in "${ASSETS[@]}"; do for asset in "${ASSETS[@]}"; do
url="https://github.com/$REPO/releases/download/$TAG/$asset" url="https://github.com/$REPO/releases/download/$TAG/$asset"
code="$(curl -s -o /dev/null -w '%{http_code}' -L "$url")" local_size="$(stat -c %s "$asset")"
[ "$code" = "200" ] || { echo "FAILED: $url returned $code" >&2; exit 1; }
echo " $code $url" 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 done
echo "OK: $TAG updated." echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it."