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
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
This commit is contained in:
@@ -119,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"
|
||||||
|
|
||||||
@@ -210,11 +218,18 @@ else
|
|||||||
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
|
||||||
|
|
||||||
@@ -258,7 +273,17 @@ fi
|
|||||||
|
|
||||||
# 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 "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" ] \
|
[ -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."; }
|
||||||
|
|
||||||
@@ -284,5 +309,9 @@ shopt -u nullglob
|
|||||||
[ "${#beside[@]}" -eq 1 ] \
|
[ "${#beside[@]}" -eq 1 ] \
|
||||||
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
|
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
|
||||||
|
|
||||||
echo "OK: $appimage prefers the host $LIB (fallback kept) and carries AppStream"
|
if [ "$demoted" = true ]; then
|
||||||
echo " metadata. Channel pair in $CHANNEL_DIR/, updating from the $UPDATE_TAG tag."
|
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."
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ 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" "$@"; }
|
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.**
|
# 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
|
# 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.
|
# ever there to get around a 409; asking first removes the need.
|
||||||
echo "==> Anchoring the $TAG tag in Gitea"
|
echo "==> Anchoring the $TAG tag in Gitea"
|
||||||
if tea "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1; then
|
anchor_probe="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
|
||||||
echo " already anchored — left alone"
|
case "$anchor_probe" in
|
||||||
else
|
200)
|
||||||
echo " creating it at ${GITEA_SHA:0:9}"
|
echo " already anchored — left alone"
|
||||||
tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \
|
;;
|
||||||
-d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \
|
404)
|
||||||
>/dev/null
|
echo " creating it at ${GITEA_SHA:0:9}"
|
||||||
fi
|
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
|
# Not best-effort. Without this tag the mirror removes GitHub's and the
|
||||||
# channel dies silently somewhere between now and four hours from now.
|
# channel dies silently somewhere between now and four hours from now. Reported
|
||||||
tea "$GITEA_API/repos/$GITEA_REPO/tags/$TAG" >/dev/null 2>&1 \
|
# by code, so "Gitea was unreachable" cannot masquerade as "the tag is gone".
|
||||||
|| { echo "FAILED: the $TAG tag does not exist in Gitea; the mirror would delete GitHub's copy." >&2; exit 1; }
|
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
|
# Look through the authenticated list rather than /releases/tags/, which never
|
||||||
# returns drafts. That matters here specifically: GitHub demotes a published
|
# returns drafts. That matters here specifically: GitHub demotes a published
|
||||||
@@ -110,8 +134,17 @@ done
|
|||||||
if [ -n "$release_id" ]; then
|
if [ -n "$release_id" ]; then
|
||||||
# A draft has no tag and serves no download URL, so it has to be republished.
|
# A draft has no tag and serves no download URL, so it has to be republished.
|
||||||
echo " reusing release $release_id"
|
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" \
|
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")"
|
release="$(gh "$API/releases/$release_id")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -120,36 +153,78 @@ if [ -z "$release_id" ]; then
|
|||||||
# 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-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 "
|
"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
|
# --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).
|
# 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 --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \
|
curl -sf --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \
|
||||||
-X POST \
|
-X POST \
|
||||||
|
|||||||
Reference in New Issue
Block a user