From 9fadfbc37a2cc266fba1885a61efa6d00a347709 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 2 Sep 2026 17:10:44 -0700 Subject: [PATCH] Make the AppImage updatable, and drop the deb and rpm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An AppImage manager can adopt the current build but never update it: the image carries no update information, which is the string that tells such a tool where to look for a newer one. It also carries no AppStream metadata, so a manager has nothing to show but a filename — appimagetool has been warning about that on every build — and linuxdeploy leaves `Categories=` empty, which files the app nowhere in a desktop menu. All three are fixed while the image is already unpacked for the wayland fix, so the cost is a few lines rather than a second pass. `unbundle-wayland-client.sh` is now `finalize-appimage.sh`, since it does more than unbundle. The update URL is a **fixed** `linux-latest` tag on the GitHub mirror, which is where updates are pulled from — deliberately not `releases/latest`. `latest` follows whichever release is newest, and the Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including the `-win` and `-mac` tags that carry no AppImage. A URL that can resolve to a release with no AppImage in it fails on users' machines and nowhere else. The output is named for that tag too, and that is not cosmetic: zsync records a *relative* filename which a client resolves against the .zsync URL it fetched, so a versioned name would send every client after the build it already has. Verified by reading the generated header — `Filename: Triple-C_x86_64 .AppImage` — and the image's own `.upd_info` section, which is where the tag actually lives. My first guard checked the .zsync for the tag and failed correctly, which is how that distinction got found rather than shipped. Range requests were confirmed against the mirror before building on them: 206 with a correct content-range, so updates are real deltas rather than an 85 MB re-download. The .deb and .rpm go. They are two more artifacts to build, publish and keep working for an audience already served by the one file that runs on every distribution, and neither could ever self-update — which is now the difference that matters. Older releases keep theirs. The Linux job passes `--bundles appimage` rather than changing `tauri.conf.json`, so macOS and Windows are untouched. Verified against the real 0.4.19 artifact: it repacks, the AppStream file and filled-in Categories land inside the image, the update string resolves to the fixed tag, and the wayland fallback still holds. Both publisher failure paths refuse rather than half-publishing — no token, and missing artifacts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm --- .gitea/workflows/build-app-preview.yml | 12 +- .gitea/workflows/build-app.yml | 21 +++- CLAUDE.md | 22 +++- HOW-TO-USE.md | 8 +- .../appimage/com.triple-c.desktop.appdata.xml | 56 +++++++++ ...wayland-client.sh => finalize-appimage.sh} | 106 +++++++++++++++++- scripts/publish-update-channel.sh | 95 ++++++++++++++++ 7 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 packaging/appimage/com.triple-c.desktop.appdata.xml rename scripts/{unbundle-wayland-client.sh => finalize-appimage.sh} (54%) create mode 100755 scripts/publish-update-channel.sh diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 410870c..371d8a5 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -319,21 +319,23 @@ jobs: TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} run: | export PATH="$HOME/.cargo/bin:$PATH" - npx tauri build + # AppImage only: the .deb and .rpm were dropped in favour of the one + # artifact that runs everywhere, and building them is pure cost. + # Left as "all" in tauri.conf.json so macOS and Windows are unaffected. + npx tauri build --bundles appimage # linuxdeploy bundles a libwayland-client.so.0 that shadows the host's # and breaks Mesa's EGL on systems newer than the build runner, so the # window comes up blank. It has to come from the host; see the script # header for the evidence and the trade. - - name: Unbundle the host-coupled Wayland client - run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage + - name: Finalize the AppImage + run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage - name: Collect artifacts 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/deb/*.deb artifacts/ 2>/dev/null || true - cp app/src-tauri/target/release/bundle/rpm/*.rpm 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 9b540a0..0d4aa02 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -185,21 +185,23 @@ jobs: working-directory: ./app run: | export PATH="$HOME/.cargo/bin:$PATH" - npx tauri build + # AppImage only: the .deb and .rpm were dropped in favour of the one + # artifact that runs everywhere, and building them is pure cost. + # Left as "all" in tauri.conf.json so macOS and Windows are unaffected. + npx tauri build --bundles appimage # linuxdeploy bundles a libwayland-client.so.0 that shadows the host's # and breaks Mesa's EGL on systems newer than the build runner, so the # window comes up blank. It has to come from the host; see the script # header for the evidence and the trade. - - name: Unbundle the host-coupled Wayland client - run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage + - name: Finalize the AppImage + run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage - name: Collect artifacts 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/deb/*.deb artifacts/ 2>/dev/null || true - cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true + cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true ls -la artifacts/ - name: Upload to Gitea release @@ -277,6 +279,15 @@ jobs: "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" done + # The fixed tag every installed AppImage checks for updates. Separate + # from the versioned release above because the updater's URL must never + # move, and `releases/latest` does. + - name: Publish the Linux update channel + if: gitea.event_name == 'push' + env: + GH_PAT: ${{ secrets.GH_PAT }} + run: bash scripts/publish-update-channel.sh artifacts + build-macos: runs-on: macos-latest needs: [compute-version] diff --git a/CLAUDE.md b/CLAUDE.md index 2092355..837cc43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -679,8 +679,26 @@ deliberately out of scope — this is not a project backup. ## Packaging -Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and -`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A +Linux ships as **AppImage only**, built by `build-app.yml` (releases) and +`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to +build and publish for an audience the AppImage already serves, and neither could self-update. The +Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and +Windows are untouched. + +`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are +load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as +a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a +bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up +blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by +bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our +releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream +metadata and update information**, without which an AppImage manager can adopt the app but never +update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror +(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is +newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones +that carry no AppImage. The script's post-repack assertions are the only test any of this has. + +**There is deliberately no Arch package.** A `triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on `hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 008aebd..56afc0a 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -41,14 +41,16 @@ Download the build for your platform from [GitHub Releases](https://github.com/s |----------|------|---------| | **Windows** | `Triple-C__x64-setup.exe` or `.msi` | Run the installer. | | **macOS** | `Triple-C__universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. | -| **Debian / Ubuntu** | `Triple-C__amd64.deb` | `sudo apt install ./Triple-C__amd64.deb` | -| **Fedora / RHEL** | `Triple-C--1.x86_64.rpm` | `sudo dnf install ./Triple-C--1.x86_64.rpm` | -| **Arch / CachyOS / other Linux** | `Triple-C__amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. | +| **Linux (all distributions)** | `Triple-C__amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. | > **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`. > **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher). +> **Linux is AppImage only.** The `.deb` and `.rpm` were dropped. They were a second and third artifact to build, test and publish for an audience already served by the one file that runs on every distribution — and unlike the AppImage they could not be kept up to date automatically. Older releases still carry them if you need one. + +> **Updates.** The AppImage carries update information, so an AppImage manager (Gear Lever, AppImageLauncher and similar) can adopt it and update it in place — pulling only the changed blocks rather than re-downloading 85 MB. It reads a fixed `linux-latest` tag on GitHub, so the URL never moves between versions. + > **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly. ### Adding an AppImage to the app launcher diff --git a/packaging/appimage/com.triple-c.desktop.appdata.xml b/packaging/appimage/com.triple-c.desktop.appdata.xml new file mode 100644 index 0000000..fd00030 --- /dev/null +++ b/packaging/appimage/com.triple-c.desktop.appdata.xml @@ -0,0 +1,56 @@ + + + + com.triple-c.desktop + CC0-1.0 + MIT + + Triple-C + Run Claude Code sessions in isolated Docker containers + + +

+ Triple-C sandboxes Claude Code inside per-project Docker containers, so an + agent can install packages, edit files and run commands without touching + the host. Each project gets its own container, its own credentials and its + own terminal sessions. +

+

Features:

+
    +
  • Per-project containers with persistent home and config volumes
  • +
  • Multiple terminal sessions per project, in one reorderable tab strip
  • +
  • Notes that can be sent straight into a running agent's prompt
  • +
  • Anthropic, AWS Bedrock, Ollama, llama.cpp and OpenAI-compatible backends
  • +
  • Remote access over a browser terminal, and speech-to-text input
  • +
+
+ + Triple-C.desktop + + Development + Utility + + + https://github.com/shadowdao/triple-c + https://github.com/shadowdao/triple-c/issues + + + triple-c + + + + + + + +
diff --git a/scripts/unbundle-wayland-client.sh b/scripts/finalize-appimage.sh similarity index 54% rename from scripts/unbundle-wayland-client.sh rename to scripts/finalize-appimage.sh index b6e7367..502bc87 100755 --- a/scripts/unbundle-wayland-client.sh +++ b/scripts/finalize-appimage.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash # -# Drop the bundled libwayland-client.so.0 out of a built AppImage. +# Post-process a built AppImage: make it start on modern Mesa, and make it +# adoptable and updatable by an AppImage manager. +# +# Tauri hands off to linuxdeploy, which offers no hook between building the +# AppDir and packing it, so both jobs are done by unpacking the finished image +# and repacking it. That is also why the update information is embedded here +# rather than passed to the bundler. +# +# --------------------------------------------------------------------------- +# 1. The bundled Wayland client +# --------------------------------------------------------------------------- # # linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of # GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's @@ -44,7 +54,34 @@ # LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports # lands last: a fallback, never an override. # -# Usage: unbundle-wayland-client.sh +# --------------------------------------------------------------------------- +# 2. Metadata an AppImage manager needs +# --------------------------------------------------------------------------- +# +# Two things, neither of which the bundler produces: +# +# * AppStream metadata, so a manager can show what the app is rather than a +# bare filename. appimagetool warns about its absence on every build. +# * Update information embedded in the image — the string that tells a +# manager where to look for a newer build. Without it the app can be +# adopted but never updated, which is the whole point. +# +# The update URL is a **fixed** tag on the GitHub mirror, which is where +# updates are pulled from, rather than `releases/latest`. `latest` follows +# whatever release is newest, and the Gitea-to-GitHub backfill creates one +# GitHub release per Gitea tag — including the `-win` and `-mac` tags, which +# carry no AppImage. A fixed tag cannot be pointed at a release that has none, +# and is equally immune to a release marked prerelease. +# +# The output is named for the fixed tag too. zsync records the filename it was +# generated for and a client resolves it relative to the .zsync URL, so a +# versioned name would send every client looking for the version it already +# has. The versioned copy is written afterwards for the normal release. +# +# It also fills in `Categories=`, which linuxdeploy leaves empty — that is what +# a desktop menu and most managers use to file the application. +# +# Usage: finalize-appimage.sh set -euo pipefail @@ -53,6 +90,15 @@ FALLBACK_DIR="usr/lib/wayland-fallback" 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" +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" +CATEGORIES="Development;Utility;" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml" + dir="${1:?usage: unbundle-wayland-client.sh }" cd "$dir" @@ -133,6 +179,30 @@ open(path, "w").write(src) PATCH_EOF fi +# --- metadata ------------------------------------------------------------- + +# Version comes from the artifact rather than a second source that could drift. +version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')" +[ -n "$version" ] || { echo "Could not read a version out of $appimage" >&2; exit 1; } + +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" + 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 + [ -e "$desktop" ] || continue + if grep -q "^Categories=$" "$desktop"; then + sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop" + echo "Filled in Categories for $(basename "$desktop")." + fi +done + echo "Demoted $LIB to $FALLBACK_DIR; repacking." tool="$work/appimagetool" @@ -140,7 +210,14 @@ curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" chmod +x "$tool" # --appimage-extract-and-run: CI runners generally have no FUSE. -ARCH=x86_64 "$tool" --appimage-extract-and-run "$root" "$appimage" >/dev/null +# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image. +ARCH=x86_64 "$tool" --appimage-extract-and-run \ + -u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null +chmod +x "$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" chmod +x "$appimage" # The guards are the test. Each one is a way the repack could look like it @@ -156,4 +233,25 @@ fail() { echo "FAILED: $1" >&2; exit 1; } grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook." [ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c." -echo "OK: $appimage now prefers the host $LIB, with a bundled fallback." +# 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" ] \ + || fail "AppStream metadata did not make it into the image."; } + +# The update string is the difference between adoptable and updatable. It +# lives in the image's own `.upd_info` ELF section, not in the .zsync — the +# .zsync only records a *relative* filename, which a client resolves against +# 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." + +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" \ + || 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." diff --git a/scripts/publish-update-channel.sh b/scripts/publish-update-channel.sh new file mode 100755 index 0000000..8126bcc --- /dev/null +++ b/scripts/publish-update-channel.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Publish the AppImage and its .zsync to the fixed `linux-latest` tag on the +# GitHub mirror — the URL every installed copy checks for updates. +# +# This exists because the update URL has to be one that never moves. +# `releases/latest` does move: it follows whatever release is newest, and the +# Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including +# the `-win` and `-mac` tags that carry no AppImage. Pointing a million +# installed copies at a URL that can resolve to a release with no AppImage in +# it is a failure that shows up on users' machines and nowhere else. +# +# So this tag holds exactly two files, replaced in place on every release. +# The versioned per-release artifacts are published separately and are what a +# human downloads; this is what the updater reads. +# +# 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 + +set -euo pipefail + +REPO="shadowdao/triple-c" +TAG="linux-latest" +API="https://api.github.com/repos/$REPO" +ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync") + +: "${GH_PAT:?GH_PAT is required to publish the update channel}" +dir="${1:?usage: publish-update-channel.sh }" +cd "$dir" + +for asset in "${ASSETS[@]}"; do + [ -e "$asset" ] || { echo "Missing $asset in $dir" >&2; exit 1; } +done + +gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; } + +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)" + +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 ' +import json +print(json.dumps({ + "tag_name": "'"$TAG"'", + "name": "Linux update channel", + "body": "Rolling AppImage build that Triple-C’s 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", +}))')")" + 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 ' +import sys, json +keep = set(sys.argv[1:]) +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 + +for asset in "${ASSETS[@]}"; do + echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))" + curl -sf -X POST \ + -H "Authorization: Bearer $GH_PAT" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$asset" \ + "https://uploads.github.com/repos/$REPO/releases/$release_id/assets?name=$asset" >/dev/null +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. +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" +done + +echo "OK: $TAG updated." -- 2.52.0