Skip to content

Add a release mirror with a GitHub fallback - #2795

Merged
Soph merged 16 commits into
mainfrom
soph/github-independent-release
Oct 20, 2026
Merged

Soph merged 16 commits into
mainfrom
soph/github-independent-release

Conversation

@Soph

@Soph Soph commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

https://entire.io/gh/entireio/cli/trails/1591

Why

Every install and update path resolved a version and fetched bytes from GitHub alone, so a GitHub outage made an already-published release unobtainable — not just unreleasable. This publishes the same artifacts to a second origin we own and reads ours first, with GitHub as the fallback.

Reading ours first is deliberate: a fallback that only runs during an incident is a fallback nobody has tested. This way every install exercises the path.

What reads what

versioncheck.versionOrigins is an ordered chain (install.entire.com, then api.github.com) walked by fetchLatestForChannel. scripts/install.sh walks the same two origins for archives and checksums. httpTimeout (2s) bounds the whole chain, and each candidate gets an equal share of what is left — so a primary that hangs cannot swallow the budget and starve the fallback, which would leave update checks worse off than before the second origin existed.

Two rules, both borrowed from checkpoint/remote/checkpoint_ref.go:

  1. Fail open. Every origin failing is reported as one error, never as "you are up to date" or "there is nothing to install".
  2. A definite bad answer aborts; an unreachable one advances. A checksum mismatch exits rather than falling through — bytes that disagree with their own origin's checksums mean that origin is corrupt, and retrying elsewhere would hide it. Bad metadata goes the other way and advances: while discovering a version another origin may simply know better.

Publishing inverts rule 1 on purpose. publish-channel-manifest.sh fails closed — if it cannot read the current pointer it refuses to publish, because its only question is "can I prove this move is forward". The write is conditional on the object the guard inspected (--if-match), so a concurrent publisher cannot leave the pointer at the older version.

What gets published

Key Contents
v<version>/ archives and checksums.txt, immutable
channels/{stable,nightly}.json the channel pointer, max-age=60
installers/install.{sh,ps1} the install scripts, max-age=300, stable only
scoop/entire.json the Scoop manifest with URLs repointed at this origin, stable only

Order inside release.yml is load-bearing: artifacts, then the macOS Developer ID check, then the pointer, then the shared keys. The shared keys come after the pointer — which looks backwards — because they are not versioned, so publishing them first means a release the pointer refuses leaves them rolled back with nothing advanced.

mod_timestamp is pinned to the commit so rebuilding a tag is byte-identical. Without it a retried release would re-upload different bytes and a different checksums.txt to URLs cached immutable for a year, and a mixed edge cache is a hard install failure under rule 2.

Windows

scoop install https://install.entire.com/scoop/entire.json installs with no bucket and therefore no git, and scoop update re-fetches that URL and upgrades in place. Both verified against Scoop v0.6.0 on a Windows VM. The bucket manifest keeps its GitHub URLs, so the ordinary scoop install entire path is unchanged — this is an additional route, not a replacement that puts every Scoop install behind one origin.

install.ps1 itself is not covered: it is mirrored so it can be fetched, but it still resolves versions through api.github.com and downloads archives from GitHub releases. That is the remaining Windows gap and it is recorded in the design doc.

Turning it on

The publishing side is inert until ENTIRE_RELEASE_BUCKET is set; deleting it and rerunning restores exactly the old behaviour. The variables are now set and install.entire.com is serving, so the next stable release is the first that mirrors. After it, check that channels/stable.json names the new version, v<version>/checksums.txt returns 200 with an immutable cache-control, and installers/install.sh and scoop/entire.json return 200.

The gate tests the variable for non-emptiness, not presence: release.yml always exports it, an unset Actions variable exports as empty, and isEnvSet alone would have enabled the pipe with an empty bucket and no credentials — failing every release instead of staying inert.

The Homebrew cask and the bucket's Scoop manifest still point at GitHub on purpose. Repointing the cask is the highest-value follow-up once this is proven, since the sha256 stays pinned in the manifest.

Testing

scripts/test-release-origins.sh (mise run test:release-origins, and a CI job) runs both scripts against local fake origins — version discovery, download and verification from our origin, fallback to GitHub for both, a checksum mismatch aborting rather than falling through, a hung origin still failing over, --version pinning, unusable manifest metadata advancing, the pointer's routing, monotonicity, fail-closed read and conditional write, and the installer and Scoop manifest publishing. 86 assertions, hermetic, and it refuses to run an installer still aimed at a public origin.

Verified locally: mise run check green (93 packages, both canary legs), go vet for darwin/linux/windows. The Windows installer checks run in CI.

🤖 Generated with Claude Code

Soph and others added 2 commits October 16, 2026 13:54
Every install and update path resolved a version and fetched bytes from
GitHub alone, so a GitHub outage made an already-published release
unobtainable. Publish the same artifacts to install.entire.com as well,
and read ours first with GitHub as the fallback, so the second origin is
exercised by every install rather than only during an incident.

Two rules borrowed from checkpoint/remote/checkpoint_ref.go: the chain
fails open, reporting every origin failing as one error and never as "you
are up to date"; and a definite bad answer aborts while an unreachable one
advances, so install.sh exits on a checksum mismatch instead of quietly
retrying elsewhere. An install never mixes origins -- the archive and the
checksums.txt that vouches for it always come from the same place.

httpTimeout bounds the whole origin chain rather than each origin, so
adding a fallback cannot regress worst-case latency.

The publishing side is inert until the ENTIRE_RELEASE_BUCKET repository
variable is set, so this lands before the bucket exists and a release
behaves exactly as it does today. The Homebrew cask and Scoop manifest
still point at GitHub on purpose: a published manifest must not name a
host that is not serving yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M526KRY97KX6FD8CH4JWEABY
Every published install URL moves to entire.com: the one-liners in the
README, the commands the CLI prints when it nudges an update
(versioncheck_unix.go and windowsInstallURL), the installers' pointers at
each other, and the two places CI installs the CLI. Our own CI therefore
exercises the new URL on every run rather than leaving it to real users.

The entire.io URLs are NOT retired. They become permanent redirects, for
the reason that the published one-liner self-heals but its copies do not:
an old binary prints the .io URL, that URL serves the installer, and the
binary it installs prints .com from then on, so the installed population
converges after one upgrade each. What never heals is everything with no
upgrade path -- third-party Dockerfiles and CI configs that baked in the
.io one-liner, the CHANGELOG entry that published it, and docs we do not
control. Those keep arriving indefinitely, so the redirect is permanent
rather than a deprecation window.

The CHANGELOG keeps the .io one-liner it shipped with: it records what
was published at the time, not what to run today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M528AYFKG2VDJ7P3QY7JHE78
Copilot AI balanced review requested due to automatic review settings October 16, 2026 11:56
@Soph
Soph requested a review from a team as a code owner October 16, 2026 11:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Stalled downloads can block fallback, malformed manifests can suppress valid releases, and publication failures or races can roll channel pointers backwards.

5 open findings
What changed in this PR

Adds mirror-first release discovery and Unix downloads to reduce dependence on GitHub, with opt-in publishing ahead of infrastructure deployment.

Changes:

  • Adds origin fallback, version pinning, and channel manifests.
  • Gates mirror publishing and publishes channel pointers after signing verification.
  • Moves installer URLs to entire.com and adds fallback tests.
File Description
scripts/​test-release-origins.sh Tests fallback and channel publishing.
scripts/​publish-channel-manifest.sh Publishes channel pointers with version guards.
scripts/​install.sh Adds mirror-first downloads and version pinning.
scripts/​install.ps1 Updates Unix installer guidance.
README.md Updates installation and outage guidance.
mise.toml Registers release-origin tests.
docs/​architecture/​release-origins.md Documents rollout and infrastructure requirements.
cmd/​entire/​cli/​versioncheck/​versioncheck.go Adds ordered version origins and manifest parsing.
cmd/​entire/​cli/​versioncheck/​versioncheck_windows.go Updates Windows installer URL.
cmd/​entire/​cli/​versioncheck/​versioncheck_windows_test.go Updates Windows command expectations.
cmd/​entire/​cli/​versioncheck/​versioncheck_unix.go Updates Unix installer URLs.
cmd/​entire/​cli/​versioncheck/​versioncheck_unix_test.go Updates Unix command expectations.
cmd/​entire/​cli/​versioncheck/​versioncheck_test.go Tests manifest discovery and fallback.
cmd/​entire/​cli/​versioncheck/​types.go Defines manifests and origin settings.
cmd/​entire/​cli/​versioncheck/​autoupdate_windows_test.go Updates Windows update expectations.
cmd/​entire/​cli/​versioncheck/​autoupdate_test.go Updates automatic installer expectations.
CLAUDE.md Links release-origin documentation.
.goreleaser.yaml Adds opt-in S3 artifact publishing.
.github/​workflows/​release.yml Adds upload credentials and pointer publishing.
.github/​workflows/​nightly-e2e.yml Uses new installer URLs.
.github/​workflows/​ci.yml Requires release-origin tests.
.ferrata/​setup Uses the new installer URL.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/publish-channel-manifest.sh Outdated
Comment thread scripts/publish-channel-manifest.sh Outdated
Comment thread cmd/entire/cli/versioncheck/versioncheck.go
Comment thread scripts/install.sh Outdated
Comment thread scripts/install.sh Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit badfaa7. Configure here.

