Make the tagged-release path work on Gitea (closes #65)
All checks were successful
check / check (pull_request) Successful in 2m24s

No tag could be cut from this repo at all. Three independent blockers.

goreleaser was configured for GitHub while the repo lives on Gitea:
.goreleaser.yaml had a release: block but no gitea_urls:, so goreleaser
defaulted to the GitHub API and a release would have failed or published
somewhere nobody is looking. It now points at https://git.eeqj.de/api/v1.

The version was a hardcoded Makefile constant, VERSION := 1.0.0-rc.1, so
every local build claimed to be a release candidate that had never been
tagged and did not exist, while git tag -l was empty and internal/globals
defaulted to dev. The version now comes from git, via the new
script/version: the exact tag with a leading v stripped when HEAD is on
one (so a make build and a goreleaser build of the same commit report the
same string, and it matches the archive names), otherwise dev-<12-char
sha>, with -dirty appended in either case when tracked files are
modified. Untracked files are not counted, matching git describe --dirty.
goreleaser's snapshot template gets the same treatment: it was
{{ incpatch .Version }}-next, which manufactures a release number from
the last tag and, with no tags at all, from goreleaser's fabricated
v0.0.0.

That change had one non-obvious consequence. internal/cli/version.go
gated its "this is a development build" notice on the version being
exactly "dev", so as soon as untagged builds carried a commit sha the
notice would have gone silent and an unreleased binary would have read as
a release. The gate is now globals.IsDevVersion, a predicate over a
string rather than a comparison against a global so that it can be
tested, and it is tested at the boundary that matters: dev-<sha> and its
-dirty variant are development builds, 1.0.0-dev and 1.0.0-rc.1 are not.
The command writes to cmd.OutOrStdout() so its output can be asserted on
at all.

Releases now come from CI rather than a workstation: a tag-triggered
.gitea/workflows/release.yml, with fetch-depth: 0 because a shallow
checkout has no tags and would silently mislabel the release, and with
the RELEASE_TOKEN repository secret passed as GITEA_TOKEN (documented in
README.md; the runner's automatic token is deliberately not used, since
it is not guaranteed to carry release write scope). script/release unsets
any GITHUB_TOKEN or GITLAB_TOKEN it finds, because goreleaser picks its
forge from whichever token variable is set and refuses to run when it
sees more than one -- an unrelated runner token must not get to decide
where these artifacts are published.

make release and make release-snapshot were the last two Makefile targets
that were not shims; they now call script/release and
script/release-snapshot, which resolve goreleaser the way script/lint
resolves the linter -- a PATH binary is accepted only at the pinned
version, never as a silent fallback. script/bootstrap installs it from a
sha256-verified GitHub release archive per REPO_POLICIES.md, through a
separate script/install-goreleaser: separate because script/bootstrap
hard-fails without a usable Docker daemon by design, and the release
runner needs goreleaser without needing Docker. dist/ and .tool/ are
gitignored and excluded from the Docker build context.

The release workflow installs its own Go toolchain, pinned. goreleaser
is not a compiler: it shells out to go for the before: hook and for all
four cross-compiles, and nothing else in this repo puts a toolchain on
the runner, since check.yml does all of its work inside the
digest-pinned Dockerfile images. Without that step a tag either fails at
the before-hook or, worse, ships binaries built by whatever unpinned Go
the runner happens to carry -- the one unpinned thing in a release path
whose every other input is hash-pinned, in a repo whose policy admits no
exceptions and whose own script/release refuses a goreleaser that is not
the pinned build. actions/setup-go is pinned by commit sha like the
checkout above it, and reads its version from go.mod rather than
restating it.

An unobtainable version can no longer produce a binary at all. $(shell)
discards exit status, so a missing or broken script/version left VERSION
empty and the build went ahead and stamped nothing; the Makefile now
stops with an error instead. IsDevVersion("") became true as the second
line of defence, for a binary linked by something other than the
Makefile: nothing that knows its version reports no version, so an empty
version means the stamping failed, and a build that cannot be shown to
be a release is not one. This is the same defect class as the notice
that went silent above, one layer down.

Verified by running it: make release-snapshot produces the four
linux,darwin x amd64,arm64 archives plus checksums.txt, and the binary
from dist/ reports dev-<sha> with the development-build notice. Tag
handling was exercised in a throwaway repository; no tag was created
here, since that is the owner's call. Signing, SBOM, reproducible builds,
shell completions and a man page remain out of scope.
This commit is contained in:
2026-08-09 15:35:21 +00:00
parent b6e4a218a3
commit ea3d702b1f
16 changed files with 769 additions and 28 deletions

View File

@@ -606,6 +606,19 @@ them. We provide:
`script/bootstrap`, then `script/install-precommit`
* `script/projectname` — print the project name (used for the Docker
image tag)
* `script/version` — print the version string to bake into the binary.
The `Makefile`'s `LDFLAGS` call this; it is the single source of truth
for the version. See [releasing](#releasing) for the rules.
* `script/install-goreleaser` — install the pinned `goreleaser` into
`.tool/bin` from a sha256-verified release archive. Idempotent, and
called by `script/bootstrap`; the release workflow calls it directly
because it needs `goreleaser` but not the Docker daemon
`script/bootstrap` insists on.
* `script/release` — cross-compile and publish the release artifacts
with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose
version is not the pinned one, on the same reasoning as `script/lint`.
* `script/release-snapshot` — the same build with no publishing and no
tagging, into `./dist`
* `script/test` — run the test suite (verbose rerun on failure). This
runs *everything*: there is no separate integration target and no
build-tagged subset held back, so the full round-trip tests in
@@ -669,6 +682,78 @@ them. We provide:
* `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## releasing
### version numbers
The version a binary reports comes from git, not from a constant in a
file. `script/version` decides it, and everything that stamps a binary
agrees with it:
* `HEAD` is exactly on a tag → that tag with a leading `v` stripped, so
the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name
`vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser` strips the prefix the
same way.
* anything else → `dev-<12 chars of the commit sha>`.
* either, with uncommitted changes to tracked files → a `-dirty`
suffix, because a modified checkout of a tag is not that tag.
A build that is not a release never names itself like one. `vaultik
version` says so in as many words on a development build, and
`goreleaser --snapshot` stamps the same `dev-<sha>` string rather than
inventing the next patch number. If `script/version` cannot be run at
all, `make` stops with an error instead of building an unversioned
binary, and a binary that somehow carries an empty version string still
reports itself as a development build.
### cutting a release
Releases are cut by CI from a tag, not from a workstation:
```
git tag -a v1.2.3 -m 'v1.2.3'
git push origin v1.2.3
```
`.gitea/workflows/release.yml` triggers on `v*` tags, installs a Go
toolchain and the pinned `goreleaser`, and runs `script/release`, which
builds
`linux,darwin × amd64,arm64` archives plus `checksums.txt` and publishes
them to this repository's Gitea releases as a draft. `.goreleaser.yaml`
has a `gitea_urls:` block pointing at `https://git.eeqj.de/api/v1`;
without it `goreleaser` would talk to the GitHub API.
The workflow needs one repository Actions secret:
| Secret | What it is |
| --------------- | ------------------------------------------------------------------------------------------------------- |
| `RELEASE_TOKEN` | A Gitea access token with `write:repository` scope, owned by an account that can publish releases here. |
It is passed to `goreleaser` as `GITEA_TOKEN`. The runner's automatic
token is deliberately not used: it is not guaranteed to carry release
write access.
The Go toolchain that compiles the released binaries comes from an
`actions/setup-go` step pinned by commit sha, reading its version from
`go.mod` (currently `1.26.1`, the same version the `Dockerfile` builder
stage pins by digest). `goreleaser` shells out to `go` for every
cross-compile, so without that step the release would either fail
outright or ship binaries built by whatever unpinned toolchain the
runner happened to carry — the one unpinned thing in an otherwise
hash-pinned release path.
To rehearse the whole build without publishing or tagging anything:
```
make release-snapshot
```
Artifacts land in `./dist`, which is gitignored.
Release artifacts are not signed, carry no SBOM, and are not built
reproducibly; the archives contain the binary, `LICENSE`, and
`README.md` only (no shell completions or man page).
## license
[MIT](https://opensource.org/license/mit/)