Skip to content

refactor(prepublish): run GoReleaser through release-cli - #17

Merged
jmgilman merged 3 commits into
mainfrom
feat/release-cli-goreleaser
Aug 20, 2026
Merged

refactor(prepublish): run GoReleaser through release-cli#17
jmgilman merged 3 commits into
mainfrom
feat/release-cli-goreleaser

Conversation

@jmgilman

Copy link
Copy Markdown
Contributor

PR 10 of the eleven-PR release-cli program (.journal/002/PLAN.md), on top of PR 9 (8a5e0a7). This is the last behavioral slice: inventory item PP-05, the goreleaser release --clean --skip=publish shell line, moves out of .github/workflows/go-pre-publish.yml and into internal/profile/goprof. All four reusable workflows are now thin shells. PR 11 is documentation only.

Scope

  • internal/profile/goprof/goreleaser.go (new) — RunGoReleaser(ctx, GoReleaserOptions{Path, Dist, Environ, Stdout, Stderr}) invoking exactly release --clean --skip=publish through exec.CommandContext: explicit argv slice, deferred exec.LookPath, bounded 4 KiB stderr tail, WaitDelay, no environment or token in any error. No port, no adapter package, no execx — the plan forbids all three. goreleaser release has no --dist flag, so Dist is the package's RootName domain type, validated rather than forwarded.
  • internal/cli/stage.gostage --profile go builds, then validates, then writes the oci-build-inputs.json projection. Options.RunGoReleaser is an injected function seam defaulted in withDefaults; RELEASE_GORELEASER_PATH is environment-only, like RELEASE_COSIGN_PATH. Both GoReleaser streams are routed to the CLI's stderr, so the --json envelope remains the only stdout content. --dist must be a basename because GoReleaser writes relative to the working directory.
  • .github/workflows/go-pre-publish.yml — PP-04's managed-tool-path proof stays as its own step; the staging step resolves mise which goreleaser, refuses a non-executable path, and runs the CLI under mise exec because GoReleaser shells out to go, syft, and cosign. No input, output, permission, timeout, or action pin changed; both uploads byte-identical.
  • Docs — three reference pages, four how-to pages, examples/go-release/README.md, plus the two pages the plan requires: docs/tutorials/release-a-go-project.md and docs/explanation/release-trust-boundaries.md.

Invariant 17 stays intact

Four independent boundaries, all verified: the argv is a fixed Go slice with no interpolation and no shell; .goreleaser.yaml keeps release.disable: true in both this repository and the example; the producer job holds contents: read with no write and no GITHUB_TOKEN; and RELEASE_GORELEASER_PATH selects the binary, never the flags. --skip has no environment binding in GoReleaser 2.17.1.

Verification

  • mise exec -- moon run root:check green; mise exec -- goreleaser check validates the config.
  • Real pinned GoReleaser 2.17.1 driven through the CLI under mise exec with a temporary local tag: it cleaned dist, printed skipping announce and publish, validated git state, reported release is disabled, and reached module proxying, where it stopped because gomod.proxy: true cannot resolve an unpublished local tag. A complete build therefore remains a tag-time event, which is exactly the plan's Layer 3.
  • Stdout contract under pressure: 2000 colorized lines plus a decoy JSON document written by a stub to stdout still produced exactly one parseable envelope on the CLI's stdout, and zero bytes in plain mode.
  • Exit codes exercised end to end: RELEASE_JSON=yes exits 2 before any build; --dist values a\b, a/b, ., .., /abs/dist, and dist/ each exit 2 before any build; a build that succeeds and then fails validation exits 1 with one envelope.

Review

Two rounds, plus a conformance audit. Nothing blocking survived, and three real defects were fixed that the slice would otherwise have shipped:

  1. A fail-open that predates this PR but that this PR made dangerous. runStage never inspected the settings resolver error, so a malformed RELEASE_* boolean was silently ignored. Harmless while stage only validated; after this change it would have built and could exit 0 against the documented exit-2 contract. Now checked first.
  2. A nil-seam panic. options.RunGoReleaser had no not-configured guard, so deleting the default made the binary panic, write no envelope, and exit 2 — colliding with the reserved usage code. It now matches every other injected collaborator, and a test drives the real default through NewRootCommand.
  3. A separator regression introduced by a fix. Collapsing the dist rule onto ParseRootName quietly narrowed it to / only, so --dist 'a\b' went from a pre-build exit 2 to a post-build exit 1, and would have inverted on Windows. ParseRootName now rejects .. and both separators, matching ParseBinaryName in the same file.

Also fixed: GoReleaser colorizes even into a pipe, so the retained error tail carried raw ANSI into the --json envelope. The escapes are now stripped from the tail only; the live stream keeps its color for humans reading the workflow log.

Journal follow-ups recorded, not acted on here

  • The bounded-tail exec helper now exists four times (cosign, melange, apko, goprof). Conformance recommends rescinding the execx prohibition; that needs an architecture amendment, so it is deliberately out of scope.
  • Conformance also notes that Options.RunGoReleaser is, in substance, a function-shaped outbound seam rather than merely a test hook. The port budget claim should be read as "no interface and no adapter package", which is what the plan forbade.

PP-05 moves out of go-pre-publish.yml and into goprof: `stage --profile go`
now runs `goreleaser release --clean --skip=publish` and then performs the
existing validation and projection. The workflow keeps PP-04's managed-tool
path proof and becomes a thin shell.

GoReleaser shells out to go, syft, and cosign, so the workflow invokes the
CLI under `mise exec` and passes the pinned binary as
RELEASE_GORELEASER_PATH. Both GoReleaser streams are routed to the CLI's
stderr so the --json envelope stays the only stdout content.

Adds the guided tutorial and the trust-boundary explanation the plan
requires, and records that --dist must be a basename because GoReleaser
writes its distribution directory relative to the working directory.
`runStage` never inspected the settings resolver error, so a malformed
boolean such as RELEASE_JSON=yes was silently ignored. That was harmless
while stage only validated; now that it builds, the command would have run
GoReleaser and could exit 0 despite the documented exit-2 contract.

Also give the injected GoReleaser seam the same not-configured guard every
other collaborator has, so a missing default cannot panic and collide with
the reserved usage exit code, and collapse the ad-hoc dist basename rule
into goprof.RootName, which now rejects `..` and both path separators.
@jmgilman
jmgilman merged commit 7197ca2 into main Aug 20, 2026
2 checks passed
@jmgilman
jmgilman deleted the feat/release-cli-goreleaser branch August 20, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant