- Go 100%
Include annotated tag message in Forgejo release body. * feature/tag-message-in-release: refactor: improve composeReleaseBody design and fix signed-tag handling feat: include annotated tag message in release body |
||
|---|---|---|
| .claude | ||
| .gemini | ||
| internal | ||
| .gogogo.conf | ||
| .golangci.yml | ||
| go.mod | ||
| go.sum | ||
| LICENSE.txt | ||
| main.go | ||
| README.md | ||
go · go · go
Reproducible release builder for Go modules on Forgejo / Gitea.
Publish what others can build. Verify what your users will get.
What is it
gogogo clones a Go module straight from your VCS host, verifies the clone byte-for-byte against the public module proxy, builds across every OS/arch you list, and uploads the artifacts as a release on Forgejo or Gitea — all from a single config file next to your go.mod.
The point is reproducibility: the binary you publish is built from the same bytes that any user gets via go install.
┌────────────────────────────────────────────────────────────────────┐
│ $ gogogo release │
│ ✓ cloned go.example.com/foo @ v1.2.3 │
│ ✓ verified clone hash matches module proxy │
│ ✓ built foo-linux-amd64 foo-linux-arm64 foo-darwin-arm64 │
│ ✓ uploaded release v1.2.3 to forgejo.example.com │
│ ✓ cleaned up superseded pre-releases │
└────────────────────────────────────────────────────────────────────┘
Why
| Bit-for-bit reproducible | GOWORK=off, fresh go mod init, go build -trimpath. Nothing from your local checkout leaks in. |
| Hash-verified against the proxy | The clone is checked against golang.org/x/mod/zip hashes from the Go module proxy before any binary is built. |
| Cross-arch in parallel | Every OS/arch target builds concurrently. |
| VCS-stamped binaries | Builds run inside a real git checkout, so vcs.revision and vcs.time end up in the binary. |
| Private-repo aware | Token is auto-injected into git/go mod download via GIT_CONFIG_* — including across redirect chains. |
| Self-cleaning | Pre-releases superseded by a full release are removed automatically. |
Quick start
go install go.schlittermann.de/heiko/gogogo@latest
cd /path/to/your/module # the directory with go.mod
gogogo config default > .gogogo.conf
$EDITOR .gogogo.conf # set repo.{apiurl,owner,name,token}, build.targets
# at HEAD on a signed semver tag:
gogogo release
That's the whole workflow. Ahead of pushing, try gogogo release --dry-run to see exactly what would happen.
How it works
flowchart LR
L[Local checkout]:::soft -. ref check .-> CL
R[(Remote / Forgejo)] -->|clone @ ref| CL[Verified clone]
P[(Go module proxy)] <-->|hash compare| CL
CL -->|go build -trimpath| B[Cross-arch binaries]
B -->|upload| REL[(Release on Forgejo)]
CL -->|stamps vcs.revision / vcs.time| B
classDef soft fill:#f4f4f4,stroke:#bbb,color:#666;
- Clone — shallow-clone at the requested ref. Stable releases must point to the same commit locally and remotely.
- Verify — compute the module zip hash (
golang.org/x/mod/zip) and compare with whatgo mod download -jsonreports. Mismatch = abort. - Build —
go build -trimpathruns inside the clone, so the Go toolchain stamps real VCS info into every binary. - Upload — publish as a Forgejo/Gitea release, then run retention cleanup.
If repo config is missing or --local is set, gogogo falls back to a temp-dir build via the module cache (go get module@version). That path can't stamp VCS metadata.
Configuration
.gogogo.conf lives next to your go.mod. Generate the annotated default with gogogo config --default.
| Field | Description |
|---|---|
build.commands |
Subdirectories of commands to build (empty = module root) |
build.targets |
OS/arch pairs, e.g. linux/amd64 (empty = current platform) |
build.test.skip |
Skip tests before building |
repo.apiurl |
Forgejo/Gitea API URL, e.g. https://git.example.com/api/v1 |
repo.owner |
Repository owner / organization |
repo.name |
Repository name |
repo.token |
API token (PAT) — see secret for syntax |
repo.restrict30x |
Permitted redirect-target prefixes (optional) |
env |
Extra environment variables passed to the build |
Tip
If
repo.tokenis unset, gogogo falls back tonetrc:<host>. Rungo tool dist listfor the full target catalog.
Commands
gogogo [flags] <command> [flags]
-v, --verbose progress and status messages
--debug LEVEL 1=HTTP requests/status, 2=full bodies (implies --verbose)
--expose-secrets show credentials in cleartext in debug output (dangerous)
--version print version and exit
--help help for any command
Commands:
config show configuration
release build and upload a release
status list existing releases
completion generate shell completion script
gogogo config — inspect and migrate configuration
gogogo config default # built-in default config
gogogo config parsed # effective config (defaults + .gogogo.conf)
gogogo config migrate # print migrated config with new sections added
gogogo config migrate --write # update .gogogo.conf in place
migrate adds any sections present in the default config but absent from your .gogogo.conf, as commented-out examples. The operation is idempotent.
gogogo release — build and upload
--commit COMMITISH what to release (default: auto-detect from HEAD)
(empty) use tag at HEAD if present, else
pseudo-version from current branch
@ current local branch
+ default branch HEAD
latest latest tag on default branch (via proxy)
--dry-run show what would happen, no builds, no uploads
--force replace an existing release with the same tag
--local build only, do not upload
--out DIR copy artifacts to DIR (also writes release-info.json)
--skip-verify skip module-proxy verification for stable releases
(env: GOGOGO_RELEASE_SKIP_VERIFY)
--timeout DURATION total timeout for entire build+upload (default 2m)
(env: GOGOGO_RELEASE_TIMEOUT)
--verify during --dry-run, actually run the verification
When --commit is omitted, gogogo inspects HEAD:
- Tagged with a semver tag → that tag is used (after a remote-reachability check).
- Untagged → the current branch name is used, producing a pseudo-version pre-release.
- In both cases, HEAD must be reachable from a remote tracking branch — push first.
gogogo status — list releases
gogogo status # tabular
gogogo status --json # machine-readable
gogogo status --limit 10 # cap the count (0 = all)
Shows tag, name, asset count, creation time, and draft/pre flags.
gogogo completion — shell completion
# bash
source <(gogogo completion bash)
# zsh
gogogo completion zsh > "${fpath[1]}/_gogogo"
# fish
gogogo completion fish | source
The --commit flag completes to git tags, branches, and the special tokens @, +, latest.
Module proxy verification
For stable releases (semver-tagged, e.g. v1.2.0 or v2.0.0-rc.1), gogogo runs go mod download -json module@version in a temp dir and compares the proxy-reported hash with the clone's content hash. This guarantees the release matches what users will get via go get.
For branch builds (pseudo-versions), proxy verification is skipped automatically — the commit SHA already anchors the build, and remote reachability was confirmed during cloning.
Note
Verification is skipped only when
--skip-verifyis passed orGOGOGO_RELEASE_SKIP_VERIFYis set. In that case the version is derived from the highest semver tag pointing at HEAD.
Authentication & private repositories
A single token (repo.token in .gogogo.conf) is used for two independent purposes:
- Cloning — injected as
https://token:<TOKEN>@host/owner/repo.git. - Forgejo API — authenticates release uploads and tag deletion.
For module-proxy verification, the Go toolchain runs separately, so it needs its own credentials. gogogo automatically wires the token through via GIT_CONFIG_* — verification works out of the box for private repos.
Vanity domain + redirect chain
A common Go-vanity setup chains a vanity domain → a legacy git host → the actual Forgejo:
sequenceDiagram
participant U as gogogo
participant V as go.example.com<br/>(vanity)
participant G as git.example.com<br/>(legacy)
participant F as forgejo.example.com<br/>(real)
U->>V: GET /org/repo?go-import=1
V-->>U: meta refresh → git.example.com/org/repo
U->>G: HEAD /org/repo
G-->>U: 301 forgejo.example.com/org/repo
U->>F: clone with credentials for forgejo.example.com
Note over U,F: Token is sent only to the final host —<br/>git does not forward credentials across redirects.
gogogo resolves the chain before injecting the token, so the credential always lands on the final host — both for cloning and for the GIT_CONFIG_* URL rewriting passed to go mod download.
Minimal private-repo config
# .gogogo.conf
env:
GOPRIVATE: "go.example.com"
GOPRIVATE tells Go to skip the public proxy and checksum DB. gogogo handles token injection and redirect resolution automatically.
Advanced: explicit GIT_CONFIG_* for unusual setups
# .gogogo.conf
env:
GOPRIVATE: "go.example.com"
GOPROXY: "direct"
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: "url.https://token:${FORGEJO_TOKEN}@forgejo.example.com/.insteadOf"
GIT_CONFIG_VALUE_0: "https://go.example.com/"
| Variable | Purpose |
|---|---|
GOPRIVATE |
Bypass the proxy for matching modules |
GONOSUMCHECK |
Skip checksum verification for matching modules |
GOPROXY |
Override the module proxy (e.g. direct) |
GIT_CONFIG_COUNT |
Number of process-scoped Git config entries |
GIT_CONFIG_KEY_N |
Git config key (e.g. url....insteadOf) |
GIT_CONFIG_VALUE_N |
Git config value |
User-provided entries are preserved; gogogo's auto-injected entry is appended after them.
Important
--skip-verifyis for the rare case where verification is fundamentally impossible (air-gapped, broken vanity-domain DNS). For everyday private-repo work, just setGOPRIVATE.
Major-version subdirectory modules
gogogo supports the Go major-version subdirectory layout (e.g. v2/ with its own go.mod). When the configured module ends with /vN (N ≥ 2) and the clone has vN/go.mod, gogogo automatically:
- Hashes only the subdirectory (matching what the proxy serves)
- Runs
go listinside the subdirectory - Treats
build.commandsas relative to the subdirectory
Release lifecycle
Releasing a tag
- Create and push a signed semver tag on your default branch.
- Run
gogogo release— the tag at HEAD is auto-detected.
If the tag is annotated (e.g. created via gogogo set-tag), its message is used as the release body on Forgejo. When package registry uploads are configured, install instructions are appended after a separator.
Releasing a branch (pre-release)
gogogo release # uses current branch
gogogo release --commit my-feature # explicit
Untagged HEAD produces a Go pseudo-version pre-release automatically.
Retention policy
flowchart TD
NEW[New release published]
NEW --> KIND{kind?}
KIND -->|pre-release| P1["Strip assets from older pre-releases<br/>(keep the immediate previous one)"]
KIND -->|pre-release| P2[Full releases untouched]
KIND -->|full release| F1[Delete all older pre-releases entirely<br/>release object + git tag]
KIND -->|full release| F2["Strip assets from older full releases<br/>(keep the immediate previous one)"]
"Previous" is always defined by semver order, not upload time.
Native packages
gogogo can automatically generate native distribution packages from your binaries. This makes it easy for users on Debian/Ubuntu, Fedora/RHEL, and other distributions to install your tool via their system package manager.
Binary packages
For Linux targets, gogogo generates .deb (Debian/Ubuntu) and .rpm (Fedora/RHEL) packages from the built binaries. Each package includes:
- Binary installed to
/usr/bin/<name> - Metadata from
.gogogo.confor auto-detected from LICENSE/README - Reproducible — uses the same VCS timestamp as the binary
Source packages
gogogo can also generate source packages (.dsc for Debian, .src.rpm for RPM) with:
- Vendored dependencies —
go mod vendorbundles all deps, so the source builds offline - Changelog — formatted per distribution conventions
- Copyright — from your LICENSE file
Source packages let distributions rebuild your tool with their own toolchain or security patches.
Configuration
Enable packaging in .gogogo.conf:
# Generate binary packages for linux targets
packages: [deb, rpm]
# Optional: customize package metadata and build behavior
formats:
common:
maintainer: "Your Name <you@example.com>"
description: "My tool does X"
homepage: "https://example.com"
license: "Apache-2.0"
deb:
section: "utils" # Debian section (default: misc)
priority: "optional" # Debian priority (default: optional)
depends: ["libc6 (>= 2.31)"] # Additional runtime dependencies
rpm:
group: "Development/Tools" # RPM group (default: empty)
requires: ["glibc"] # Additional runtime dependencies
source:
enabled: true # Generate source packages (default: true)
vendor: true # Run go mod vendor in source (default: true)
Note
Packaging gracefully skips if the required tools are absent.
.debrequiresdpkg-source,.rpmrequiresrpmbuild. Both are optional — gogogo still builds and uploads binaries even if packaging tools are missing.
How it works
After each release build completes:
- Binary packages — for each Linux/arch target,
gogogocreates a.deband.rpmwrapping the built binary. - Source packages — once per release, generates a source
.deb(.dsc+ tarballs) and source.rpm(.src.rpm) with vendored dependencies. - Upload — all packages are included in the Forgejo release alongside the raw binaries.
Verbosity
gogogo is quiet by default — only errors are printed.
| Flag | Effect |
|---|---|
--verbose (-v) |
One-line status per action; progress bars on TTYs; richer error hints. |
--debug 1 |
Above + HTTP request lines and status codes. |
--debug 2 |
Above + full request and response bodies (verbose; secrets are redacted). |
Roadmap
- APT/YUM repository publishing (
gogogo reposubcommand) — host your packages on a Debian or RPM repository server. - Winget + Scoop manifests for Windows releases — auto-publish installers to package registries.
License
Apache-2.0 — see LICENSE.txt.