Comment thread .goreleaser.yaml Outdated
Soph and others added 8 commits October 16, 2026 18:04
The monotonic guard sent every `aws s3 cp` error to /dev/null and treated
it as "no pointer exists", so permission denied, throttling and network
errors all skipped the backwards-move check. A re-dispatch of an old tag
during a transient S3 error would then publish it unchecked, regressing
the channel for every install and update check.

Only a definite not-found now counts as an empty channel. Any other read
error aborts with the stderr quoted. A successful read that names no
version aborts too: a manifest that exists but cannot be parsed is not an
empty channel, and overwriting it unchecked moves the channel by an
unknown amount.

This inverts the rule the read chains follow, deliberately. A reader needs
an answer and a confident wrong one is the worse outcome, so it tries
every origin and fails open. The guard only has to answer "can I prove
this move is forward", and a pointer it could not read cannot prove it.

The version extraction needed `|| true` as well: under `set -euo
pipefail` a manifest without a "version" key tripped grep's exit status
and killed the script before the explicit check could report why.

The aws stub now distinguishes a 404 from a read failure, since the two
are no longer handled the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M52QB3YBXMEGTZ15FH9Q2EC9
The binaries survived a GitHub outage but the one-liner did not:
entire.com/install.{sh,ps1} redirects to raw.githubusercontent.com, so a
new install still failed at hop zero. Mirroring the scripts to
installers/ closes the last GitHub dependency in the install path, and
fixes a version-skew bug at the same time -- the redirect serves main's
installer, never the released one.

Stable releases only. These two keys are overwritten on every release
rather than versioned, so a nightly publishing there would replace the
installer stable users fetch; nightly users run the same stable installer
with --channel nightly. They get max-age=300 rather than the artifacts'
immutable TTL for the same reason: a long max-age on an overwritten key
pins a stale installer in every edge cache for its lifetime.

The step runs after the macOS signing check and before the channel
pointer. An installer newer than the pointer is harmless; the reverse is
not.

The upload lives in a script rather than inline YAML so the hermetic
harness can exercise it against the existing aws stub.

The design doc now describes the origin as built: a private bucket read
through the Worker's own OIDC role, not the anonymous-read or CloudFront
shapes it was specified with. Infra repoints the entire.com redirects at
installers/ only once a release has actually uploaded those keys --
repointing first would turn every install into a 404 with no fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M52QBHK76D45ZW2YMQ4NSFPE
**The mirror was not inert.** release.yml always exports
ENTIRE_RELEASE_BUCKET, and an Actions variable that is unset exports as an
empty string, so `isEnvSet` was true on every release. The blobs pipe
therefore enabled with an empty bucket and no credentials --
configure-aws-credentials is correctly skipped -- failing every release
rather than staying inert, which is the property the whole change rests
on. Both gates now test non-emptiness; `.Env.<missing>` renders empty
rather than erroring, so one expression covers absent and empty alike.
Verified by rendering the template unset, empty and set.

**An unusable manifest ended the chain.** isNightly is false for an
unparseable string, so a version like "not-a-version" passed the stable
channel check. The chain then stopped without trying GitHub, and
isOutdated reported no update because an invalid version sorts below
every valid one -- a corrupt pointer on our own origin would have
switched update checks off. install.sh had the same hole from the other
end: both origins were asked for a release that does not exist, failing
the install even with a healthy GitHub release. Both sides now require
the canonical vX.Y.Z form. semver.IsValid alone is not enough: it accepts
"v1.2" and "v1" with an implied patch, which resolve to artifact prefixes
that were never published.

