Two defects in the AppImage update channel added by #51. Both are live in v0.4.20 and v0.4.21.
1. The channel tag does not survive, so the channel is already dead
publish-update-channel.sh did everything right. From the build-linux job log:
==> Looking for the linux-latest release
==> Creating it
==> Uploading Triple-C_x86_64.AppImage (83M)
==> Verifying the published URLs
200 .../releases/download/linux-latest/Triple-C_x86_64.AppImage
200 .../releases/download/linux-latest/Triple-C_x86_64.AppImage.zsync
OK: linux-latest updated.
That is 2026-09-03T00:38:47Z. The tag is now gone, and every installed copy is checking a 404.
Cause: Gitea push-mirrors this repo to github.com/shadowdao/triple-c.git on a 4-hour interval with sync_on_commit: true. A mirror push deletes remote refs that have no local counterpart. linux-latest was created by GitHub's release API and never existed as a Gitea tag, so the 13:04:50Z mirror run removed it. Versioned tags were never affected because create-tag creates them in Gitea first.
Fix: anchor the tag in Gitea, and do it before creating the GitHub release rather than after, so there is no window where the two disagree. A missing Gitea tag fails the step rather than warning, because it is the only thing keeping the channel alive.
What this says about the verification in #51. I presented "the publisher verifies its own URLs return 200" as closing the risk. It does not. Publishing correctly is not evidence the channel still works, and a check that runs at publish time cannot catch a failure that arrives twelve hours later. The durable property is the Gitea tag, so that is what is now asserted.
2. Every release shipped the AppImage twice
v0.4.21, on both Gitea and the mirror:
asset
size
Triple-C_0.4.21_amd64.AppImage
86,686,200 bytes
Triple-C_x86_64.AppImage
86,686,200 bytes
Byte-identical — one is a cp of the other. The channel's stable-named copy sat beside the versioned one, where the release job's *.AppImage glob picked it up, and sync-to-github then copied both to the mirror. 80 MB of duplicate per release, under a name that reads like a different build. That is how it was spotted.
Fix: the channel pair moves to bundle/appimage/update-channel/, out of the glob's reach, and a guard fails the build if more than one AppImage is left beside the release.
An appimagetool quirk found on the way
zsyncmake writes the .zsync into the working directory, not beside the image it describes. Moving the output into a subdirectory therefore separated the pair, and the existing assertion caught it — the .zsync is now collected explicitly rather than assumed in place.
Verification
Against the real v0.4.19 artifact:
exactly one AppImage beside the release; the channel pair in its own directory
the embedded update string still resolves to the fixed tag
the #50 wayland fallback intact — library demoted, fallback copy and hook present
the duplicate guard bites: planting a second AppImage fails the script with expected 1 AppImage beside the release, found 2
both publisher preconditions refuse rather than half-publishing (GH_PAT, GITEA_TOKEN)
set -e does not abort on a failing && list — checked directly, since both scripts rely on that pattern for their optional branches
Not verifiable here: that the anchored tag actually survives a mirror run. That takes one release plus a 4-hour interval, and it is the thing to check afterwards — if releases/download/linux-latest/Triple-C_x86_64.AppImage.zsync still returns 200 the morning after a release, this is fixed.
Separately, not in this PR
The duplicate assets already published to v0.4.20 and v0.4.21 are still there (~330 MB across Gitea and the mirror) and want deleting by hand.
Two defects in the AppImage update channel added by #51. Both are live in v0.4.20 and v0.4.21.
## 1. The channel tag does not survive, so the channel is already dead
`publish-update-channel.sh` did everything right. From the `build-linux` job log:
```
==> Looking for the linux-latest release
==> Creating it
==> Uploading Triple-C_x86_64.AppImage (83M)
==> Verifying the published URLs
200 .../releases/download/linux-latest/Triple-C_x86_64.AppImage
200 .../releases/download/linux-latest/Triple-C_x86_64.AppImage.zsync
OK: linux-latest updated.
```
That is `2026-09-03T00:38:47Z`. The tag is now gone, and every installed copy is checking a 404.
**Cause:** Gitea push-mirrors this repo to `github.com/shadowdao/triple-c.git` on a 4-hour interval with `sync_on_commit: true`. A mirror push deletes remote refs that have no local counterpart. `linux-latest` was created by GitHub's release API and never existed as a Gitea tag, so the `13:04:50Z` mirror run removed it. Versioned tags were never affected because `create-tag` creates them in Gitea first.
**Fix:** anchor the tag in Gitea, and do it *before* creating the GitHub release rather than after, so there is no window where the two disagree. A missing Gitea tag fails the step rather than warning, because it is the only thing keeping the channel alive.
**What this says about the verification in #51.** I presented "the publisher verifies its own URLs return 200" as closing the risk. It does not. Publishing correctly is not evidence the channel still works, and a check that runs at publish time cannot catch a failure that arrives twelve hours later. The durable property is the Gitea tag, so that is what is now asserted.
## 2. Every release shipped the AppImage twice
v0.4.21, on both Gitea and the mirror:
| asset | size |
|---|---|
| `Triple-C_0.4.21_amd64.AppImage` | 86,686,200 bytes |
| `Triple-C_x86_64.AppImage` | 86,686,200 bytes |
Byte-identical — one is a `cp` of the other. The channel's stable-named copy sat beside the versioned one, where the release job's `*.AppImage` glob picked it up, and `sync-to-github` then copied both to the mirror. 80 MB of duplicate per release, under a name that reads like a different build. That is how it was spotted.
**Fix:** the channel pair moves to `bundle/appimage/update-channel/`, out of the glob's reach, and a guard fails the build if more than one AppImage is left beside the release.
## An appimagetool quirk found on the way
`zsyncmake` writes the `.zsync` into the *working directory*, not beside the image it describes. Moving the output into a subdirectory therefore separated the pair, and the existing assertion caught it — the `.zsync` is now collected explicitly rather than assumed in place.
## Verification
Against the real v0.4.19 artifact:
- exactly one AppImage beside the release; the channel pair in its own directory
- the embedded update string still resolves to the fixed tag
- the #50 wayland fallback intact — library demoted, fallback copy and hook present
- **the duplicate guard bites**: planting a second AppImage fails the script with `expected 1 AppImage beside the release, found 2`
- both publisher preconditions refuse rather than half-publishing (`GH_PAT`, `GITEA_TOKEN`)
- `set -e` does not abort on a failing `&&` list — checked directly, since both scripts rely on that pattern for their optional branches
**Not verifiable here:** that the anchored tag actually survives a mirror run. That takes one release plus a 4-hour interval, and it is the thing to check afterwards — if `releases/download/linux-latest/Triple-C_x86_64.AppImage.zsync` still returns 200 the morning after a release, this is fixed.
## Separately, not in this PR
The duplicate assets already published to v0.4.20 and v0.4.21 are still there (~330 MB across Gitea and the mirror) and want deleting by hand.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Two defects in the update channel, both visible in 0.4.20 and 0.4.21.
**The channel tag does not survive.** `publish-update-channel.sh` created the
GitHub release, uploaded both assets and verified each URL returned 200 — the
job log shows it succeeding at 00:38. By 13:04 the tag was gone and every
installed copy was checking a 404.
Gitea push-mirrors this repo to GitHub every four hours, and a mirror push
deletes remote refs with no local counterpart. `linux-latest` was created by
GitHub's release API and never existed as a Gitea tag, so the mirror removed
it. Versioned tags were never affected because `create-tag` creates them in
Gitea first.
So the tag is now anchored in Gitea, and before the GitHub release rather than
after, so there is no window where the two disagree. Its absence fails the
step instead of warning, because it is the only thing keeping the channel
alive. Worth stating plainly: publishing correctly is not evidence the channel
still works, and the verification that passed at 00:38 could not have caught a
failure that arrives twelve hours later.
**Every release carried the AppImage twice.** The channel's stable-named copy
sat beside the versioned one, where the release job's `*.AppImage` glob picked
it up — so v0.4.21 published `Triple-C_0.4.21_amd64.AppImage` and
`Triple-C_x86_64.AppImage`, byte-identical at 86,686,200 bytes each, and
`sync-to-github` copied both to the mirror. 80 MB of duplicate per release,
under a name that reads like a different build. That is how it was noticed.
The channel pair now lives in `bundle/appimage/update-channel/`, out of the
glob's reach, and a guard fails the build if more than one AppImage is left
beside the release. Verified by planting a second one: it fails.
One appimagetool quirk found while moving it — 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. The existing guard caught that too.
Verified against the real 0.4.19 artifact: exactly one AppImage at top level,
the channel pair in its own directory, update string still resolving to the
fixed tag, and the wayland fallback intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
An adversarial review of the previous commit found six real problems and
corrected one of my claims. Taking all of it.
**The anchoring could kill the channel it exists to protect.** It did
DELETE-then-POST so the tag would name the current build. If the POST failed
for any transient reason the script aborted having already deleted the anchor a
previous run put there, and the next mirror run pruned GitHub's copy — a
transient Gitea error converting a healthy channel into a dead one, which is
strictly worse than the step not existing. There was also a real window
between the two calls with no tag at all.
The DELETE bought nothing. The update string resolves the tag by *name* and the
assets hang off the release object, so nothing about the channel depends on
which commit the tag points at; moving it changes only the source-zip link.
It existed solely to get past a 409, since Gitea's POST /tags has no force
semantics. Now the tag is created if absent and otherwise left alone, which
removes the window too.
**My "no window where the two disagree" claim was wrong, and it is the third
time in this area I have asserted something I had not established.** The
release POST sets no `target_commitish`, so GitHub creates its tag at its own
default-branch HEAD, not at `GITEA_SHA`; the two agree only because
`sync_on_commit` pushes main minutes earlier. And the DELETE actively created
the window. What the ordering genuinely buys is narrower: if anchoring fails,
the script aborts before creating a GitHub release that would be orphaned.
**Orphaned drafts were invisible to the release lookup.** GitHub demotes a
release to a draft when its tag is deleted, and `/releases/tags/` never returns
drafts — precisely the state every mirror run left behind. The by-tag lookup
reported "absent" while 86 MB drafts accumulated, one per release. The lookup
now reads the authenticated list, republishes the newest, and deletes the rest.
**A guard that could not catch what it named.** The update-info assertion was
a substring match on the tag, so it passed for a wrong host, path, filename or
transport — verified: an `evil.example.com/.../linux-latest/...` string passes
the old check and fails the new one. Now a fixed full-string match.
Also from the review: an absent bundled library no longer exits early, because
that skipped the metadata *and* left `update-channel/` uncreated, killing the
publish step on a missing directory and taking the tag and mirror jobs with it;
the Categories guard asserts the absence of an empty value rather than the
presence of any filled one; the channel directory is cleared before use so a
stale zsync cannot satisfy an existence check while describing the previous
build; the AppImage count uses a glob array, since `ls | wc -l` aborted under
pipefail before the message it promised could print; uploads carry the
retry/http1.1 hardening this repo's other upload steps already learned to
need; verification compares served size against built size, because a status
code only proves something is served; and the release workflow now fails on
empty artifacts instead of publishing a release with no AppImage.
The metainfo file is installed as `Triple-C.appdata.xml`. appimagetool derives
the name it looks for from the .desktop basename, so under the id-based name it
warned the metadata was missing on every build while this script reported it
present. Now it prints "AppStream upstream metadata found in
usr/share/metainfo/Triple-C.appdata.xml" — the AppStream id inside the file is
unchanged and is what identifies the component.
Two review hypotheses did not hold and nothing was changed for them: `set -e`
does not abort on a failing `&&` list mid-script, and my claim of a `trap`
reassignment was wrong — there is one trap, installed once.
Verified against the real 0.4.19 artifact: exit 0, one AppImage beside the
release, channel pair in its own directory, appimagetool reporting the metadata
found, and the wayland fallback intact. Guards exercised individually — the
duplicate one bites, the exact-match one rejects an impostor carrying the tag,
the empty directory reports cleanly, and all four publisher preconditions
refuse rather than half-publishing. Header parsing for the size check was
tested against a real redirecting GitHub asset URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Two defects in the AppImage update channel added by #51. Both are live in v0.4.20 and v0.4.21.
1. The channel tag does not survive, so the channel is already dead
publish-update-channel.shdid everything right. From thebuild-linuxjob log:That is
2026-09-03T00:38:47Z. The tag is now gone, and every installed copy is checking a 404.Cause: Gitea push-mirrors this repo to
github.com/shadowdao/triple-c.giton a 4-hour interval withsync_on_commit: true. A mirror push deletes remote refs that have no local counterpart.linux-latestwas created by GitHub's release API and never existed as a Gitea tag, so the13:04:50Zmirror run removed it. Versioned tags were never affected becausecreate-tagcreates them in Gitea first.Fix: anchor the tag in Gitea, and do it before creating the GitHub release rather than after, so there is no window where the two disagree. A missing Gitea tag fails the step rather than warning, because it is the only thing keeping the channel alive.
What this says about the verification in #51. I presented "the publisher verifies its own URLs return 200" as closing the risk. It does not. Publishing correctly is not evidence the channel still works, and a check that runs at publish time cannot catch a failure that arrives twelve hours later. The durable property is the Gitea tag, so that is what is now asserted.
2. Every release shipped the AppImage twice
v0.4.21, on both Gitea and the mirror:
Triple-C_0.4.21_amd64.AppImageTriple-C_x86_64.AppImageByte-identical — one is a
cpof the other. The channel's stable-named copy sat beside the versioned one, where the release job's*.AppImageglob picked it up, andsync-to-githubthen copied both to the mirror. 80 MB of duplicate per release, under a name that reads like a different build. That is how it was spotted.Fix: the channel pair moves to
bundle/appimage/update-channel/, out of the glob's reach, and a guard fails the build if more than one AppImage is left beside the release.An appimagetool quirk found on the way
zsyncmakewrites the.zsyncinto the working directory, not beside the image it describes. Moving the output into a subdirectory therefore separated the pair, and the existing assertion caught it — the.zsyncis now collected explicitly rather than assumed in place.Verification
Against the real v0.4.19 artifact:
expected 1 AppImage beside the release, found 2GH_PAT,GITEA_TOKEN)set -edoes not abort on a failing&&list — checked directly, since both scripts rely on that pattern for their optional branchesNot verifiable here: that the anchored tag actually survives a mirror run. That takes one release plus a 4-hour interval, and it is the thing to check afterwards — if
releases/download/linux-latest/Triple-C_x86_64.AppImage.zsyncstill returns 200 the morning after a release, this is fixed.Separately, not in this PR
The duplicate assets already published to v0.4.20 and v0.4.21 are still there (~330 MB across Gitea and the mirror) and want deleting by hand.
🤖 Generated with Claude Code
https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm