Compare commits

..

1 Commits

Author SHA1 Message Date
ea3d702b1f 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.
2026-08-09 15:51:29 +00:00
5 changed files with 72 additions and 5 deletions

View File

@@ -14,6 +14,39 @@ jobs:
# the commits since the previous one. A shallow checkout # the commits since the previous one. A shallow checkout
# silently produces a mislabelled release. # silently produces a mislabelled release.
fetch-depth: 0 fetch-depth: 0
# goreleaser is not a compiler: it shells out to `go` for the
# `before:` hook and for every one of the four cross-compiles.
# Nothing else in this repo puts a Go toolchain on the runner --
# check.yml runs script/cibuild, which does all of its work inside
# the digest-pinned Dockerfile images -- so without this step the
# release either fails at the before-hook or, worse, ships binaries
# built by whatever unpinned Go the runner happens to carry.
# REPO_POLICIES.md requires every external reference to be pinned,
# and script/release already refuses a goreleaser that is not the
# pinned build; the compiler that actually produces the artifacts
# is the last thing that should be exempt from that.
#
# go-version-file rather than a literal: go.mod's `go 1.26.1` is
# the single source of truth for the toolchain, the same way the
# Dockerfile FROM line is the single source of truth for the
# linter version that script/lint enforces. It is a three-component
# version, so setup-go resolves it exactly -- no silent drift onto
# a newer patch release.
#
# actions/setup-go v5.6.0, 2025-12-15. Pinned by commit sha, like
# the checkout above. v5.x is a node20 action, matching the node20
# actions/checkout v4 already in use here; the v6/v7 line requires
# a node24 runner, which this Gitea runner has never been asked
# for and cannot be assumed to provide.
- name: Install Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
# setup-go's module cache needs a runner-side cache backend.
# A release is cut rarely and a cold module download costs
# seconds; a release failing because a cache service is absent
# costs a re-tag. Off, deliberately.
cache: false
- name: Install goreleaser - name: Install goreleaser
run: script/install-goreleaser run: script/install-goreleaser
- name: Release - name: Release

View File

@@ -6,6 +6,16 @@
# had never been tagged. # had never been tagged.
VERSION := $(shell script/version) VERSION := $(shell script/version)
# $(shell) discards exit status, so a script/version that is missing,
# non-executable or broken would otherwise leave VERSION empty and every
# binary built here would print "vaultik " with no version at all. A
# build that cannot determine what it is must not produce an artifact.
ifeq ($(strip $(VERSION)),)
$(error script/version produced no version string; a build that cannot \
determine its version will not be made. Check that script/version exists \
and is executable)
endif
# Build variables # Build variables
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
GIT_COMMIT_DATE := $(shell git show -s --format=%cs HEAD 2>/dev/null || echo "unknown") GIT_COMMIT_DATE := $(shell git show -s --format=%cs HEAD 2>/dev/null || echo "unknown")

View File

@@ -701,7 +701,10 @@ agrees with it:
A build that is not a release never names itself like one. `vaultik 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 version` says so in as many words on a development build, and
`goreleaser --snapshot` stamps the same `dev-<sha>` string rather than `goreleaser --snapshot` stamps the same `dev-<sha>` string rather than
inventing the next patch number. 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 ### cutting a release
@@ -712,8 +715,9 @@ git tag -a v1.2.3 -m 'v1.2.3'
git push origin v1.2.3 git push origin v1.2.3
``` ```
`.gitea/workflows/release.yml` triggers on `v*` tags, installs the `.gitea/workflows/release.yml` triggers on `v*` tags, installs a Go
pinned `goreleaser`, and runs `script/release`, which builds toolchain and the pinned `goreleaser`, and runs `script/release`, which
builds
`linux,darwin × amd64,arm64` archives plus `checksums.txt` and publishes `linux,darwin × amd64,arm64` archives plus `checksums.txt` and publishes
them to this repository's Gitea releases as a draft. `.goreleaser.yaml` 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`; has a `gitea_urls:` block pointing at `https://git.eeqj.de/api/v1`;
@@ -729,6 +733,15 @@ It is passed to `goreleaser` as `GITEA_TOKEN`. The runner's automatic
token is deliberately not used: it is not guaranteed to carry release token is deliberately not used: it is not guaranteed to carry release
write access. 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: To rehearse the whole build without publishing or tagging anything:
``` ```

View File

@@ -63,8 +63,15 @@ func New() (*Globals, error) {
// a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant) // a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant)
// count: a caller that compares against "dev" exactly would treat every // count: a caller that compares against "dev" exactly would treat every
// commit-stamped development build as a release. // commit-stamped development build as a release.
//
// The empty string counts too. Nothing that knows its version reports
// no version, so an empty Version means the stamping failed, and the
// safe reading of "we could not establish that this is a release" is
// that it is not one. The Makefile refuses to build at all in that
// case; this is the second line of defence, for a binary linked by
// something other than the Makefile.
func IsDevVersion(v string) bool { func IsDevVersion(v string) bool {
return v == DevVersion || strings.HasPrefix(v, DevVersion+"-") return v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-")
} }
// shortCommitLen is the number of commit-hash characters ShortCommit keeps. // shortCommitLen is the number of commit-hash characters ShortCommit keeps.

View File

@@ -59,7 +59,11 @@ func TestIsDevVersion(t *testing.T) {
// the string happens to contain "dev". // the string happens to contain "dev".
{"1.0.0-dev", false}, {"1.0.0-dev", false},
{"developer", false}, {"developer", false},
{"", false}, // A binary with no version string at all did not get stamped,
// which is a build failure, not a release. It must never print
// as one. The Makefile refuses to build when script/version
// yields nothing; this covers a binary linked some other way.
{"", true},
} }
for _, tc := range cases { for _, tc := range cases {