Repository navigation
Add a release mirror with a GitHub fallback - #2795
Conversation
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
There was a problem hiding this comment.
🟡 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
Transient pointer read failures can overwrite newer releases · New Concurrent releases can regress the channel pointer · New Invalid primary versions suppress fallback update checks · New Stalled downloads can hang indefinitely without low-speed timeout · New Unvalidated manifest metadata suppresses valid fallback releases · New
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.comand 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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
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
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



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.versionOriginsis an ordered chain (install.entire.com, thenapi.github.com) walked byfetchLatestForChannel.scripts/install.shwalks 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:Publishing inverts rule 1 on purpose.
publish-channel-manifest.shfails 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
v<version>/checksums.txt, immutablechannels/{stable,nightly}.jsonmax-age=60installers/install.{sh,ps1}max-age=300, stable onlyscoop/entire.jsonOrder inside
release.ymlis 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_timestampis pinned to the commit so rebuilding a tag is byte-identical. Without it a retried release would re-upload different bytes and a differentchecksums.txtto 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.jsoninstalls with no bucket and therefore no git, andscoop updatere-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 ordinaryscoop install entirepath is unchanged — this is an additional route, not a replacement that puts every Scoop install behind one origin.install.ps1itself is not covered: it is mirrored so it can be fetched, but it still resolves versions throughapi.github.comand 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_BUCKETis set; deleting it and rerunning restores exactly the old behaviour. The variables are now set andinstall.entire.comis serving, so the next stable release is the first that mirrors. After it, check thatchannels/stable.jsonnames the new version,v<version>/checksums.txtreturns 200 with an immutable cache-control, andinstallers/install.shandscoop/entire.jsonreturn 200.The gate tests the variable for non-emptiness, not presence:
release.ymlalways exports it, an unset Actions variable exports as empty, andisEnvSetalone 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,--versionpinning, 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 checkgreen (93 packages, both canary legs),go vetfor darwin/linux/windows. The Windows installer checks run in CI.🤖 Generated with Claude Code