No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Heiko Schlittermann (ai) b4601eb464
All checks were successful
nagonag (Push) / nagonag (push) Successful in 3m36s
docs(pack): the symlink-verbatim guarantee covers RPM too ai:claude-opus-5
README and gogogo.conf(5) said Debian source packages reproduce a tracked symlink verbatim rather than requiring it to resolve. That was accurate when only the Debian writer did so; the RPM source-tarball writer had the same bug independently and is fixed alongside it. Both docs now name both formats. (ai)
2026-08-18 11:59:01 +02:00
.forgejo/workflows cicd: new workflow 2026-05-21 22:54:40 +02:00
internal fix(exec): redact a token-only URL userinfo in logged arguments ai:claude-opus-5 2026-08-18 11:58:17 +02:00
man docs(pack): the symlink-verbatim guarantee covers RPM too ai:claude-opus-5 2026-08-18 11:59:01 +02:00
.gitignore new: doctor mode and debian release number (closes #38, closes #46) 2026-05-12 23:35:26 +02:00
.gogogo.conf gogogo: package (deb) to forgejo *and* apt.schlittermann.de 2026-07-23 14:23:18 +02:00
.golangci.yml lint: new config 2026-04-14 22:12:55 +02:00
AGENTS.md docs: consolidate agent guidance ai:chatgpt 2026-07-22 17:26:32 +02:00
DESIGN.md docs: consolidate agent guidance ai:chatgpt 2026-07-22 17:26:32 +02:00
go.mod deps(master): update minor/patch dependencies 2026-08-18 04:20:33 +00:00
go.sum deps(master): update minor/patch dependencies 2026-08-18 04:20:33 +00:00
LICENSE.txt new: first PoC 2024-10-02 00:04:36 +02:00
main.go ai: slight improvements 2026-05-23 09:43:26 +02:00
README.md docs(pack): the symlink-verbatim guarantee covers RPM too ai:claude-opus-5 2026-08-18 11:59:01 +02:00

go · go · go

Reproducible release builder for Go modules on Forgejo / Gitea.
Publish what others can build. Verify what your users will get.

License Go Status Targets


Contents


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 publish                                                 │
│   ✓ 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 build.targets and any non-detected repo fields

# at HEAD on a signed semver tag:
gogogo publish

Ahead of a real run, gogogo publish --dry-run shows what would happen without building or uploading.


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 -->|package linux targets| PKG[Deb and RPM packages]
    B -->|publish.blob| PB[publish.blob]
    PKG -->|publish.deb| PD[publish.deb]
    PKG -->|publish.rpm| PR[publish.rpm]
    PB -->|default| RR[releases]
    PB -->|empty list| NONE[no destinations]
    PB -->|explicit names| DESTS[publish.destinations]
    PD -->|default| RP[packages]
    PD -->|empty list| NONE
    PD -->|explicit names| DESTS
    PR -->|default| RP
    PR -->|empty list| NONE
    PR -->|explicit names| DESTS
    RR -->|built-in| TREL[type: forgejo-release]
    RP -->|built-in| TPKG[type: forgejo-packages]
    TREL --> REL[(Forgejo release)]
    DESTS -->|named destination| XREL[type: forgejo-release]
    DESTS -->|named destination| XPKG[type: forgejo-packages]
    DESTS -->|named destination| XDUP[type: dupload]
    CL -->|stamps vcs.revision / vcs.time| B
    classDef soft fill:#f4f4f4,stroke:#bbb,color:#666;
  1. Clone — shallow-clone at the requested ref. Stable releases must point to the same commit locally and remotely.
  2. Verify — compute the module zip hash (golang.org/x/mod/zip) and compare with what go mod download -json reports. Mismatch = abort.
  3. Buildgo build -trimpath runs inside the clone, so the Go toolchain stamps real VCS info into every binary.
  4. Package & publish — route blobs, Debian artifacts, and RPM artifacts through publish.blob, publish.deb, and publish.rpm, using built-in defaults or explicit destinations from publish.destinations, then run retention cleanup.

Each publish.* route accepts default, [], or an explicit destination list. Built-in destination names are releases, packages, and dupload; explicit names resolve through publish.destinations to a type such as forgejo-release, forgejo-packages, or dupload. Blob binaries (and optional .sig sidecars) use publish.blob. Debian binary .deb files use every compatible publish.deb destination; Debian source sidecars and the dupload .changes file use only dupload. RPM binary and source packages use publish.rpm.

If repo config is missing or --skip-upload 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 and isn't bit-for-bit reproducible across runs.


Configuration

.gogogo.conf lives next to your go.mod. Generate the annotated default with gogogo config default.

Field Description
version Config schema version; current new-style config uses 2
require-gogogo-version Minimum gogogo binary version allowed to read this config (e.g. v0.25.0)
build.commands Subdirectories of commands to build (empty = module root)
build.targets OS/arch pairs, e.g. linux/amd64 (empty = current platform)
build.strip Linker strip level: all (default, -s -w), symbols, dwarf, none
build.test.skip Skip tests before building
repo.remote Git remote used to derive repo settings (default: origin)
repo.apiurl Forgejo/Gitea API URL; derived from repo.remote when unset
repo.owner Repository owner / organization; derived from repo.remote when unset
repo.name Repository name; derived from repo.remote when unset
repo.token API token — defaults to netrc:<remote-host> when unset
security.trusted_redirects Permitted redirect-target prefixes that may receive forwarded request headers
artifacts.packages Multi-package split: absent = one package, '*' = one per cmd/, or explicit list (see below)
artifacts.allow_untracked_payload Permit native-package payload sources absent from git ls-files (default: false)
artifacts.blob.extra Extra files to publish as blobs (list of {src, name})
artifacts.common.name Override the source/package name derived from the module path's last component
artifacts.common.maintainer Package maintainer name and email; auto-detects from git committer when unset
artifacts.deb.lintian Run lintian on generated .deb packages (default: true)
artifacts.deb.systemd Auto-generate maintainer scripts for systemd/sysusers/tmpfiles assets (default: true)
artifacts.{common,deb,rpm}.scripts Neutral lifecycle fragments: before-install, after-install, before-remove, after-remove
artifacts.{common,deb,rpm}.permissions Ordered {path, owner, group, mode} ownership/mode overrides
artifacts.deb.conffiles Paths registered as dpkg conffiles
artifacts.rpm.vendor RPM Vendor tag
artifacts.rpm.repository RPM Repository tag
publish.* Artifact destination routing
release.branch Branch where release tags may be created (empty = repository default branch)
env Extra environment variables passed to the build

require-gogogo-version accepts a single semantic version, with or without its leading v; write vX.Y.Z canonically. It is a minimum binary version, not the unrelated version: 2 configuration-schema key. A gogogo binary that supports this key aborts before strict decoding or schema validation when it is too old, with an upgrade hint. gogogo doctor is the exception: it keeps running and reports the mismatch as one failing check, so you still get the whole diagnosis. Older gogogo versions do not recognize the key and instead fail safely with field require-gogogo-version not found in type config.Config.

Tip

If repo.token is unset, gogogo falls back to netrc:<remote-host>. Run go tool dist list for the full target catalog.

Minimal real-world example

A typical project needs only a handful of fields — everything else is auto-detected from go.mod and the origin remote:

version: 2

build:
  targets:
    - linux/amd64
    - linux/arm64
    - darwin/arm64
    - windows/amd64

artifacts:
  common:
    maintainer: "Jane Doe <jane@example.com>"
    description: "My Go tool — short one-liner"
    homepage: "https://example.com/mytool"
    license: "Apache-2.0"

  deb:
    enabled: true   # .deb packages for Linux targets

  rpm:
    enabled: true   # .rpm packages for Linux targets

repo.* fields are omitted here: gogogo derives the Forgejo API URL, owner, and repo name from git remote get-url origin, and picks up the token from ~/.netrc. Add them explicitly only if auto-detection fails or you use a non-standard remote.

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)
      --no-network      kill switch: no clone, no module proxy, no upload
      --ignore-remote   build the local checkout without requiring HEAD on the remote
                        (uploads still happen)
      --version         print version and exit
      --help            help for any command

