-
Notifications
You must be signed in to change notification settings - Fork 0
Design
A release tool for Go. Only Go.
A release should be correct before it ships, provable at the moment it ships, and legible long after.
This document is the design rationale: the decisions, the alternatives we rejected, and the reasoning behind both. For the intended user experience and the roadmap, see README.md. No code exists yet.
GoReleaser is a distribution tool. letsgo is an integrity tool.
GoReleaser's job is get my software to many places — Homebrew, Docker, deb, rpm, snap, scoop, AUR, krew, Discord, Mastodon. It is very good at that, and breadth is the correct axis for it.
letsgo's job is prove this release is what it claims to be, refuse to ship it if it is wrong, and never leave it half-finished. The two overlap on "makes a GitHub release," but they optimise different things. This is not a claim that GoReleaser is bad software. It is a claim that there is a different axis.
This is the load-bearing part of the argument, because it explains why we can do things GoReleaser cannot, rather than merely asserting we are smarter.
The non-goals fund the differentiators. They are not losses we tolerate. They are what pays for the wins.
- Reproducible-by-default is tractable because we only build Go. Add Docker layers, snap, or CGO toolchains and byte-identical output stops being achievable as a default.
- Symbol-checked
-Xflags are possible because we can parse the main package AST. That is only cheap when Go is the sole target. - A closed template vocabulary works because there are about ten things to
interpolate. GoReleaser needs full
text/templateprecisely because it supports fifteen packaging backends with different naming rules. - Semver enforcement, vulnerability gating, and module-path checks (§9) are only possible against a language with a module system, an API surface we can diff, and a vulnerability database keyed to it.
Every advantage below traces back to the scope cut. GoReleaser's authors did not miss these; their scope forecloses them.
| GoReleaser | letsgo | |
|---|---|---|
| CI dies after uploading 4 of 6 assets | Re-run produces conflicting assets, or you delete the release and redo the whole build. Resume is a Pro feature. | Re-run skips the 4 by digest, uploads 2, finishes in seconds. |
| Typo in an archive-name template | Six minutes of cross-compiling, then the template error. Zero artifacts. |
plan fails in about two seconds, names the bad variable, lists the valid ones. |
--version prints dev in production |
-X main.version silently no-ops on a wrong symbol path. You learn from a user's bug report. |
Plan fails: main.version not found in ./cmd/foo (did you mean main.Version?), and the smoke test would have caught it anyway. |
| "Is this binary really built from the tagged source?" | Unanswerable. Not only for third parties — you cannot answer it either. | letsgo verify v1.2.3 |
| Shallow clone in CI | Cryptic changelog failure, so you set fetch-depth: 0, so every CI run is slower forever. |
Falls back to the GitHub compare API. |
A flat feature list overstates the case. Sorted by what kind of difference each one actually is:
1. Capabilities GoReleaser cannot have.
letsgo verify — rebuild a published release from source and compare digests.
GoReleaser has no equivalent because non-reproducible builds make it
impossible. Reproducibility is not a feature here, it is the prerequisite.
Likewise the published manifest (§8), from which install.sh, update checkers,
mirroring, and letsgo diff all fall out without release-page scraping.
2. Capabilities GoReleaser charges for. Resume after partial failure. Ours is also structurally better: because artifacts are reproducible, "is this asset already correct?" is a question we recompute from content rather than bookkeeping we have to trust.
3. Same capability, better ordering. Plan/apply. All cheap validation before all expensive work. A sequencing change rather than a new feature, but the one you would feel most often.
4. Different defaults.
-trimpath, CGO_ENABLED=0, commit-derived timestamps, deterministic
archives. In GoReleaser these are reachable but opt-in, and even with
mod_timestamp set, gzip headers remain non-deterministic.
5. Just smaller. (the weak category) Nine verbs, a ten-line config, no dependencies. This is not a reason to switch. "Smaller" is a pleasant property of a tool you already chose for other reasons. If categories 1 through 4 do not land, this one will not carry the argument.
Stated plainly, because a pitch that will not name its losses is not credible.
-
Packaging breadth — nfpm (deb/rpm/apk), snap, scoop, AUR, krew, nix. We
do none of it. Container images are the one exception, and only because a
static Go binary on
scratchneeds no build system at all (§18). - Announcements — Slack, Discord, Mastodon, email.
- Multi-forge — GitLab and Gitea work today; letsgo publishes to GitHub, behind an interface that makes a second forge an addition rather than a rewrite, but nobody should wait for one.
- Maturity — years of accumulated edge cases from a large user base. We will ship bugs they fixed in 2021.
- Monorepos — a Pro feature, but it exists. We have deferred it entirely.
If a repo needs any of these, use GoReleaser for that repo. The plan assumes both tools coexist, not that letsgo replaces GoReleaser everywhere.
The entire pitch rests on §7. If the determinism spec cannot be made to hold,
verify does not ship, category 1 evaporates, and what remains is "GoReleaser
but smaller" — category 5, which is not enough. That is why the determinism
test is a gate in M0, not a nice-to-have in M2. We find out whether this
tool has a reason to exist in the first milestone, before building a release
path on top of it.
- Zero config is the primary path. A config file is for exceptions.
- Fail in two seconds, not six minutes. Everything resolvable is resolved before anything expensive runs.
- Deterministic by construction. Reproducibility is not a flag. It is the only mode.
- Every operation is idempotent. Re-running is always safe and cheap.
-
Every gate has an override, and every override is visible. Checks that
block a release (§9) are default-on, but each can be disabled explicitly,
and
planalways reports which gates ran and which were skipped. A tool that blocks your release with no escape hatch is a tool you stop using; a tool that lets you skip checks silently is one you stop trusting. - Small. Nine verbs, no direct dependencies, one static binary.
- Go-only is a feature. We assume the toolchain, the module graph, the vulnerability database, and the conventions. That assumption is where all the leverage is.
Everything in this design belongs to one of three phases of a release's life.
letsgo plan is not a config validator. It is a release gate (§9). It
refuses to ship a release that is wrong in ways Go lets you be wrong silently:
a /v2 module path tagged v1.4.0, a minor bump that removes an exported
function, a binary with a known reachable vulnerability, a -X flag pointing
at a symbol that does not exist, a binary that does not run.
Nothing in this act requires trusting us. Each check is a thing you could have run yourself and would not have.
Reproducible builds (§7), a machine-readable manifest (§8), digests, and OIDC provenance attestation (§13). The release is a pure function of the commit, and the artifact records enough to re-derive itself:
$ letsgo verify v1.2.3
✓ fetched manifest from release
✓ rebuilt 6/6 targets at commit 9f2ab1c
✓ all digests match published assets
✓ dependency graph matches recorded go.sum
✓ provenance attestation valid (github-actions, danielriddell21/foo)
Anyone can run that against anyone's release. This is the reason to switch.
A release is not finished when the assets upload. letsgo diff (§14) answers
"what actually changed between these two releases" in terms of binary size,
dependency graph, and exported API — not commit messages. letsgo yank (§14)
handles the case where a release turns out to be a mistake, including the
go.mod retract directive that Go provides and nothing in this category
knows about.
The entire tool:
letsgo plan resolve, gate, and print the intended release. no side effects
letsgo build build + archive + checksum into dist/. no publish
letsgo release plan, build, publish. resumable, idempotent
letsgo release --snapshot same code path; publishing swapped for a recorder
letsgo verify [tag] rebuild from source, compare against published digests
letsgo tag work out the next version from the API and the commits
letsgo diff <tag> <tag> size, dependency, and API deltas between two releases
letsgo yank <tag> retract a bad release, including the go.mod directive
letsgo update update letsgo itself, verified against its manifest
Plus letsgo fmt (formats the config) and letsgo version.
letsgo plan --explain additionally prints why each value was chosen — which
defaults were inferred, from what evidence, and which gates ran. Zero config
only feels safe if the inference is inspectable; without --explain the
primary path is indistinguishable from magic.
There is no --rm-dist and no matrix of --skip-* flags. If a skip flag
becomes necessary for anything other than a §9 gate, that is a signal the
pipeline is doing something it should not.
The config file is letsgo.mod, written in the line-and-block directive syntax
used by go.mod and go.work:
project foo
build (
linux/amd64
linux/arm64
darwin/arm64
darwin/amd64
windows/amd64
)
ldflags -s -w
archive (
README.md
LICENSE
)
release prerelease=auto
brew danielriddell21/homebrew-tap
Rationale:
- Zero learning cost for the audience. This tool's users are, by definition, Go developers. They read this syntax fluently every day. It is "our own format" without being an invented one.
- Trivially parseable. Line-oriented with parenthesised blocks. A complete parser with good error messages is roughly 200 lines, which keeps the config dependency count at zero.
-
Strict and unambiguous. No indentation significance, no YAML type
coercion, no JSON comment problem. Comments use
//. -
Formattable.
letsgo fmtcanonicalises it exactly asgo mod tidydoes, so config never drifts stylistically across repos. - Low blast radius. The file is usually absent and always tiny, which is what makes choosing the pleasant option defensible rather than self-indulgent.
Known cost: no editor syntax highlighting out of the box. Mitigations are that
go.mod highlighters mostly apply by coincidence of syntax, the file is rarely
more than ten lines, and parse errors carry file:line:col plus a suggestion.
| Option | Why not |
|---|---|
| TOML | Fine, but needs a dependency and adds a fourth format to a repo that already has go.mod
|
| YAML | Familiar from GoReleaser, but inherits YAML's ambiguity, and matching their format invites matching their surface area |
| JSON + schema | No comments, noisy to hand-edit |
| HCL | Fits the plan/apply framing, but a heavy dependency for a ten-line file |
| Starlark / CUE | Now the config is a programming language. Directly against principle 1 |
Config inside go.mod
|
Unknown directives are a hard error in the Go toolchain. Not possible |
Still open for revision before implementation starts (§18).
| Value | Derived from |
|---|---|
| Project name |
go.mod module path, last element |
| Repo owner/name | git remote get-url origin |
| Version | The git tag being released; --snapshot uses <last-tag>-next+<short-sha>
|
| Main packages |
./cmd/* if present, else the module root if it is package main
|
| Targets |
linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64
|
| Archive format |
tar.gz, and zip for Windows |
| Archive name | <project>_<version>_<os>_<arch><ext> |
| Extra archive files |
README*, LICENSE*, CHANGELOG* if present at the repo root |
| Version injection |
-X main.version, main.commit, main.date — only if those symbols exist
|
letsgo plan --explain prints this table populated with the actual evidence for
each row.
Before building, we parse the main package and confirm each -X target symbol
exists and is a package-level string. If it does not, plan fails naming the
symbol. This alone fixes the most common silent failure in Go release tooling.
We also detect whether the binary already calls
runtime/debug.ReadBuildInfo(). Go embeds VCS data automatically, so in that
case we suggest dropping the ldflags rather than injecting redundant values.
{{project}} {{version}} {{tag}} {{commit}} {{short_commit}}
{{date}} {{os}} {{arch}} {{binary}} {{ext}}
No functions, no conditionals, no pipelines. Unknown names are a plan-time error listing the valid set. If a case genuinely needs logic, that is a bug report about a missing default, not a reason to add a template language.
This section is normative. Deviating from it is a bug.
Source of time. SOURCE_DATE_EPOCH is the committer timestamp of the
commit being released. It is never time.Now().
Build.
-
-trimpathalways. -
CGO_ENABLED=0unless explicitly overridden in config. -
-ldflags "-s -w"by default, plus version injection. - A clean worktree is required for a real release (
--allow-dirtyexists forbuildand--snapshotonly), so the commit hash is a valid cache key. - The Go toolchain version is recorded in the manifest, because it is an input.
tar.
- Entries sorted by path, byte-wise.
-
Uid/Gid= 0,Uname/Gnameempty. -
ModTime=SOURCE_DATE_EPOCH;AccessTimeandChangeTimezeroed. - Mode
0755for binaries,0644for everything else. -
Header.Formatpinned explicitly rather than inferred per entry.
gzip.
-
Header.Nameempty,Header.ModTimezero,Header.OS= 255 (unknown). Go's default writes both an OS byte and a modtime, which breaks byte-equality across machines.
zip.
- Entries sorted by path.
-
Modified=SOURCE_DATE_EPOCHin UTC, applied identically to every entry.
Enforcement, in two tiers.
-
Same-machine: CI builds the same commit twice in different working
directories, at different wall-clock times, with different
TMPDIRs, and asserts digest equality. - Cross-machine: CI builds the same commit on Linux and macOS runners and asserts the cross-compiled artifacts match byte for byte.
Tier 2 is the claim that actually matters, because it is the one a third party
relies on when they run letsgo verify on hardware we do not control. If
either tier cannot be made to pass, the feature it covers does not ship.
Every release publishes letsgo.json as an asset:
{
"schema": 1,
"project": "foo",
"version": "1.2.3",
"tag": "v1.2.3",
"commit": "9f2ab1c...",
"source_date_epoch": 1757000000,
"builder": { "tool": "letsgo 0.1.0", "go": "go1.24.7" },
"source": { "archive": "foo_1.2.3_source.tar.gz", "sha256": "..." },
"modules": { "go_sum_sha256": "...", "count": 14 },
"gates": { "vulncheck": "pass", "apidiff": "pass", "smoke": "pass" },
"artifacts": [
{
"name": "foo_1.2.3_linux_amd64.tar.gz",
"os": "linux",
"arch": "amd64",
"size": 4812345,
"sha256": "...",
"binary_sha256": "...",
"build": {
"flags": ["-trimpath"],
"ldflags": "-s -w -X main.version=1.2.3 -X main.commit=9f2ab1c",
"env": { "CGO_ENABLED": "0" }
}
}
]
}Recording size, go_sum_sha256, and the gate results is what makes letsgo diff (§14) nearly free: size and dependency deltas between two releases need
only the two manifests, with no rebuild.
One small file yields five things:
-
letsgo verify— everything needed to reproduce the build is recorded. -
letsgo diff— size and dependency deltas without downloading artifacts. - A generated
install.shthat selects the right asset and verifies its checksum, with no release-page scraping. - Update checkers — a client diffs its embedded version against the manifest.
- Mirroring and air-gapped distribution.
letsgo plan runs these before any expensive work. Each is default-on with an
explicit override (principle 5), and plan reports which ran and which were
skipped. Gate results are recorded in the manifest, so a consumer can see what
a release was checked against.
- Worktree is clean (unless
--snapshot). - Tag exists, parses as semver, is not already released — or
--replace. -
GITHUB_TOKENpresent, valid, carrying the scopes this release needs. One API call. - All config templates resolve against the closed vocabulary (§6).
- Target list is valid against
go tool dist list. - Homebrew tap repo is writable, if a tap is configured.
These are the checks only a Go-only tool can afford to make, and they are where most of the novel value sits.
-
Major version suffix agreement. If the module path ends in
/vNfor N ≥ 2, the tag must bevN.x.y, and vice versa. Publishingv2.0.0from a module path without a/v2suffix produces a release thatgo getwill never resolve — one of the most common and most confusing Go module mistakes. It is a two-line check that nothing in this category performs. -
Exported API versus semver bump. If the module exports packages beyond
main, diff the exported API against the previous tag. A removal or incompatible change in a patch or minor bump fails the gate with the specific symbol named:✗ apidiff: v1.3.0 is a minor bump, but the API is not backward compatible removed: func (*Client) Do(context.Context, *Request) (*Response, error) changed: func New(string) *Client -> func New(string, ...Option) *Client a breaking change requires v2.0.0 and a /v2 module path suffix override: letsgo release --allow-breakingFor a library, accidentally breaking API in a minor release is the cardinal sin of the Go ecosystem, and the tooling to detect it (
golang.org/x/exp/apidiff) has existed for years without ever being wired into a release tool. -
Known reachable vulnerabilities. Run
govulncheckand fail on findings in reachable code. Go's vulnerability database is first-party and govulncheck performs reachability analysis, so unlike generic SCA the signal is low-noise enough to gate on. The claim — letsgo will not publish a binary with a known reachable vulnerability — is a meaningful safety property, and it is only credible because of the reachability analysis. Override:--allow-vulnerable, which records the accepted advisory IDs in the manifest rather than hiding them. -
Symbol-checked ldflags (§6).
-
Smoke test. Build the host-platform binary first and run it — by default
--version, asserting the output contains the expected version string; configurable to any command. This catches the version-injection class of bug empirically rather than only structurally, and it catches a binary that panics on startup before six other targets are built and published. Skipped automatically when the host platform is not in the target list.
apidiff and govulncheck are invoked as external binaries, not linked as
libraries, so neither enters our dependency graph (§16). If the binary is
absent, plan reports the gate as unavailable with the one-line go install
command rather than failing — an unavailable gate is a warning, a failing gate
is an error.
Output of a successful plan is the full artifact list with names, targets, and resolved ldflags, plus the gate summary.
Before uploading, the release's existing assets are listed and compared by name and digest, falling back to size where the forge reports no digest. Matching assets are skipped; mismatched ones are deleted and re-uploaded.
Because artifacts are reproducible, "already uploaded and correct" is a question with a definite answer. That is what makes resume safe rather than hopeful.
There is no local state file. An earlier draft of this design had one, recording completed steps by digest so a re-run in the same directory would be instant. Building it showed it to be a cache of an answer the forge gives directly, and a cache that disagrees with the server is worse than no cache at all.
Build cache. Binaries are cached under a content-addressed key of
(commit, target, flags, toolchain version). Go's own build cache handles
incremental compilation; ours removes the re-archive and re-checksum work, so a
retried release costs seconds rather than minutes.
Default behaviour, in order of preference:
- Group by pull request where PR data is available, deduplicating squash-merge noise.
- Fall back to conventional-commit grouping (
feat,fix,perf, …). - Fall back to a plain commit list.
Breaking changes are detected from ! in the type prefix and from
BREAKING CHANGE: trailers, and hoisted to the top. Contributors are credited.
An API-changes section generated from the diff, not the prose. Because §9 already computes the exported API delta, the changelog can carry a section derived from what the code actually did rather than what the commit messages claimed:
### API changes
+ func WithTimeout(time.Duration) Option
+ func (*Client) Close() error
~ func New(string) *Client -> func New(string, ...Option) *Client
Commit messages are a lossy, optional, human-authored account of a change. The API diff is the change. Deriving release notes from the latter is obvious in retrospect and, as far as this design is aware, unattempted.
No fetch-depth: 0 requirement. On a shallow clone, fall back to the
GitHub compare API rather than failing — a direct fix for a well-known CI
papercut.
-
SHA256SUMSpublished alongside artifacts. - Build provenance attestation via GitHub OIDC — sigstore-backed, no key management, nothing to rotate.
- The manifest records
go.sum's digest, soletsgo verifyproves the dependency graph as well as the output. - A generated
install.shcarries every archive's digest inline, so installing verifies against a number recorded by the build rather than one fetched from the same page as the archive. Acurl | shinstaller that downloads its own checksum file proves only that the release page is internally consistent. - SBOM generation is deferred to M4. It is table stakes, not a differentiator.
GitHub's automatically generated source tarballs are not guaranteed to be
byte-stable over time, and a change in how they are produced has previously
invalidated checksums that downstream packagers had already published. Anything
that pins a checksum of https://github.com/…/archive/v1.2.3.tar.gz — Homebrew
formulas among them — is exposed to that.
letsgo therefore publishes its own source archive, built with the same deterministic writer as every other artifact (§7) and recorded in the manifest. Anything downstream that needs to pin a source checksum — a distro package, a vendoring mirror, a build-from-source formula — can pin ours. The cost is one extra asset; the benefit is that a checksum we publish is a checksum we control.
A tap is generated from the release that just happened, one formula per command
the module builds, each pointing at the prebuilt archives with the digests the
manifest recorded. brew install then unpacks a binary rather than compiling
one, and the digest it checks came from the build rather than from a checksum
file served by the same page as the archive.
Three decisions are worth stating:
-
One formula per command. A formula installs one archive per platform, so
a module with
alphaandbetacannot express both in one. GeneratingFormula/alpha.rbbesideFormula/beta.rbis what a tap is shaped to hold anyway; refusing the repository would have been the lazier answer. -
The
test doblock asserts the version. letsgo has already run the binary and confirmed it reports this version (§9), so the formula can assert the same thing rather than merely proving the binary starts. That catches a formula pointing at the wrong release, which is the failure a tap actually has. - An unchanged formula is not committed. Rendering is deterministic, so re-running a release — after a failed upload, or a corrected changelog — produces identical bytes and writes nothing. A tap is someone else's repository; filling its history with commits that say nothing happened is a cost paid by whoever reads it later.
desc and license come from the forge's own description of the repository
rather than from two new config directives, and are omitted when it has none.
Inventing either would be worse than leaving them out.
The tap is checked during plan, before anything is built: a release that
succeeds and then cannot write the formula has left the two out of step, which
is worse than not starting. Inside GitHub Actions the check can only warn — the
workflow token cannot write to another repository at all, so a tap needs a PAT
or an App token, and the repository endpoint will not describe either.
Answers "what actually changed between these two releases" in terms of artifacts rather than commit messages:
$ letsgo diff v1.2.3 v1.3.0
v1.2.3 -> v1.3.0
binary size
linux/amd64 12.4 MB -> 16.8 MB +35%
darwin/arm64 12.1 MB -> 16.5 MB +36%
dependencies
+ github.com/some/large-dep v1.4.0
~ golang.org/x/net v0.21.0 -> v0.23.0
api
+ example.com/foo: func WithTimeout(time.Duration) Option
toolchain
go1.23.4 -> go1.24.7
Every line comes from the two manifests. Nothing is rebuilt and nothing is
downloaded, which makes this feature nearly free once §8 exists — and means it
works against a project that is not checked out, or between two releases of
someone else's. Either side may equally be a local letsgo.json, so "what did
the build I just ran do to the binary" is the same command.
Binary size regressions are a real and under-tooled problem in Go, and a single added dependency is usually the cause — putting both on the same screen makes the culprit obvious. The toolchain line is listed last and is often the explanation, because a compiler change moves every target at once and no dependency accounts for it.
The size compared is the binary, not the archive, wherever both manifests record one: archive size moves with the compressor as well as with the code. Against a release published before letsgo recorded binary size, the comparison falls back to archive size and says so rather than mixing the two.
Size budgets. Optional config (budget linux/amd64 15MB) caps a target's
binary. The size is parsed at plan time — a malformed one, or a budget naming a
target the release does not build, fails the plan rather than surfacing after a
full matrix has been compiled. The cap itself is checked immediately after the
build, because a binary's size is not knowable before one exists; every target
is checked before anything is reported, so one run names all five. A binary
that reaches 90% of its budget is reported as a warning, since a cap that is
only ever discussed on the day it breaks tends to break on the day of a
release.
Go module releases cannot be unpublished — the proxy is immutable by design,
which is correct and also means a bad release is permanent. What Go does
provide is the retract directive, supported since Go 1.16 and almost never
used, because nothing automates it.
letsgo yank v1.2.3 performs the full retraction:
- Marks the GitHub release as a prerelease and prepends a notice to its body.
- Generates the
retractblock ingo.mod, with the rationale comment thatgo list -retractedsurfaces to users. - Opens the commit or PR that adds it, since a retraction only takes effect once it is itself tagged.
- Reverts the Homebrew formula to the previous version if a tap is configured.
Step 3 is the part people get wrong: a retraction must be published in a later version to be visible. Encoding that sequencing is exactly the kind of Go-specific knowledge a Go-only tool should own.
Immediately after a release, issue a single GET to
https://proxy.golang.org/<escaped-module>/@v/<version>.info so the module is
cached before anyone runs go install. Without it there is a window where a
freshly tagged release is not yet installable, which reads to users as a broken
release. The module path requires case-escaping (uppercase letters become
!-prefixed lowercase). One HTTP request, one papercut permanently gone.
Moving a repository off GoReleaser is mostly deletion. Most of a
.goreleaser.yaml describes behaviour letsgo infers, so a ninety-line config
becomes nothing at all or a letsgo.mod of three or four lines.
There is deliberately no letsgo migrate command. A converter for a config
that mostly says "use the defaults" earns little, and the parts that would need
one are the parts letsgo does not do — where the answer is a decision about
which tool to use for that repository, not a translation. Building it would
also have cost the project its only dependency, a YAML parser, to read files
that are about to be deleted.
MIGRATING.md carries the mapping, a worked example, and the
behaviour changes to expect on the first release: a renamed checksum file,
digests that no longer match because the archives are now reproducible, version
metadata taken from the commit rather than the clock, and a wrong -X path
that now fails instead of silently producing a binary reporting dev.
cmd/letsgo/ entry point, subcommand routing (hand-rolled, no framework)
internal/config/ letsgo.mod parser, formatter, defaults, validation
internal/discover/ go.mod, git remote, main-package and symbol discovery
internal/safeexec/ resolving external binaries without trusting PATH
internal/gobuild/ target matrix, toolchain resolution, build execution
internal/archive/ deterministic tar/gzip/zip writers
internal/build/ compile, package, smoke, source archive, cache, SHA256SUMS
internal/bytesize/ one interpretation of "15MB", shared by config and reports
internal/gate/ apidiff and govulncheck, invoked as external tools
internal/semver/ version ordering and comparison
internal/bump/ next version from the API delta and the commits
internal/changelog/ conventional-commit and API-derived changelog
internal/manifest/ letsgo.json schema, read and write
internal/install/ the self-verifying install.sh generator
internal/brew/ Homebrew formula generation and tap push
internal/oci/ deterministic image layers, and a registry client
internal/publish/ GitHub releases, asset upload, resume logic, proxy warm-up
internal/release/ plan to a complete set of publishable files on disk
internal/verify/ rebuild-and-compare
internal/diff/ manifest-to-manifest release diffing
internal/plan/ orchestration, the release gate, and the plan report
internal/repro/ the cross-machine determinism suite, driving the real pipeline
internal/yank/, the retraction workflow, has since been added — see Commands.
The GitHub API is accessed with net/http directly rather than through an SDK;
we use roughly eight endpoints. Config needs no dependency. Archives,
compression, and hashing are stdlib. apidiff and govulncheck are invoked as
external binaries (§9), keeping them out of the module graph.
The budget was ≤3, kept for a YAML parser that migrate would have needed.
Writing the migration as documentation instead spends none of it, so go.mod
has no require block at all.
A small dependency tree is not asceticism. This tool signs and publishes your
releases, so its attack surface is part of its threat model, and a fast
go install is part of its adoption story.
MVP is M0 through M3.
M0 — Foundation
Skeleton and subcommand routing. letsgo.mod parser and formatter. Discovery
of module, git, main packages, and symbols. Target matrix. Deterministic
archive writers. SHA256SUMS. letsgo build. plan --explain. Major version
suffix check. Both tiers of the determinism test in CI — the go/no-go gate
for the whole project.
M1 — The release path
letsgo plan with the configuration gates and the smoke test. Changelog.
GitHub release creation and asset upload with resume. Manifest emission. Stable
source archive. Proxy warm-up. --snapshot. letsgo release.
M2 — Verification and correctness gates
letsgo verify. OIDC provenance attestation. Build cache. The govulncheck
and apidiff gates, plus the API-derived changelog section that falls out of
the latter.
M3 — Adoption and afterlife
A migration guide (MIGRATING.md) rather than a converter. letsgo diff, size
budgets, a generated install.sh that carries its own digests, and Homebrew
tap generation pointed at our own reproducible archives.
MVP complete. letsgo can now replace GoReleaser across the repos it targets, and does several things GoReleaser does not.
M4 — Container images Multi-arch OCI images built from the binaries M0 produced, with a deterministic layer and no daemon (§19). This reverses a non-goal (§18) and is the one place it is worth reversing, because "reproducible everywhere except the container" is not a claim worth making.
M5 — Breadth, only if needed
letsgo yank. SBOM. An embeddable selfupdate package that consumes the
manifest, so any binary released with letsgo gets verified self-update for
free.
GitLab, Gitea and a reusable GitHub Action are out of scope here. The forge interface (§16) exists so a second forge is an addition rather than a refactor, and the Action is packaging rather than tooling — it belongs in its own repository, where it can version independently of the binary it runs.
-
Docker builds.Reversed in M4, and built; see §19. The original reasoning was thatdocker buildxis better at building images, which is still true — and irrelevant, because letsgo does not need to build one. The binaries already exist; an image around a static binary is a tar layer and a JSON config, both of which §7's writer already produces deterministically. Wrapping buildx would have been wrong; reaching the registry API directly needs no daemon and keeps the reproducibility claim whole. - deb, rpm, snap, scoop, AUR, krew, nix. Not until a repo actually needs one.
- Announcements (Slack, Discord, Mastodon, email). Not a release tool's job.
- A plugin system. The moment plugins exist, the config surface reopens.
- Non-Go languages. The entire advantage comes from assuming Go.
- Monorepos. Deferred until there is one to test against.
Each of these — the reversed one aside — is a place where GoReleaser is the better tool. Saying so is part of the design, and so is saying when the reasoning stops holding.
An image around a static Go binary is not a build problem. The binary already exists. What remains is a tar layer, a JSON config, a JSON manifest and four HTTP requests — so letsgo does that directly rather than shelling out to a build system, a daemon and a cache to produce it.
image # ghcr.io/<owner>/<project>, on scratch
image ghcr.io/you/thing # or name it
image base gcr.io/distroless/static@sha256:…
Opt-in, because publishing a package to a registry creates something in the owner's account that they should have asked for. Everything else is inferred: the reference from the repository, the platforms from the Linux subset of the build matrix, the tag from the release, the entrypoint from the binary.
Wrapping docker buildx would import a build system to do work that is a few
hundred lines of tar and JSON, require a daemon or a rootless shim wherever
letsgo runs, and — the point that settles it — give up reproducibility. The
image digest would then depend on the builder's Docker version, which is
exactly the class of dependency §7 exists to eliminate. Reaching the registry
API directly keeps the image as reproducible as everything else.
The layer is written by §7's tar writer: sorted entries, zeroed ownership,
normalised modes, timestamps from the commit. The config's created is the
commit time. The index's manifests are sorted by platform, so the published
digest does not depend on which cross-compile finished first. Two machines
given the same commit produce the same image digest, and CI checks that.
scratch is the default, and correct for a binary that makes no TLS calls: one
layer, nothing else, nothing to patch. It is also wrong for anything that talks
HTTPS, because there are no CA certificates — so a base can be named, and its
layers are then stacked below ours.
A base is not built, it is copied: the reference is resolved to a per-platform
manifest, its config's rootfs and run settings are inherited, and its layers
are made available in the target repository with a cross-repository mount,
which transfers no bytes at all when the base is on the same registry. Only
when that is refused, or the base lives elsewhere, is anything downloaded.
A base named by tag is a warning rather than a refusal. The digest it resolved to is recorded in the manifest either way, so a release always says what it actually built on, whatever the config file said.
Blobs are content-addressed, so re-running a release uploads nothing and publishes the same digest. Manifests go up before the tag, and the tag last: a failed run leaves blobs behind and no tag pointing at anything incomplete.
The index digest goes into letsgo.json, which is what lets letsgo verify
ask the one question a mutable tag cannot answer about itself — whether 1.2.3
still resolves to what the release published. latest is deliberately not
checked: a newer release moving it is not a failure.
-
Config format.
letsgo.modingo.modsyntax is the recommendation in §5, with alternatives recorded. Reversible now, expensive later. -
Snapshot version scheme.
<last-tag>-next+<short-sha>is proposed; it must sort correctly under semver against both the previous and the next real tag. -
Whether
letsgo buildrequires a tag, or defaults to snapshot semantics whenHEADis untagged. Leaning toward the latter. -
Whether the
apidiffgate is default-on for libraries. It is the most opinionated check in §9 and the most likely to annoy. Default-on with--allow-breakingis proposed, but default-warn is defensible for a first release while we learn its false-positive rate. - Private base images. A base is read anonymously, which covers every public base worth using. A private one would need a second credential, and it is not yet clear whether that belongs in the config, the environment, or nowhere.
-
M3 scope.Settled and complete.migratewas dropped in favour of a guide, which also returns the dependency budget to zero;diff, size budgets,install.shand Homebrew tap generation are built.