feat(release): mirror releases to GitHub and fall back to it #112

Merged
deadstyle merged 8 commits from feature/102-mirror-releases-github into develop 2026-10-09 17:29:20 -05:00
Owner

Summary

Tagged releases now also publish to the GitHub mirror with every archive and checksums.txt. install.sh, install.ps1 and the in-app updater fall back to GitHub when the Forgejo server is unreachable. A manual github-release workflow mirrors releases that already exist, such as v0.1.0.

This also fixes an existing updater bug: the version-check timeout was cancelling the context the download inherited, so every accepted update failed immediately with context canceled.

Changes

  • scripts/publish-github-release.sh <tag> <dir> (new): waits up to 5 min for the tag to reach GitHub (github-push.yml pushes it concurrently), refuses if a release for the tag already exists (drafts included), creates a draft, uploads every file, then un-drafts.
  • release.yml: new "Mirror to GitHub" step after the Forgejo publish, push only. A GitHub failure can't block the Forgejo release.
  • github-release.yml (new, workflow_dispatch, input tag): downloads an existing Forgejo release's assets and runs the same script.
  • install.sh / install.ps1: lookup, archive and checksums are fetched as one unit from Forgejo (10 s connect timeout). Any failure in that unit re-runs it against GitHub, with a one-line notice. A checksum mismatch or a missing checksum line stays a hard failure, and the archive and checksums always come from the same host. New overrides: LAZYFORGE_GITHUB_API_URL, LAZYFORGE_GITHUB_URL.
  • internal/update: Checker.Sources replaces BaseURL. Latest tries Forgejo, then GitHub, on a transport error, timeout (including a stalled body) or 5xx. A 404 or a bad response body does not fall back. Release.Source records where the release was found, and Apply downloads only from that source. If every source fails, the error names each one.
  • cmd/lazyforge/update.go: wires both sources, and the check context is now its own (4 s, at most 2 s per source), which fixes the cancelled-download bug.
  • Docs: README install note, architecture, and an amendment to ADR 0009.

Decisions

  • Decided: the publish script never creates the tag. It polls for it, because the tag push and the release run in separate workflows.
  • Decided: publish-script overrides use GH_RELEASE_* names. Forgejo runners preset GITHUB_API_URL to the Forgejo API, which would otherwise send the GitHub token to Forgejo. A test pins this.
  • Decided: in the updater, "unreachable" means a transport error, a timeout or a 5xx. A Forgejo 404 or a bad body is an answer, so there's no fallback and the startup check stays quiet. The installers fall back on any lookup failure, because their job is to deliver a binary.
  • Decided: worst-case startup delay is 4 s, and only while Forgejo is hanging; it's normally unchanged. Recorded in ADR 0009.
  • Assumed: "same four archives" means every file in dist/ (six archives since Windows support, plus checksums).

Test plan

  • make check (format, lint, race tests across 14 packages, govulncheck, build, shellcheck 0.11.0, test-install.sh 25/25, Windows vet/build): exit 0.
  • New Go tests in internal/update/fallback_test.go: Forgejo down, 503, hanging, or stalling its body → GitHub. 404 or bad JSON → no fallback, GitHub never contacted. Healthy Forgejo → GitHub never contacted. Both failing → the error names both. Checksum mismatch from GitHub → binary untouched.
  • New test-install.sh cases cover fallback when Forgejo is down, when an archive or checksums file is missing, when the lookup returns no tag, and with a pinned version. Healthy Forgejo, a checksum mismatch and a missing checksum line never contact GitHub. Six publish-script cases run against a fake GitHub API.
  • Read-verified only (not runnable in CI): install.ps1, both workflows, and real api.github.com upload behavior.

Follow-ups

  • PM, after merge: create a GitHub fine-grained token with Contents read/write on Bparsons0904/lazyforge, add it as the Forgejo Actions secret GH_RELEASE_TOKEN, then run the github-release workflow with tag v0.1.0. Until the secret exists, the release workflow's mirror step fails. The Forgejo release has already been published by then.

Refs #102

🤖 Generated with Claude Code

https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY

## Summary Tagged releases now also publish to the GitHub mirror with every archive and `checksums.txt`. `install.sh`, `install.ps1` and the in-app updater fall back to GitHub when the Forgejo server is unreachable. A manual `github-release` workflow mirrors releases that already exist, such as v0.1.0. This also fixes an existing updater bug: the version-check timeout was cancelling the context the download inherited, so every accepted update failed immediately with `context canceled`. ## Changes - `scripts/publish-github-release.sh <tag> <dir>` (new): waits up to 5 min for the tag to reach GitHub (`github-push.yml` pushes it concurrently), refuses if a release for the tag already exists (drafts included), creates a draft, uploads every file, then un-drafts. - `release.yml`: new "Mirror to GitHub" step after the Forgejo publish, push only. A GitHub failure can't block the Forgejo release. - `github-release.yml` (new, `workflow_dispatch`, input `tag`): downloads an existing Forgejo release's assets and runs the same script. - `install.sh` / `install.ps1`: lookup, archive and checksums are fetched as one unit from Forgejo (10 s connect timeout). Any failure in that unit re-runs it against GitHub, with a one-line notice. A checksum mismatch or a missing checksum line stays a hard failure, and the archive and checksums always come from the same host. New overrides: `LAZYFORGE_GITHUB_API_URL`, `LAZYFORGE_GITHUB_URL`. - `internal/update`: `Checker.Sources` replaces `BaseURL`. `Latest` tries Forgejo, then GitHub, on a transport error, timeout (including a stalled body) or 5xx. A 404 or a bad response body does not fall back. `Release.Source` records where the release was found, and `Apply` downloads only from that source. If every source fails, the error names each one. - `cmd/lazyforge/update.go`: wires both sources, and the check context is now its own (4 s, at most 2 s per source), which fixes the cancelled-download bug. - Docs: README install note, architecture, and an amendment to ADR 0009. ## Decisions - Decided: the publish script never creates the tag. It polls for it, because the tag push and the release run in separate workflows. - Decided: publish-script overrides use `GH_RELEASE_*` names. Forgejo runners preset `GITHUB_API_URL` to the Forgejo API, which would otherwise send the GitHub token to Forgejo. A test pins this. - Decided: in the updater, "unreachable" means a transport error, a timeout or a 5xx. A Forgejo 404 or a bad body is an answer, so there's no fallback and the startup check stays quiet. The installers fall back on any lookup failure, because their job is to deliver a binary. - Decided: worst-case startup delay is 4 s, and only while Forgejo is hanging; it's normally unchanged. Recorded in ADR 0009. - Assumed: "same four archives" means every file in `dist/` (six archives since Windows support, plus checksums). ## Test plan - `make check` (format, lint, race tests across 14 packages, govulncheck, build, shellcheck 0.11.0, `test-install.sh` 25/25, Windows vet/build): exit 0. - New Go tests in `internal/update/fallback_test.go`: Forgejo down, 503, hanging, or stalling its body → GitHub. 404 or bad JSON → no fallback, GitHub never contacted. Healthy Forgejo → GitHub never contacted. Both failing → the error names both. Checksum mismatch from GitHub → binary untouched. - New `test-install.sh` cases cover fallback when Forgejo is down, when an archive or checksums file is missing, when the lookup returns no tag, and with a pinned version. Healthy Forgejo, a checksum mismatch and a missing checksum line never contact GitHub. Six publish-script cases run against a fake GitHub API. - Read-verified only (not runnable in CI): `install.ps1`, both workflows, and real api.github.com upload behavior. ## Follow-ups - **PM, after merge:** create a GitHub fine-grained token with Contents read/write on `Bparsons0904/lazyforge`, add it as the Forgejo Actions secret `GH_RELEASE_TOKEN`, then run the `github-release` workflow with tag `v0.1.0`. Until the secret exists, the release workflow's mirror step fails. The Forgejo release has already been published by then. Refs #102 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
Checker now takes an ordered list of Sources. Latest tries each in turn and
falls back only on a transport error, timeout or 5xx; any other failure stops
the search. Apply downloads from the source that Latest found the release on.

The Latest context grows to 4s, and Apply no longer derives its context from
the 2s Latest timeout.

Refs #102

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
publish-github-release.sh waits for the tag github-push.yml pushes, creates a
draft release, uploads every file in the asset directory, and publishes only
after the last upload. release.yml runs it after the Forgejo publish on tag
push, and github-release.yml backfills an existing Forgejo release by hand.

Refs #102

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
install.sh and install.ps1 look up and download from Forgejo first. Any lookup
or download failure, including a response with no tag_name, retries the whole
sequence on GitHub, so the archive and checksums always come from one host.
A checksum mismatch or an unsupported platform still fails without a retry.

Refs #102

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
Refs #102

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
Criteria 1-9, 7b and 10b: Latest falls back to the GitHub source on Forgejo outages, 5xx, hangs and stalled bodies, but not on 404 or a bad release. Errors name both sources. Apply verifies checksums against the release's own source only. DownloadURL shapes match the plan.

Refs #102

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
Criteria 11-15, 13b, 14, 14b and 16a-16f: install.sh falls back to the GitHub mirror when Forgejo is unreachable, returns 404 for the archive or checksums, or has a pinned version missing; it never falls back on checksum mismatch or a healthy Forgejo. publish-github-release.sh waits for the tag, refuses an existing release, keeps the draft on upload failure, requires a token, and ignores legacy env overrides. The harness now sets SERVER_URL in the current shell so server PIDs reach the cleanup trap.

Refs #102

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
Refs #102

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
test(install): cover a Forgejo latest with no tag_name
All checks were successful
ci / check (pull_request) Successful in 1m46s
249c5eff7a
Refs #102

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTZrh5vn4LGcjFQzRVp5TY
deadstyle merged commit 43094b481a into develop 2026-10-09 17:29:20 -05:00
deadstyle deleted branch feature/102-mirror-releases-github 2026-10-09 17:29:20 -05:00
Sign in to join this conversation.
No description provided.