Bad metadata advances to the next origin where bad bytes abort, and the
asymmetry is deliberate. While discovering a version another origin may
simply know better; once a checksum is being compared the version is
settled and only the bytes are in doubt.

**A stalled download never reached the fallback.** --connect-timeout
bounds connection setup only, so an origin that accepts the connection
and sends nothing stalled forever -- measured at 40s against a blackhole,
ending only when the fake server died. Downloads now carry
--speed-limit/--speed-time, which aborts a stalled transfer without
capping a slow but progressing one the way --max-time would, and which
fires before any headers arrive.

The blackhole test was masking this: it rewrote the download options to
add a --max-time production does not have, so it proved the fallback
works given a timeout rather than proving we had one. It now shortens the
real policy instead.

**The pointer write was a read-modify-write with no serialization.** Two
overlapping publishers could both read the same current version, both
pass the monotonic guard, and leave the pointer wherever the slower write
landed. release.yml gains a concurrency group, and the write is
conditional on the object the guard inspected -- --if-match, or
--if-none-match for a first publish -- which also covers runs outside the
workflow. That required s3api in place of `aws s3 cp`, which cannot
surface an ETag. A conflict aborts rather than retrying: the guard exists
to refuse unproven moves.

The test stub grows s3api support and a stale-ETag hook so the conflict
branch is actually exercised. 50 assertions to 64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M52V9X7H1WJG9J6CMQ6F9FDS
Two problems with the ordering added alongside the conditional pointer
write.

The concurrency group could cancel a queued release. GitHub keeps at most
one pending run per group and cancels the previously pending one;
cancel-in-progress: false does not prevent that, it only protects the run
already in progress. Because mirror-nightly pushes a nightly tag, a single
stable release can enqueue a second run and cancel a queued hotfix by
itself. Dropping a release outright is worse than the race the group was
meant to close, and the pointer's conditional write already closes that
race -- for runs started by hand as well as by the workflow. So the group
goes, with the reasoning recorded where someone would otherwise re-add it.

The installers were published before the pointer was proven moveable.
Those keys are shared across versions, so re-dispatching an older tag
uploaded the older scripts and only then hit the monotonic guard, failing
the job with the installers rolled back and nothing advanced. They now go
after the pointer: the installer is version-independent, so a pointer that
moved first is harmless, while the reverse leaves a silent rollback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M530TFSFE70RG1Z7K64HPFZD
One 2s deadline was shared by the whole chain and handed to each
candidate in turn, so a primary that hangs consumed all of it and GitHub
was never tried. The realistic trigger is a firewall that blackholes
rather than refuses install.entire.com: the connect sits there, the budget
expires, and the update check stops working indefinitely -- strictly worse
than before the second origin existed, because GitHub used to answer.

Each candidate now gets an equal share of whatever is left. That is
division of one absolute deadline, not a per-origin timeout multiplied
into a ceiling that never binds, so the chain still cannot exceed
httpTimeout. A new test pins it: a primary that accepts the connection and
never answers, a healthy fallback, and an assertion that the whole call
stays inside the budget.

The comment in types.go claimed a hang "degrades to exactly today's
behaviour". It did not, and the replacement says so rather than quietly
dropping the claim.

An already-cancelled context also reported "no version origins
configured", which sends anyone reading a debug log after the wrong thing.
It now reports the cancellation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M530VYWECE2YWKTD1VB9GYX0
The three places that decide what a channel contains disagreed, and an rc
tag fell straight through the gap. The publisher routes any prerelease to
nightly, but readers identify nightly by the "nightly" prerelease segment
-- so v1.2.3-rc.1 was published to nightly, where both readers reject it,
leaving nightly silently stuck on the previous version with nothing
surfaced but a skipped check. The Go reader would have accepted that same
version on stable, where it is never published, while install.sh rejects
any prerelease there.

Now: a nightly prerelease goes to nightly, no prerelease goes to stable,
and any other prerelease is refused at publish time rather than routed
somewhere no reader will read it. The Go reader treats stable as "no
prerelease at all" instead of "not a nightly", matching install.sh.

