Skip to content

Feat: Template assumes a single Go module; inlining a third-party CLI for supply-chain control forces a multi-module (go.work) layout the template doesn't cover #18

@so0k

Description

@so0k

Summary

The origin template assumes — and actively encourages — a single Go module
("One Go module, many binaries" in AGENTS.md, the root go.mod comment, and
.skillrig-origin.toml). That holds nicely when every backing CLI is first-party code
authored in cmd/<tool>/. It breaks down the moment an org needs to inline a
substantial third-party CLI
into the origin for supply-chain control (vendoring a fork
so releases are built from an audited, re-syncable copy instead of pulled from an upstream
we don't control).

We hit this concretely while inlining two upstream CLIs into our origin:

  • ankitpokhrel/jira-cli (our so0k fork) → cli/jira — module …/cli/jira, jira-v* stream, seeded 1.7.0
  • so0k/tfc-clicli/tfc — module …/cli/tfc, tfc-v* stream, seeded 0.0.6

Why "one Go module" doesn't work for inlined CLIs

Folding a large upstream program into the single root module would have forced us to:

  1. Rewrite 80+ import sites — every internal package path in the upstream tree gets
    rewritten to the root module path. That mutates the source and makes future re-syncs a
    manual merge.
  2. Merge two dependency trees into one go.sum, resolving version conflicts between
    the origin's deps and each CLI's deps. One transitive conflict bumps the whole origin.
  3. Bump the shared go directive to the highest common denominator, dragging every
    other binary along (jira needs go 1.25, tfc 1.24, the origin 1.23).
  4. Lose the verbatim, auditable source tree — the whole point of inlining. You want a
    copy you can diff against upstream and re-sync; import-rewrite + go.sum merge destroys
    that property.

So we went multi-module: a root go.work tying together the root module (cmd/oxid)
and cli/jira / cli/tfc, each keeping its own go.mod/go.sum/go directive and a
verbatim source tree (only the module path is rewritten). This works — but none of it is
covered by the template
, and a couple of sharp edges cost us real time:

Sharp edge 1 — goreleaser monorepo.tag_prefix is Pro-only

The obvious fix for our prefixed tags (jira-v1.7.0) is goreleaser's
monorepo: { tag_prefix:, dir: }. But that stanza is a goreleaser Pro feature —
goreleaser check on the OSS binary rejects it outright:

yaml: unmarshal errors: line N: field monorepo not found in type config.Project

OSS goreleaser also can't parse a prefixed tag as semver
(failed to parse tag 'jira-v1.7.0' as semver). The OSS-compatible recipe we landed on,
per binary:

  • per-tool config (.goreleaser.<tool>.yaml) with builds[].dir: cli/<tool> (OSS supports
    per-build dir), correct -X <module>/internal/...Version={{.Version}} ldflags, and
    release.disable: true + changelog.disable: true (release-please owns the Release and
    the CHANGELOG);
  • in CI, derive the clean semver from the prefixed tag and feed it via
    GORELEASER_CURRENT_TAG=v1.7.0, run goreleaser release --clean --skip=validate, then
    attach the archives + checksums to the release-please-created Release with
    gh release upload <prefixed-tag> dist/*.tar.gz dist/*_checksums.txt --clobber.

The template ships a single .goreleaser.yaml with no prefix handling at all, so it is
already subtly broken for the existing oxid-v* stream under OSS goreleaser — this isn't
only a multi-module issue.

Sharp edge 2 — go work sync silently un-vendors your deps

Per-binary release builds must use GOWORK=off so each binary builds from its own
pinned go.mod/go.sum — that pin is the supply-chain guarantee; the workspace is dev
convenience only. More importantly, running go work sync mutates the member modules'
go.mod
to unify versions across the workspace: it bumped our vendored jira's
spf13/pflag from upstream v1.0.5 to v1.0.9 (to match tfc) without backfilling
go.sum, which (a) broke the per-module release build and (b) silently falsified the
"only the module path was rewritten" provenance claim. Vendored modules should be treated
as read-only by the workspace; go work sync is a footgun here.

What works fine as-is

  • release-please: per-module packages entries (cli/jirajira, cli/tfctfc) with
    include-component-in-tag + tag-separator: "-"jira-v* / tfc-v* tags. Note the
    component must not be a prefix of a skill component — we used jira (not jira-cli) so
    the binary stream doesn't collide with the jira-cli-v* skill tags.
  • mise: per-tool [tools] entries with the per-stream tag_regex encoded in the key
    (keys must be unique), anchored ^jira-v (not ^jira-) so it doesn't also match the
    skill's jira-cli-v* tags.

Requests

  1. Document a supported multi-module / go.work topology for vendored CLIs: the
    cli/<tool>/ submodule layout, when to choose it over single-module, how to keep an
    inlined fork verbatim + re-syncable, and the GOWORK=off-for-releases / don't-
    go work sync-vendored-modules rules.
  2. Ship release plumbing that works for per-binary streams across separate module dirs:
    the OSS-compatible goreleaser recipe above (and/or call out the Pro monorepo
    requirement explicitly), per-module release-please packages, and anchored mise
    tag_regex. Ideally fix the existing single-module template so its oxid-v* stream
    actually releases under OSS goreleaser.
  3. Soften the "one Go module" language in AGENTS.md / go.mod / .skillrig-origin.toml
    so single-module is the default, not the only supported shape.

Concrete resolution for reference

Our origin now ships oxid (root module), jira (cli/jira), and tfc (cli/tfc),
tied by a root go.work. All three build and stamp version under
GOWORK=off … GORELEASER_CURRENT_TAG=vX.Y.Z goreleaser release --clean --skip=validate;
the inlined trees retain their upstream import paths (no rewrite of the 80+ upstream import
sites) and verbatim go.mod/go.sum. Happy to share the full diff if it helps shape the
template guidance.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions