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 \