The two readers also disagreed about the manifest itself. The CLI preferred
tag and fell back to version while install.sh read only version, and the
CLI checked the declared channel field while install.sh ignored it.
install.sh now does both, so a manifest published to the wrong key is
refused by whichever reader finds it first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M530WY2N6376VE4GK19P8KK4
Versioned objects are uploaded with a one-year immutable cache, which is
correct only if the bytes at a URL never change. Without mod_timestamp the
archives carry build-time mtimes, so rebuilding the same tag -- a retry
after a downstream publishing failure -- produces different bytes and a
different checksums.txt at URLs that no cache will revalidate for a year.

An edge holding the old archive beside the new checksums.txt then makes
install.sh abort, because a checksum mismatch deliberately does not fall
back to the other origin. Pinning the mtime to the commit makes rebuilds
identical, so the immutable TTL is safe rather than hopeful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M530XEE5JJPFYCRX7P6CRNFS
publish-installers.sh said mirroring the scripts "closes the last GitHub
dependency in the install path". It does not. install.ps1 is mirrored so
it can be fetched during an outage, but it still resolves versions through
api.github.com, downloads archives from GitHub releases, and adds the
Scoop bucket by git URL.

Windows is therefore still outage-affected, and it is now the first item
under Remaining work rather than an implication nobody wrote down. Scoop
in particular is a git dependency that two-origin HTTP cannot fix, so it
needs its own answer rather than a copy of this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M530XFQNBSAC4D9ERKG6DP5W
@Soph Soph changed the title Serve releases from a second origin so installs survive a GitHub outage Add a release mirror with a GitHub fallback Oct 16, 2026
Soph and others added 3 commits October 17, 2026 15:41
The commit that claimed to make the channel tests agree left a fourth
one, and made it the loosest. The publisher matched *-*nightly*, which
accepts any prerelease containing "nightly" anywhere, while install.sh
and the GitHub release scan both require the literal -nightly. segment
that create-nightly-tag.sh produces.

The readers do not merely both reject the near misses -- they disagree
about them. isNightly used a substring test, so v0.5.4-nightly,
v0.5.4-prenightly and v0.5.4-rc.nightly.1 were published to the nightly
channel, accepted by the CLI and rejected by install.sh. The CLI would
announce an update that the installer then refuses to install, which is
worse than a channel that merely stalls.

All four now test the same segment: the publisher's glob narrows to
*-nightly.*, isNightly becomes a -nightly. prefix check, and the two that
were already strict are unchanged, so the loose ones moved to meet them
rather than the reverse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M551HZR2ZK52BCA4RA6NDMSK
Reordering the installer upload after the channel pointer updated
release.yml's comment and left two other places asserting the old order:
the doc's numbered step list, which still had the installers at step 3 and
the pointer "flipped last" at step 4, and publish-installers.sh's header,
which still said to run it before the pointer.

The workflow's ordering is the correct one, so the other two move to it.
The doc section is labelled load-bearing and said the opposite of the
code, which is the kind of contradiction someone resolves in the wrong
direction -- reintroducing the rollback this ordering exists to prevent.
The doc now says outright that the order looks backwards, so it reads as
deliberate rather than accidental.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M5A59ASV9W0K8JQCPSV5ZKDK
Scoop does not require git. `scoop install <url-to-manifest.json>`
installs with no bucket added, records that URL as the app's source, and
`scoop update` re-fetches it and upgrades in place -- both verified
against Scoop v0.6.0 on Windows. The earlier claim here that Scoop was a
git dependency two-origin HTTP could not fix was wrong, and it was
written into the design doc and this script's header.

So dist/scoop/entire.json is published to scoop/entire.json with its
artifact URLs repointed at this origin. Scoop has no fallback of its own,
so a mirrored manifest naming GitHub would depend on the thing it exists
to route around. The rewrite fails the release if it matches nothing: a
manifest that still named GitHub would upload cleanly and only be
discovered during the outage it is for.

The bucket manifest keeps its GitHub URLs. The ordinary `scoop install
entire` path is unchanged, so this is an additional route rather than one
that makes every Scoop install depend on a single origin.

