diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 62119b2..5a0c592 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -361,7 +361,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 c267df9..0664f37 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -226,10 +226,23 @@ jobs: - name: Collect artifacts run: | mkdir -p artifacts + # The versioned AppImage only. The update channel's copy lives in + # bundle/appimage/update-channel/ precisely so this glob cannot pick + # it up and publish an 80 MB duplicate under a second name. cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true - cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true ls -la artifacts/ + # A green job that published nothing is the worst outcome available: + # the release exists, carries no AppImage, and nobody is told. The + # `|| true` above is there so a missing bundle does not mask the real + # error, which makes this check the thing that catches it. + shopt -s nullglob + collected=(artifacts/*) + if [ ${#collected[@]} -eq 0 ]; then + echo "No artifacts collected — the bundler produced nothing." >&2 + exit 1 + fi + - name: Upload to Gitea release if: gitea.event_name == 'push' env: @@ -312,7 +325,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..c928bd7 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" @@ -98,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 @@ -109,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" @@ -120,15 +138,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 @@ -177,6 +195,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 ------------------------------------------------------------- @@ -188,22 +211,29 @@ 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 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 -echo "Demoted $LIB to $FALLBACK_DIR; repacking." +echo "Repacking." tool="$work/appimagetool" curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" @@ -211,13 +241,19 @@ 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" "$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 @@ -227,16 +263,28 @@ 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" ] \ +# 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."; } # 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 # 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" \ - || fail "the image carries no update information for the $UPDATE_TAG tag." -grep -aq "^Filename: $STABLE_NAME$" "$STABLE_NAME.zsync" \ +readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -qF "$UPDATE_INFO" \ + || fail "the image does not carry exactly the expected update information." +grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \ || 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. +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." diff --git a/scripts/publish-update-channel.sh b/scripts/publish-update-channel.sh index 8126bcc..4016d40 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,46 +55,179 @@ for asset in "${ASSETS[@]}"; do done gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; } +tea() { curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; } +# Status, not a boolean. `curl -sf` fails identically for "404, the tag is +# genuinely absent" and "503, Gitea is briefly unreachable", and treating the +# second as the first means POSTing over a tag that already exists, taking a +# 409, and aborting the last step of build-linux — which `create-tag` and +# `sync-to-github` both depend on. A transient blip would cost the release, not +# just the channel update. Same `case`-on-code idiom as `Upload to Gitea +# release` two steps above in the workflow. A refused connection reports 000 +# and lands in the catch-all. +tea_code() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@"; } -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)" +# Anchor the tag in Gitea — see the header. **Created if absent, never moved.** +# +# An earlier version deleted and recreated it so the tag would name the current +# build. That was worse than useless: nothing about the channel depends on +# which commit the tag points at — the update string resolves the tag by *name* +# and the assets hang off the release object — while a DELETE followed by a +# failed POST destroys a working anchor and leaves a window in which a mirror +# run prunes GitHub's copy. A transient Gitea error would have converted a +# healthy channel into a dead one, which is strictly worse than this step not +# existing. Gitea's POST /tags has no force semantics, so the DELETE was only +# ever there to get around a 409; asking first removes the need. +echo "==> Anchoring the $TAG tag in Gitea" +anchor_probe="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")" +case "$anchor_probe" in + 200) + echo " already anchored — left alone" + ;; + 404) + echo " creating it at ${GITEA_SHA:0:9}" + tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \ + -d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \ + >/dev/null + ;; + *) + echo "FAILED: Gitea answered $anchor_probe asking whether the $TAG tag exists." >&2 + echo " Refusing to guess — creating it blindly would 409 over an" >&2 + echo " existing tag and abort the release." >&2 + exit 1 + ;; +esac + +# Not best-effort. Without this tag the mirror removes GitHub's and the +# channel dies silently somewhere between now and four hours from now. Reported +# by code, so "Gitea was unreachable" cannot masquerade as "the tag is gone". +anchor_code="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")" +[ "$anchor_code" = "200" ] || { + echo "FAILED: the $TAG tag is not readable in Gitea (HTTP $anchor_code);" >&2 + echo " without it the mirror would delete GitHub's copy." >&2 + exit 1 +} + +# Look through the authenticated list rather than /releases/tags/, which never +# returns drafts. That matters here specifically: GitHub demotes a published +# release to a draft when its tag is deleted, which is the state every mirror +# run left behind, so the by-tag lookup reports "absent" while orphaned drafts +# sit there holding 86 MB each. Reuse the newest and delete the rest, or they +# accumulate one per release forever. +echo "==> Looking for the $TAG release (drafts included)" +all_releases="$(gh "$API/releases?per_page=100")" +mapfile -t existing < <(printf '%s' "$all_releases" | python3 -c ' +import sys, json +tag = sys.argv[1] +rs = [r for r in json.load(sys.stdin) if r.get("tag_name") == tag] +rs.sort(key=lambda r: r.get("created_at",""), reverse=True) +for r in rs: + print(r["id"]) +' "$TAG") + +release_id="${existing[0]:-}" + +for stale in "${existing[@]:1}"; do + echo " deleting orphaned duplicate release $stale" + gh -X DELETE "$API/releases/$stale" >/dev/null || true +done + +if [ -n "$release_id" ]; then + # A draft has no tag and serves no download URL, so it has to be republished. + echo " reusing release $release_id" + # `make_latest` is not optional here even though this release already exists. + # Publishing a draft is a publish transition, where the API's documented + # default is `true` — so omitting it would quietly promote this channel to + # the repository's "Latest release" and bury the versioned release a person + # actually wants from the releases page. + # + # `tag_name` is re-sent deliberately, and must be: the API removes the tag + # when a PATCH omits it. Given this whole change exists because a tag + # disappeared, that is an expensive line to tidy away. + gh -X PATCH "$API/releases/$release_id" \ + -d "{\"tag_name\": \"$TAG\", \"draft\": false, \"make_latest\": \"false\"}" >/dev/null + release="$(gh "$API/releases/$release_id")" +fi if [ -z "$release_id" ]; then echo "==> Creating it" # Not a prerelease, but deliberately not the "latest" release either: this # tag is a channel, and it must never displace the versioned release a # person lands on from the releases page. - 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 -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" \ @@ -84,12 +237,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." +echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it."