Commands:
  config       show configuration (subcommands: default, parsed, migrate, packages, auto)
  doctor       run pre-flight checks
  build        run build stage only
  pack         run build + package stages (subcommand: populate)
  publish      run build + package + publish stages
  tag          create the next annotated release tag
  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 new-style config to stdout
gogogo config packages           # scaffold artifacts.packages: block from cmd/* discovery
gogogo config auto               # generate a pre-filled .gogogo.conf from project state

migrate reads the existing .gogogo.conf and prints a new rich commented config. Values matching defaults or auto-detected settings are commented out; non-default user values stay active.

packages generates an artifacts.packages: block. Use --shorthand to emit the packages: '*' one-liner, or --in-place to splice the block directly into .gogogo.conf.

auto analyses the current project (module path, git remote, README, LICENSE, systemd units) and emits a pre-filled .gogogo.conf. By default writes to stdout; use --write to update .gogogo.conf in place (a .bak backup is kept by default).

gogogo tag — create the next release tag
  --approve              approve a guessed or major release for creation
  --bump LEVEL           create the next release tag with LEVEL (patch, minor, or major)
  --dry-run              print the changelog without creating the tag
  --force                replace an existing tag
  --push                 push the tag to the tracking remote after creation
  --gpg-sign MODE        GPG-sign the tag: auto (default), true, or false

If you omit --bump, gogogo guesses from commits since the latest stable release tag. The heuristics are deliberately simple:

  • feat: or feat(scope): means minor
  • feat!: / feat(scope)!: or BREAKING CHANGE in the body means major
  • everything else falls back to patch

Pre-release and pseudo-version tags are ignored when looking for the baseline release. --bump patch and --bump minor imply --approve; --bump major and guessed releases require --approve before anything is created.

Tag creation must run on release.branch. If unset, gogogo uses the repository's default branch, usually main or master. --dry-run can be used from any branch; --force overrides the branch guard.

gogogo build, gogogo pack, gogogo publish
  build       build
  pack        build + package  (subcommand: pack populate — scaffold debian/* and rpm/ stubs)
  publish     build + package + publish

Shared stage flags:
  --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)
  --out DIR            output directory; artifacts written to DIR/{blob,deb,rpm}/
                       required for build and pack; optional for publish
  --sign               GPG-sign release binaries (default: auto from git user.signingkey)
  --testing            publish Debian packages to the *-testing suites
                       (default: auto -- snapshot/pre-release builds go to testing)
                       (pack and publish only)
  --release            publish Debian packages to the release suites even for a
                       snapshot/pre-release build (mutually exclusive with --testing)
                       (pack and publish only)
  --no-lintian         skip lintian check on generated .deb packages
  --skip-doctor        skip release pre-flight checks
  --skip-verify        skip module-proxy verification for stable releases
                       (env: GOGOGO_RELEASE_SKIP_VERIFY)
  --timeout DURATION   total timeout for staged operation (default 2m)
                       (env: GOGOGO_RELEASE_TIMEOUT)
  --allow-unclean      allow local-checkout builds (--no-network / --ignore-remote)
                       from a dirty worktree

Build/pack-only flags:
  --keep-tmp           keep temporary build directories instead of cleaning up

Publish-only flags:
  --dry-run            show what would happen, no builds, no uploads
  --replace            delete and recreate an existing Forgejo release;
                       for Debian, the pool cannot replace so the revision
                       auto-bumps and a warning is emitted (see Re-publishing
                       and Debian revisions below)
  --skip-upload        build and package everything, but upload nothing
                       (combine with --out to keep the artifacts)
  --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 build, gogogo pack, and gogogo publish all run the same pre-flight checks as gogogo doctor unless you pass --skip-doctor.

Two flags relax the normal "clone the remote and build that" model. They are not interchangeable:

Flag Scope Blocks uploads? Use when
--no-network Kill switch — no clone, no module proxy, no upload Yes (so publish requires --skip-upload) Offline, or to keep gogogo's own commands off the network (see the note below — it is not a sandbox)
--ignore-remote Build-time only — HEAD need not be pushed; builds the local checkout No — it makes no statement about publishing "I know this commit isn't pushed, build it anyway"

--no-network implies --ignore-remote.

--ignore-remote changes what is built and from where; it never touches an upload path, so uploads are simply unaffected rather than enabled. What does carry into publishing is the artifacts' provenance: anything built from a local checkout is recorded as such — by either flag — and a later publish says so, even when resumed from --out in a separate invocation that passed no flag at all.

Note

--no-network is not a sandbox. For the build, test and vendor commands it runs, gogogo pins the module proxy, direct-VCS bypass, the checksum database and toolchain selection (GOTOOLCHAIN) off. It cannot police what a project's own test code does, and a few metadata queries (go env, go tool dist list) still run with the ambient environment.

Both build from the local checkout, use a tag at HEAD or derive a local pseudo-version, and skip module-proxy verification (the proxy cannot have a commit the remote does not). Both require a clean worktree unless --allow-unclean is given, and neither bypasses native-package payload tracking: ignored and untracked payload sources still fail unless artifacts.allow_untracked_payload is explicitly enabled.

Warning

--ignore-remote still uploads. If the commit isn't on the remote, the published artifact cannot be reproduced from the repository and go get cannot resolve that version. gogogo warns whenever it publishes such a build.

A forge release is anchored to a git ref, so Forgejo cannot create one for a commit it never received. publish always creates the forge release and does so before other destinations, so publishing a genuinely unpushed commit fails and nothing else uploads either. gogogo appends a note naming the likely cause — it does not claim to have diagnosed it, since the forge reports an absent commit, a rejected token and a server error alike. --ignore-remote is therefore most useful with build/pack, and for publishing a commit that is on the remote but isn't a branch or tag tip (the case auto-detection can't see).

gogogo pack populate scaffolds editable debian/ and rpm/ stub files (with text/template placeholders) so you can fully control package layout. Use --force to overwrite existing stubs.

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. The pseudo-version is derived entirely from the clone's commit time and reachable release tags; gogogo expands a shallow clone when necessary and does not contact the module proxy or vanity import host.

Note

For stable releases, verification is skipped when --skip-verify is passed, GOGOGO_RELEASE_SKIP_VERIFY is set, or GOPRIVATE is configured. In that case the version is derived from the semver tag pointing at HEAD.

Git submodules are rejected

gogogo refuses to build a source tree that records git submodules, on every path (clone, --no-network, and gogogo doctor):

git submodules are not supported: vendor/lib
Go module archives never contain submodule content, so this checkout cannot match the module proxy
and `go get` consumers would not receive these files.
Vendor the content into this repository, or publish the submodule as its own module and require it from go.mod

This is not a gogogo policy preference — the Go toolchain does not support submodules in modules. Go builds a module archive with git archive (cmd/go/internal/modfetch/codehost/git.go), and git archive omits gitlinks. The module zip published for a tag therefore contains no submodule files, and anyone running go get receives a module without them, no matter what gogogo does.

Consequently gogogo does not clone submodules (no git clone --recursive): fetching content that can never appear in the published module would only produce a tree that cannot match the module proxy. Submodules are still detected without being fetched, because gitlinks are recorded in the repository index — including for submodules that are uninitialized or inactive.

To package such content, vendor it into the repository, or publish the submodule as its own module and require it from go.mod.


Authentication & private repositories

repo.token is used for cloning (https://token:<TOKEN>@host/…) and Forgejo API calls. For module-proxy verification the Go toolchain runs separately; gogogo wires the token via GIT_CONFIG_* automatically.

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-verify is for the rare case where verification is fundamentally impossible (air-gapped, broken vanity-domain DNS). For everyday private-repo work, just set GOPRIVATE.


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 list inside the subdirectory
  • Treats build.commands as relative to the subdirectory

Release lifecycle

Releasing a tag

  1. Create and push a signed semver tag on your default branch.
  2. Run gogogo publish — the tag at HEAD is auto-detected.

Releasing a branch (pre-release)

gogogo publish                       # uses current branch
gogogo publish --commit my-feature   # explicit

Untagged HEAD produces a Go pseudo-version pre-release automatically.

Debian suite routing: a branch/pre-release build's .deb, .dsc/debian/changelog, and registry/dupload upload all target the *-testing suites by default (stablestable-testing, configurable via artifacts.deb.testing-suffix / artifacts.deb.testing-distributions) instead of the release suites. Pass --release to opt a specific build back into the release suites, or --testing to force testing mode even for a tagged release. The decision is made at pack time and recorded in gogogo-state.json, so a resumed publish --out cannot land in a different suite than the staged .changes declares.

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.

Re-publishing and Debian revisions

A semver tag (e.g. v0.0.12) corresponds to a single upstream release, but packaging fixes (wrong dependency, broken postinst, etc.) often need to be shipped without touching the source. gogogo handles this with a separate tag namespace and an automatic revision counter:

  • Receipt tag deb/v<X>-<N> — after every successful Debian publish, gogogo writes a GPG-signed annotated tag like deb/v0.0.12-3 at the same commit, with message gogogo: debian packaging revision N for v<X>. The next publish reads this and bumps to 0.0.12-4. The tag namespace is non-semver, so it never participates in release-tag selection. Requires user.signingkey to be set in the local git config (same key gogogo uses for signing binaries).
  • Auto-bump — running gogogo publish after a packaging-fix commit produces 0.0.12-(N+1) without any flag. A verbose log line reports the bump: # deb revision 4 for v0.0.12 (previous: 3).
  • No collision in the Debian pool — the Debian archive format (reprepro, dak, aptly, mini-dak) enforces "one checksum per filename" in pool/. Because each revision has a unique filename, gogogo's revisions land cleanly without conflict.

Re-publishing to an existing Forgejo release

When a Forgejo release for the upstream tag already exists from a prior publish, gogogo publish (without --replace) merges the new artifacts into the existing release:

warning: release for tag v0.0.12 already exists; adding new artifacts (use --replace to recreate)
warning: release asset xr-invoiced-linux-amd64 already exists; skipping (use --replace to overwrite)
  • Assets whose names don't already exist in the release are uploaded normally.
  • Assets whose names match an existing one are skipped with a per-asset warning. This preserves the prior binary and only adds the new revision-suffixed .deb/.dsc/.debian.tar.gz.
  • The release's notes, description, and metadata are preserved.

--replace semantics

--replace is the explicit override. Per transport:

  • Forgejo release — the existing release and its tag are deleted, then a fresh release is created with the new artifacts. Use this when you want to overwrite the binary blobs on the release page or reset the release notes.
  • Debian pool — cannot be replaced (archive-format invariant, not a tool quirk). The revision auto-bumps and a warning is emitted: warning: debian pool rejects same-revision re-uploads; bumping v0.0.12-3 → v0.0.12-4 and writing deb/v0.0.12-4.
  • Had no effect — if --replace was given but neither side actually replaced anything: warning: --replace had no effect; nothing to replace at v0.0.12.

Auto-pruning old revision artifacts

When new revision-N artifacts are uploaded to an existing Forgejo release that still contains revision-(N-1) .deb/.dsc/.debian.tar.gz assets, those obsolete files are auto-pruned so the release shows only the latest revision. The binary blob and .orig.tar.gz (no revision suffix) are not touched. This only applies on the default publish path — --replace already starts from a clean slate.


Native packages (DEB, RPM)

For Linux targets, gogogo generates .deb (Debian/Ubuntu) and .rpm (Fedora/RHEL) packages alongside the raw binaries. Binary packages install to /usr/bin/<name> with metadata from .gogogo.conf; source packages (.dsc/.src.rpm) include vendored dependencies for distribution rebuilds.

Configuration

Enable packaging in .gogogo.conf:

artifacts:
  common:
    maintainer: "Your Name <you@example.com>"
    description: "My tool does X"
    homepage: "https://example.com"
    license: "Apache-2.0"

  deb:
    enabled: true              # Generate .deb packages for Linux targets
    source: false              # Generate Debian source artifacts
    source_vendor: true        # Run go mod vendor in source artifacts
    section: "utils"           # Debian section (default: misc)
    priority: "optional"       # Debian priority (default: optional)
    depends: ["libc6 (>= 2.31)"] # Additional runtime dependencies
    files:                     # Generic files; src supports * and recursive ** globs
      - src: packaging/hooks/  # trailing slash installs directory contents
        dst: /usr/lib/mytool/hooks/
        owner: mytool
        group: mytool
        mode: "0750"
    permissions:
      - path: /var/lib/mytool/** # ** includes this directory and every descendant
        owner: mytool
        group: mytool
        mode: "0640"
    scripts:
      after-install: |
        mytool setup
    systemd-units: [packaging/mytool.service]
    tmpfiles: [packaging/mytool.conf]
    sysusers: [packaging/mytool-users.conf]
    manpages: [man/mytool.1, man/mytool.conf.5.gz]

  rpm:
    enabled: true              # Generate RPM packages for Linux targets
    source: false              # Generate source RPM artifacts
    source_vendor: true        # Run go mod vendor in source artifacts
    group: "Development/Tools" # RPM group (default: empty)
    requires: ["glibc"]        # Additional runtime dependencies
    files: []                  # Generic mappings are supported for RPM too
    systemd-units: [packaging/mytool.service]
    manpages: [man/mytool.1]

Inside a Git worktree, the package payload is what Git tracks, not what happens to be on disk. gogogo compares expanded sources against git ls-files, and how an untracked file is treated depends on how it was selected:

Entry Untracked source
Directory or glob (src: CA/, src: conf/*.ini) Omitted. The sweep ships the tracked part of the tree; --verbose lists what was left out
One file named exactly (src: conf/app.ini) Error. You asked for it by name, so silence would hide a typo or an uncommitted file

So a directory entry keeps working on a machine with local files lying around — an admin's own config, a generated key, a scratch directory — and ships none of them. A directory left empty purely by filtering is dropped too, rather than installing a hollow path; a directory that is genuinely empty in the tree is still installed.

Version-control metadata is omitted from sweeps even though it is tracked: .gitignore, .gitattributes, .gitmodules, .cvsignore, .hgignore, .hgtags, .arch-inventory, .hg_archival.txt. That is lintian's own vcs-control-files list — shipping them earns W: package-contains-vcs-control-file from the lintian run gogogo performs anyway. Name such a file explicitly if you really want it.

A tracked symlink may only dereference another tracked file inside the worktree, so a link cannot smuggle in its target's content. Debian and RPM source packages answer this differently on purpose: they reproduce every tracked symlink verbatim, target string and all, because a source package must mirror the tree git records — including a link whose target the repository ignores, which dangles in the fresh clone gogogo builds from. Reproducing a link copies no content, so nothing can be smuggled that way either. Source trees that are not Git worktrees, and sources outside the worktree, are untouched. This is all independent of --allow-unclean, which only concerns uncommitted changes. Set artifacts.allow_untracked_payload: true to disable the whole mechanism when generated files are packaged deliberately.

Lifecycle fragments are composed in deterministic order: gogogo-generated activation, common, format, then package scope. By default each user fragment is protected by the appropriate Debian or RPM lifecycle guard; use {file: scripts/hook.sh, raw: true} only when the fragment handles $1 itself. RPM receives matching transaction scriptlets for sysusers, tmpfiles, and systemd assets.

permissions.path matches installed package paths (including conffiles). * stays in one path segment while ** is recursive (and matches its base directory). Later matching entries win field by field, and standalone permissions entries are authoritative over inline owner/group/mode on a file entry — use permissions to override. An exact path matching nothing is an error; a glob matching nothing is a warning.

Debian ships payloads as root:root: mode-only changes are written directly, while non-root ownership generates dpkg-statoverride fragments. A marker below /var/lib/gogogo/statoverrides/<package>/ records only overrides gogogo actually added. On upgrade, an unchanged package-owned override is updated, an override removed from configuration is cleaned up, and an administrator-modified override is relinquished. On purge, only still-matching marked overrides are removed, so pre-existing and administrator-modified overrides survive.

RPM records owner, group, and mode natively in the header. Packages with sysusers assets require /usr/bin/systemd-sysusers and create accounts in %pre (before payload unpack); account-creation errors abort installation rather than silently leaving requested files root-owned.


publish:
  blob: default                # default: Forgejo/Gitea release assets
  deb:
    - packages                 # Forgejo/Gitea Debian package registry
    - debian_upload            # extra dupload destination
  rpm: default                 # default: Forgejo/Gitea RPM package registry

  destinations:
    debian_upload:
      type: dupload
      host: myrepo             # nickname from ~/.dupload.conf
      keyid: ABC123            # optional debsign key
      configfile: ~/.dupload.conf.local # optional alternate dupload config
      dir: ~/.dupload-uploads/mytool    # optional staging dir outside the module root

Note

Packaging gracefully skips source-package generation if required tools are absent. Debian source artifacts require dpkg-source; source RPMs require rpmbuild. Binary .deb and .rpm artifacts are still built without those source tools.

Note

Debian source package routing: Debian source artifacts (.dsc, .orig.tar.*, .debian.tar.*, .diff.gz) are automatically classified and routed alongside binary .deb files. With --outdir, they go to outdir/deb/ (not outdir/blobs/). Both binary and source packages are sent to dupload destinations. Forgejo's Debian package registry receives only binary .deb files, not source sidecars.

Warning

Debian source sidecars are not uploaded to Forgejo release assets or its Debian package registry. Configure a dupload destination when source packages need to be uploaded.

Multi-binary packages

artifacts.packages controls how cmd/* binaries are split across .deb/.rpm packages.

Value Behaviour
absent (default) all binaries in one package named after the module
'*' one package per cmd/* binary, named after the binary
explicit list full control: name, binary assignment, per-package deb/rpm overrides
artifacts:
  packages:
    - name: myapp-server
      binaries: [myapp-server]
      deb:
        depends: ["libsystemd0"]
    - name: myapp-client
      binaries: [myapp-client]

Entries in binaries may be bare names (myapp-server) or full Go package paths (cmd/myapp-server) — both are accepted. Package-level files, tmpfiles, sysusers, systemd-units, and manpages apply to both formats; put them under deb or rpm for one format only. These lists concatenate with global and format-specific entries deterministically.

Dedicated lists take source paths or globs. They install tmpfiles in /usr/lib/tmpfiles.d, sysusers in /usr/lib/sysusers.d, units in /usr/lib/systemd/system, and man pages in /usr/share/man/manN. Man pages must be named name.N or name.N.gz for section 19; plain roff is reproducibly gzipped. The legacy omitted files[].dst inference remains for unit/tmpfiles/sysusers names, but gogogo doctor warns to migrate those entries to a dedicated list.

Use gogogo config packages to generate this block from cmd/* discovery, or gogogo config packages --shorthand for the packages: '*' one-liner.

Use gogogo pack populate to scaffold editable debian/ and rpm/ template files when you need full control over the package layout.

Using dupload

type: dupload adds a Debian publishing path that stages packages and uploads them via the dupload tool — for example, to an incoming queue or a self-hosted APT repository front-end. It works alongside or instead of Forgejo's built-in package registry.

Prerequisites

apt install devscripts dupload   # debsign comes from devscripts

gogogo doctor checks for both tools and fails with an actionable hint if either is missing. Its upload plan groups by artifact class (blob, deb, rpm), then by destination, listing the artifacts each destination would receive; it shows only aliases, destination types, Debian suite/component routes, and dupload hosts, never tokens or signing keys. A disabled class, or one whose publish route is explicitly empty ([]), is shown as disabled, while generated artifacts without an effective destination get a warning. You do not need to run doctor manually; gogogo publish runs it automatically.

How the upload works

For each release that produces .deb files, gogogo:

  1. Creates a staging directory — default $XDG_CACHE_HOME/gogogo/dupload-uploads/<package-name> (typically ~/.cache/gogogo/dupload-uploads/<package-name>), or the path in dir. The directory is created if it does not exist and must be outside the module root.
  2. Copies artifacts — each .deb and any Debian source-package sidecars (.dsc, .tar.gz, etc.) are copied in flat; the original build directory is left untouched.
  3. Writes a .changes file<name>_<version>.changes is generated from the staged files plus metadata taken from .gogogo.conf:
    • Maintainer / Changed-By from artifacts.common.maintainer
    • Description from artifacts.common.description
    • Distribution is the space-joined list of artifacts.deb.distributions (default stable) so reprepro can land one .changes in multiple suites atomically
    • Section and Priority from artifacts.deb.section / artifacts.deb.priority
    • Checksums-Sha1, Checksums-Sha256, and Files computed from the staged files
  4. Signs with debsign — runs debsign [-k<keyid>] <changes> in the staging directory. The key must be available in the local GPG keyring. Signing happens during the pack phase; the dupload step is upload-only.
  5. Uploads with dupload — before calling dupload, gogogo checks a SHA256 sidecar (.changes.gogogo-sha256) left by the last successful upload of this .changes file. If the signed content is identical, the upload is skipped. Otherwise any stale dupload upload record (.upload) is removed so dupload re-uploads cleanly. On success, the sidecar is updated and payload files (.deb, .dsc, .changes, etc.) are purged from the staging directory; the sidecar itself is kept for future dedup checks.

~/.dupload.conf examples

dupload supports several transport methods. Choose the one that matches your repository's incoming queue:

# ~/.dupload.conf

# scp — most common for private repos and Debian Mentors work
$cfg{'myrepo'} = {
  fqdn     => 'repo.example.com',
  method   => 'scp',
  incoming => '/srv/incoming/debian',
  login    => 'upload',
};

# ftp — legacy, but still used by some self-hosted reprepro setups
$cfg{'myftp'} = {
  fqdn     => 'repo.example.com',
  method   => 'ftp',
  incoming => '/queue/incoming',
  login    => 'anonymous',
};

# local — useful for testing; copies to a local directory
$cfg{'local'} = {
  fqdn     => 'localhost',
  method   => 'local',
  incoming => '/var/spool/incoming/debian',
};

Refer to man dupload.conf for the full option set, including dinstall_runs, post_upload_command, and preupload.

Full configuration example

# .gogogo.conf

artifacts:
  common:
    maintainer: "Jane Doe <jane@example.com>"
    description: "My tool does X"

  deb:
    enabled: true
    distributions: ["stable"]   # written into the .changes Distribution field
    section: "utils"
    priority: "optional"

publish:
  deb:
    - packages                   # Forgejo Debian package registry
    - debian_upload              # additionally upload via dupload

  destinations:
    debian_upload:
      type: dupload
      host: myrepo               # nickname from ~/.dupload.conf
      keyid: ABC123              # GPG key for debsign (optional; omit to use default key)
      configfile: ~/.dupload.conf.local  # alternate config file (optional)
      dir: ~/.dupload-uploads/mytool    # staging dir outside the module root (optional)

To upload to both the Forgejo package registry and an external repository via dupload, list both destinations:

publish:
  deb:
    - packages        # built-in: Forgejo/Gitea Debian package registry
    - debian_upload   # custom destination defined in publish.destinations

  destinations:
    debian_upload:
      type: dupload
      host: myrepo    # nickname from ~/.dupload.conf
      keyid: ABC123   # optional GPG key for debsign

packages is the built-in alias for type: forgejo-packages; releases is the built-in alias for type: forgejo-release. Neither needs to be defined under destinations. To skip Forgejo and upload only via dupload:

publish:
  deb:
    - debian_upload   # [] would disable all deb publishing

Reference

Field Required Default Description
type yes Must be dupload
host yes Nickname matching a stanza in ~/.dupload.conf
keyid no GPG default key Key passed as -k<keyid> to debsign
configfile no ~/.dupload.conf Alternate dupload config file
dir no $XDG_CACHE_HOME/gogogo/dupload-uploads/<package-name> Staging directory; ~/ is expanded; must be outside module root

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 repo subcommand) — 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.