Also corrects a comment in .goreleaser.yaml, disproved while running the
snapshot build that located the manifest. It claimed `.Env.<missing>`
renders empty rather than erroring; GoReleaser is not consistent about
this -- build ldflags fail with "map has no entry for key" while other
templates render empty -- so the comment now explains why both halves of
the guard are kept instead of resting on which engine the field uses.

One trap worth knowing: Scoop rejects a manifest written with a UTF-8
BOM, and reports it as "Error parsing JSON" followed by a suggestion to
run `scoop bucket add`, which reads like URL installs are unsupported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M5A59VF1NYPX4WRMKPQ9H93C
gtrrz-victor
gtrrz-victor previously approved these changes Oct 19, 2026
Soph and others added 2 commits October 19, 2026 19:08
Review feedback: if the brew and Scoop recipes are themselves hosted on
GitHub, repointing the download URLs inside them does not move the
channel off GitHub. That is right, and the doc said the opposite -- it
called this "the highest-value follow-up", which was written thinking
about download bytes rather than outage behaviour.

Both manifests live in GitHub git repos, so `brew update` and a bucket
refresh need GitHub whatever the artifact URL says. Repointing would only
help someone who refreshed metadata before the outage and has not
installed yet, and it would put every cask and bucket install behind a
single origin with no fallback of its own.

The conclusion generalises past the templates: the complete path is to
mirror a manifest whose URLs name this origin, so metadata and bytes come
from one place. That is what scoop/entire.json does. Homebrew has no
clean equivalent, so the cask stays outage-affected and install.sh
remains the GitHub-free path on macOS -- stated plainly rather than
implying a parity we do not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M5AJ5JRJ836HNXWFRQF0MYKN
Five findings from one review pass, all the same shape: a producer and a
consumer disagreeing about what counts as a release.

The GitHub fallback in install.sh filtered nightly tags with a bare
`grep 'nightly'` and validated nothing. So it was a fifth place deciding
what a nightly is -- the commit that claimed to fix "all four places"
missed the one in the file it was editing -- and it held the origin we do
not own to a looser standard than the one we do. It now matches the
literal -nightly. segment and runs its answer through
version_valid_for_channel, exactly like the release origin's.

The publisher's tag regex allowed "+" in the prerelease while both
readers reject build metadata, so vX.Y.Z-nightly.N+meta would publish a
pointer no reader accepts and leave nightly silently stuck. It now uses
the readers' regex and fails at publish time.

`--channel nightly --version 0.11.0` quietly installed a stable build.
It now refuses: --version skips discovery and cannot honour --channel, so
guessing which half of a contradictory request was meant is worse than
saying no.

Two tidies: a doc comment was left orphaned above the predicate that got
inserted under it, and --help's usage line never gained --dist or
--origin-url.

Both new guards are mutation-checked. The first version of the fallback
test passed with the fix reverted, because its fixture was single-line
JSON while the real GitHub API returns one "tag_name" per line -- so the
per-line filter it exists to guard was never exercised. With a realistic
fixture the mutation fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M5AJ5MBZPZ2TEBDKX2AHFJ15
Two consequences of the previous round's fixes.

Refusing a prerelease no channel claims put the loudness after the point
of no return. The pointer step runs for every tag, so an -rc.1 tag died
after GoReleaser had already published the release and mirrored the
artifacts: a red job, a Slack notification and a skipped mirror-nightly,
for a release that was fine. An unclaimed prerelease is not a broken
release, it is nothing to advertise. It now exits 0 and says so on
stderr, which still surfaces a typo'd nightly tag in the release log
without failing anything. A tag that is not a version at all stays a hard
error, and both sides of that line are pinned by tests.

--version was only validated when --channel was also given, because the
check lived in the contradiction guard. Without --channel the value went
straight into ${base}/v${version}/..., and curl normalises ".."
segments, so something like ../../y addressed a path outside the version
prefix and failed later as a generic download error. The shape check is
now its own predicate that every version reaching a URL passes, with the
channel comparison layered on top only when --channel is given.

Both guards are mutation-checked: disabling the shape check fails six
assertions, restoring the die fails five.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M5C4N8G5PZF4MJGXY97K6P86
@Soph
Soph enabled auto-merge October 20, 2026 07:58
@Soph
Soph merged commit bbbe654 into main Oct 20, 2026
24 checks passed
@Soph
Soph deleted the soph/github-independent-release branch October 20, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants