Anchor the update channel tag, and stop shipping a duplicate AppImage #52
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<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"
|
||||
|
||||
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."
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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 <artifacts directory>}"
|
||||
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."
|
||||
|
||||
Reference in New Issue
Block a user