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

@@ -3,6 +3,8 @@
*.md
LICENSE
vaultik
dist
.tool
coverage.out
coverage.html
.DS_Store

View File

@@ -0,0 +1,60 @@
name: release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
# actions/checkout v4, 2024-09-16
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
# goreleaser needs the tags and the full history: the version
# it stamps comes from the tag, and the changelog comes from
# the commits since the previous one. A shallow checkout
# silently produces a mislabelled release.
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
run: script/install-goreleaser
- name: Release
run: script/release
env:
# RELEASE_TOKEN is a repository Actions secret: a Gitea access
# token with write access to this repository's releases (scope
# write:repository), owned by an account that can publish here.
# It is deliberately not the runner's automatic token, which is
# not guaranteed to carry that scope.
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}

6
.gitignore vendored
View File

@@ -1,6 +1,12 @@
# Binary
/vaultik
# goreleaser output
/dist/
# Locally installed pinned tools (script/install-goreleaser)
/.tool/
# Test artifacts
*.out
*.test

View File

@@ -2,6 +2,13 @@ version: 2
project_name: vaultik
# This repo lives on Gitea, not GitHub. Without this block goreleaser
# talks to the GitHub API by default and a `goreleaser release` either
# fails outright or publishes somewhere nobody is looking.
gitea_urls:
api: https://git.eeqj.de/api/v1
download: https://git.eeqj.de
before:
hooks:
- go mod tidy
@@ -37,8 +44,14 @@ checksum:
name_template: "checksums.txt"
algorithm: sha256
# A snapshot is not a release and must not name itself like one. The
# previous `{{ incpatch .Version }}-next` derived a plausible-looking
# release number from the last tag -- and with no tags in the repo at
# all, from goreleaser's fabricated v0.0.0. This produces the same
# string script/version produces for an untagged build, so a snapshot
# binary and a `make vaultik` binary of the same clean commit agree.
snapshot:
version_template: "{{ incpatch .Version }}-next"
version_template: "dev-{{ slice .FullCommit 0 12 }}"
changelog:
sort: asc

View File

@@ -1,7 +1,20 @@
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks
# Version number
VERSION := 1.0.0-rc.1
# Version number, derived from git by script/version -- the tag when
# HEAD is on one, otherwise dev-<sha>. This used to be a hardcoded
# constant, which meant every local build claimed to be a release that
# had never been tagged.
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
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
@@ -87,11 +100,11 @@ install: vaultik
# Build and publish release artifacts (linux/darwin × amd64/arm64) via goreleaser.
release:
goreleaser release --clean
@script/release
# Dry-run a release build without publishing or tagging.
release-snapshot:
goreleaser release --clean --snapshot
@script/release-snapshot
# Build Docker image.
docker:

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/)

65
TODO.md
View File

@@ -14,10 +14,73 @@ pre-1.0
# Next Step
Define remaining scope for a first tagged release and cut v0.1.0.
Define the remaining scope for the first tagged release under the 1.0.0
milestone, then cut that tag. The mechanism to cut it now exists and is
exercised; what is left is the scope decision, which is the owner's.
This step deliberately names one version number: it previously said
"cut v0.1.0" while the `Makefile` baked in `1.0.0-rc.1` and the issue
milestone said 1.0.0, and three different answers to "what is the next
release" is exactly the contradiction
[issue #65](https://git.eeqj.de/sneak/vaultik/issues/65) was filed over.
# Completed Steps
- 2026-08-09: Made the tagged-release path actually work on Gitea
([issue #65](https://git.eeqj.de/sneak/vaultik/issues/65)). Three
independent blockers, one of which was the whole
release: `.goreleaser.yaml` had no `gitea_urls:` block, so goreleaser
defaulted to the GitHub API and a `goreleaser release` from this repo
would have failed or published where nobody is looking. It now points
at `https://git.eeqj.de/api/v1`. The version is the second: it was a
hardcoded `VERSION := 1.0.0-rc.1` in the `Makefile`, 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`. Version now comes from git via the new
`script/version` — the exact tag with a leading `v` stripped (so a
`make` build and a goreleaser build of one 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 deliberately not counted, matching
`git describe --dirty`. The same honesty was owed by the snapshot
path: `snapshot.version_template` 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`; it now emits the
same `dev-<sha>`. The one non-obvious consequence is that
`internal/cli/version.go` gated its "this is a development build"
notice on the version being exactly `dev`, so the moment untagged
builds began carrying a commit sha that notice would have gone silent
and an unreleased binary would have read as a release — the gate is
now `globals.IsDevVersion`, which is a predicate over a string rather
than a comparison against a global precisely so it can be tested, and
it is tested at the boundary (`1.0.0-dev` is a release, `dev-<sha>`
is not). Release automation is the third blocker: a tag-triggered
`.gitea/workflows/release.yml` runs the build in CI rather than from
a laptop, 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 not used because it
is not guaranteed to carry release write scope). `script/release`
unsets any `GITHUB_TOKEN`/`GITLAB_TOKEN` it finds, since goreleaser
chooses its forge from whichever token variable is set and refuses to
run when it sees more than one — a runner-provided token must not get
to decide where these artifacts are published. `make release` and
`make release-snapshot`, the last two Makefile targets that were not
shims, now call `script/release` and `script/release-snapshot`, which
resolve goreleaser exactly the way `script/lint` resolves the linter:
a `PATH` binary is used 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`, via 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. Verified by running
the thing rather than reading it: `make release-snapshot` produced
four archives and `checksums.txt`, and the linux/amd64 binary from
`dist/` reports `dev-<sha>` with the development-build notice. Tag
handling was exercised in a throwaway repository rather than by
tagging this one; no tag was created here, since that is the owner's
call. Signing, SBOM, reproducible builds, completions and a man page
are out of scope by the issue.
- 2026-08-09: Isolated the lint cache per worktree and context-gated the
native lint path (issues #99, #80). One defect seen twice:
`script/lint` decided whether it could skip the pinned image by asking

View File

@@ -2,7 +2,7 @@ package cli
import (
"fmt"
"os"
"io"
"runtime"
"github.com/spf13/cobra"
@@ -16,28 +16,35 @@ func NewVersionCommand() *cobra.Command {
Short: "Print version information",
Long: `Print version, git commit, and build information for vaultik.`,
Args: cobra.NoArgs,
Run: func(_ *cobra.Command, _ []string) {
_, _ = fmt.Fprintf(os.Stdout, "vaultik %s\n", globals.Version)
_, _ = fmt.Fprintf(os.Stdout, " commit: %s\n", globals.Commit)
_, _ = fmt.Fprintf(os.Stdout, " build date: %s\n", globals.CommitDate)
_, _ = fmt.Fprintf(os.Stdout, " go: %s\n", runtime.Version())
_, _ = fmt.Fprintf(os.Stdout, " os/arch: %s/%s\n",
runtime.GOOS, runtime.GOARCH)
_, _ = fmt.Fprintf(os.Stdout, " author: %s\n", globals.Author)
_, _ = fmt.Fprintf(os.Stdout, " homepage: %s\n", globals.Homepage)
_, _ = fmt.Fprintf(os.Stdout, " license: %s\n", globals.License)
if globals.Version == "dev" {
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout,
"This is a development build (no version information embedded).")
_, _ = fmt.Fprintln(os.Stdout,
"Build a release binary with 'make vaultik' or download from")
_, _ = fmt.Fprintln(os.Stdout,
"https://sneak.berlin/go/vaultik for embedded version metadata.")
}
Run: func(cmd *cobra.Command, _ []string) {
writeVersion(cmd.OutOrStdout())
},
}
return cmd
}
// writeVersion prints the version report. It takes a writer rather than
// using os.Stdout directly so the output can be asserted on in tests.
func writeVersion(w io.Writer) {
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
if globals.IsDevVersion(globals.Version) {
_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintln(w,
"This is a development build: it was not built from a tagged")
_, _ = fmt.Fprintln(w,
"commit, so it carries no release version. Released binaries")
_, _ = fmt.Fprintf(w,
"are published at %s\n", globals.ReleasesURL)
_, _ = fmt.Fprintln(w,
"and report their tag on the first line above.")
}
}

View File

@@ -0,0 +1,77 @@
package cli_test
import (
"bytes"
"strings"
"testing"
"sneak.berlin/go/vaultik/internal/cli"
"sneak.berlin/go/vaultik/internal/globals"
)
// runVersionCommand executes `vaultik version` with its output
// captured, and returns what it printed.
func runVersionCommand(t *testing.T) string {
t.Helper()
cmd := cli.NewVersionCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err != nil {
t.Fatalf("version command failed: %v", err)
}
return out.String()
}
// TestVersionCommandReportsBuildVersion checks that the first line of
// the report is the version the binary was actually built with. The
// test binary carries no -ldflags, so that is the "dev" default -- the
// same string an untagged `make vaultik` build stamps a prefix of.
func TestVersionCommandReportsBuildVersion(t *testing.T) {
t.Parallel()
out := runVersionCommand(t)
wantFirst := "vaultik " + globals.Version
if first, _, _ := strings.Cut(out, "\n"); first != wantFirst {
t.Errorf("first line = %q, want %q", first, wantFirst)
}
if !strings.Contains(out, "commit:") {
t.Error("output does not report the commit")
}
}
// TestVersionCommandFlagsDevelopmentBuild is the regression test for
// the thing this command exists to prevent: a build that is not a
// release must say so. The notice used to be gated on the version
// being exactly "dev", so once untagged builds started carrying their
// commit sha it would have gone silent and an unreleased binary would
// have looked like a release.
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
t.Parallel()
if !globals.IsDevVersion(globals.Version) {
t.Skipf("test binary was stamped with release version %q",
globals.Version)
}
out := runVersionCommand(t)
if !strings.Contains(out, "development build") {
t.Errorf("dev build did not print the development-build notice:\n%s",
out)
}
if !strings.Contains(out, globals.ReleasesURL) {
t.Errorf("development-build notice does not point at %s:\n%s",
globals.ReleasesURL, out)
}
}

View File

@@ -3,14 +3,23 @@
package globals
import (
"strings"
"time"
)
// Appname is the application name, populated from main().
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
// DevVersion is the version a binary reports when it was not built
// from a tagged commit. script/version emits either this exact string
// (outside a git checkout) or this string followed by "-" and the
// commit it was built from, and goreleaser's snapshot template matches
// that shape. It is deliberately not a number: a build that is not a
// release must not name itself like one.
const DevVersion = "dev"
// Version is the application version, populated from main().
var Version = "dev" //nolint:gochecknoglobals // set via -ldflags at build time
var Version = DevVersion //nolint:gochecknoglobals // set via -ldflags at build time
// Commit is the git commit hash, populated from main().
var Commit = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time
@@ -24,6 +33,9 @@ const Author = "Jeffrey Paul <sneak@sneak.berlin>"
// Homepage is the canonical URL for vaultik.
const Homepage = "https://sneak.berlin/go/vaultik"
// ReleasesURL is where tagged release artifacts are published.
const ReleasesURL = "https://git.eeqj.de/sneak/vaultik/releases"
// License is the SPDX identifier for the project license.
const License = "MIT"
@@ -47,6 +59,21 @@ func New() (*Globals, error) {
}, nil
}
// IsDevVersion reports whether v names a development build rather than
// a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant)
// count: a caller that compares against "dev" exactly would treat every
// 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 {
return v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-")
}
// shortCommitLen is the number of commit-hash characters ShortCommit keeps.
const shortCommitLen = 12

View File

@@ -32,3 +32,56 @@ func TestGlobalsNew(t *testing.T) {
t.Error("Commit should not be empty")
}
}
// TestIsDevVersion covers the boundary that matters: everything
// script/version and goreleaser's snapshot template can emit for an
// untagged build must be recognised as a development build, and a real
// tag must not be. A plain equality check against "dev" used to decide
// this, which classified every commit-stamped dev build as a release.
func TestIsDevVersion(t *testing.T) {
t.Parallel()
cases := []struct {
version string
want bool
}{
// What an untagged build produces.
{"dev", true},
{"dev-b6e4a218a39e", true},
{"dev-b6e4a218a39e-dirty", true},
// What a tagged build produces (script/version strips the
// leading "v", matching goreleaser's .Version).
{"1.0.0", false},
{"0.1.0", false},
{"1.0.0-rc.1", false},
{"v1.0.0", false},
// A release must not be mistaken for a dev build just because
// the string happens to contain "dev".
{"1.0.0-dev", false},
{"developer", 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 {
if got := globals.IsDevVersion(tc.version); got != tc.want {
t.Errorf("IsDevVersion(%q) = %v, want %v", tc.version, got, tc.want)
}
}
}
// TestDefaultVersionIsDev pins the linker-flag contract: an unstamped
// binary (no -ldflags at all, which is what `go build ./...` and `go
// install` produce) must report itself as a development build rather
// than as some default release number.
func TestDefaultVersionIsDev(t *testing.T) {
t.Parallel()
if !globals.IsDevVersion(globals.DevVersion) {
t.Errorf("DevVersion %q is not recognised as a dev version",
globals.DevVersion)
}
}

View File

@@ -114,6 +114,14 @@ main() {
# sqlite3 CLI: the test suite shells out to it (VACUUM).
if missing sqlite3; then pkg_install sqlite sqlite3 sqlite sqlite; fi
# goreleaser, at the version pinned by script/install-goreleaser and
# verified against a hardcoded sha256. Package managers are not used
# for it: they ship whatever version they happen to carry, and the
# tool that builds a release has to be a known one. The install is
# its own script because the release workflow needs goreleaser
# without needing the Docker requirement below.
"$ROOT/script/install-goreleaser"
go mod download
# Last, so that everything installable is installed before the one

144
script/install-goreleaser Executable file
View File

@@ -0,0 +1,144 @@
#!/bin/sh
# script/install-goreleaser: install the pinned goreleaser into the
# repo-local tool directory. Our own extension to
# scripts-to-rule-them-all. Idempotent: exits immediately when the
# pinned version is already available.
#
# script/bootstrap calls this, and so does .gitea/workflows/release.yml.
# It is a separate script rather than an inline block in bootstrap
# because bootstrap deliberately hard-fails on a machine without a
# usable Docker daemon (Docker gates script/lint, and therefore
# script/check), while the release runner needs goreleaser and does not
# need Docker. One script, two callers, no duplicated pin.
#
# The install is a specific GitHub release archive verified against the
# sha256 hardcoded below, per REPO_POLICIES.md: no `curl | sh`, no
# `@latest`, no version tag that a server can move. Bumping goreleaser
# means editing GORELEASER_VERSION *and* the four checksums, which are
# taken from the checksums.txt published with that release.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# goreleaser v2.17.1, 2026-08-05. Checksums are from
# https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/checksums.txt
GORELEASER_VERSION="2.17.1"
SHA256_LINUX_X86_64="a99bbc7ae0d8d897b07c4c497a9b62f222558804715ef219d1af05a7e417bc80"
SHA256_LINUX_ARM64="702f03769ac8bcb0e47839c82243cc614ae995633599a98c63062e13ea85f829"
SHA256_DARWIN_X86_64="a92a68c61a6833ff67748f532cbebc7b8e49ba30de062ab463b221211ee6368f"
SHA256_DARWIN_ARM64="b65624885c25da9a677b7ad11cf86a02123cc5a56af66f6b4ebb574658eada2e"
TOOLBIN="$ROOT/.tool/bin"
# Print the version of the goreleaser at $1, or nothing if it is not
# usable. `goreleaser --version` prints a multi-line banner; the version
# is on the line beginning "GitVersion:".
goreleaser_version() {
[ -x "$1" ] || return 0
"$1" --version 2>/dev/null |
sed -n 's/^ *GitVersion: *//p' |
head -n 1
}
verify_sha256() {
file="$1"
want="$2"
if command -v sha256sum >/dev/null 2>&1; then
got="$(sha256sum "$file" | cut -d' ' -f1)"
elif command -v shasum >/dev/null 2>&1; then
got="$(shasum -a 256 "$file" | cut -d' ' -f1)"
else
echo "install-goreleaser: no sha256sum or shasum available" >&2
return 1
fi
if [ "$got" != "$want" ]; then
echo "install-goreleaser: checksum mismatch for $file" >&2
echo " expected: $want" >&2
echo " actual: $got" >&2
return 1
fi
}
main() {
cd "$ROOT"
# Already have it, either on PATH or from a previous run? Then stop.
# An arbitrary PATH goreleaser is NOT accepted: the config uses
# version-2 schema features, and the whole point of pinning is that
# a release is cut by a known build of a known tool.
if [ "$(goreleaser_version "$(command -v goreleaser || true)")" \
= "$GORELEASER_VERSION" ]; then
echo "goreleaser $GORELEASER_VERSION already on PATH"
return 0
fi
if [ "$(goreleaser_version "$TOOLBIN/goreleaser")" \
= "$GORELEASER_VERSION" ]; then
echo "goreleaser $GORELEASER_VERSION already installed in .tool/bin"
return 0
fi
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux) ;;
Darwin) ;;
*)
echo "install-goreleaser: unsupported OS $os" >&2
exit 1
;;
esac
case "$arch" in
x86_64 | amd64) arch="x86_64" ;;
arm64 | aarch64) arch="arm64" ;;
*)
echo "install-goreleaser: unsupported architecture $arch" >&2
exit 1
;;
esac
case "${os}_${arch}" in
Linux_x86_64) sum="$SHA256_LINUX_X86_64" ;;
Linux_arm64) sum="$SHA256_LINUX_ARM64" ;;
Darwin_x86_64) sum="$SHA256_DARWIN_X86_64" ;;
Darwin_arm64) sum="$SHA256_DARWIN_ARM64" ;;
*)
echo "install-goreleaser: no pinned checksum for ${os}_${arch}" >&2
exit 1
;;
esac
archive="goreleaser_${os}_${arch}.tar.gz"
url="https://github.com/goreleaser/goreleaser/releases/download/v${GORELEASER_VERSION}/${archive}"
if ! command -v curl >/dev/null 2>&1; then
echo "install-goreleaser: curl is required" >&2
exit 1
fi
tmp="$(mktemp -d)"
# shellcheck disable=SC2064 # expand $tmp now, not at trap time
trap "rm -rf '$tmp'" EXIT INT TERM
echo "installing goreleaser $GORELEASER_VERSION for ${os}_${arch}"
curl -fsSL --retry 3 -o "$tmp/$archive" "$url"
verify_sha256 "$tmp/$archive" "$sum"
tar -xzf "$tmp/$archive" -C "$tmp" goreleaser
mkdir -p "$TOOLBIN"
# Move into place via a temp name in the destination directory so a
# concurrent run never observes a half-written binary.
mv "$tmp/goreleaser" "$TOOLBIN/.goreleaser.$$"
chmod 0755 "$TOOLBIN/.goreleaser.$$"
mv "$TOOLBIN/.goreleaser.$$" "$TOOLBIN/goreleaser"
installed="$(goreleaser_version "$TOOLBIN/goreleaser")"
if [ "$installed" != "$GORELEASER_VERSION" ]; then
echo "install-goreleaser: installed binary reports '$installed'," \
"expected '$GORELEASER_VERSION'" >&2
exit 1
fi
echo "goreleaser $GORELEASER_VERSION installed to .tool/bin"
}
main "$@"

92
script/release Executable file
View File

@@ -0,0 +1,92 @@
#!/bin/sh
# script/release: build and publish the release artifacts with the
# pinned goreleaser. Our own extension to scripts-to-rule-them-all.
#
# Normally invoked by a tag push through .gitea/workflows/release.yml,
# not by hand: a release cut from a workstation is a release nobody can
# reproduce. Any arguments are passed through to `goreleaser release`,
# which is how script/release-snapshot adds --snapshot.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# Keep in sync with script/install-goreleaser, which owns the pin.
GORELEASER_VERSION="2.17.1"
goreleaser_version() {
[ -x "$1" ] || return 0
"$1" --version 2>/dev/null |
sed -n 's/^ *GitVersion: *//p' |
head -n 1
}
# Resolve the goreleaser to run, on the same rule script/lint uses for
# golangci-lint: a binary on PATH is accepted only when it is exactly
# the pinned version, because a differently versioned tool would
# produce a differently built release from the same tag. Anything else
# comes from .tool/bin, and a missing one is a loud failure naming the
# script that installs it rather than a silent fallback.
resolve_goreleaser() {
path_bin="$(command -v goreleaser || true)"
if [ -n "$path_bin" ] &&
[ "$(goreleaser_version "$path_bin")" = "$GORELEASER_VERSION" ]; then
echo "$path_bin"
return 0
fi
if [ "$(goreleaser_version "$ROOT/.tool/bin/goreleaser")" \
= "$GORELEASER_VERSION" ]; then
echo "$ROOT/.tool/bin/goreleaser"
return 0
fi
return 1
}
main() {
cd "$ROOT"
if ! bin="$(resolve_goreleaser)"; then
cat >&2 <<EOF
release: goreleaser $GORELEASER_VERSION is not available.
Run script/bootstrap (or script/install-goreleaser directly) to install
it. A goreleaser already on PATH is used only when it reports exactly
$GORELEASER_VERSION; any other version is refused rather than used,
because the released binaries must come from a known build of a known
tool.
EOF
exit 1
fi
snapshot=0
for arg in "$@"; do
[ "$arg" = "--snapshot" ] && snapshot=1
done
if [ "$snapshot" -eq 0 ]; then
# Publishing needs a Gitea token. Check it here so the failure
# names the secret, rather than after several minutes of
# cross-compiling.
if [ -z "${GITEA_TOKEN:-}" ]; then
cat >&2 <<'EOF'
release: GITEA_TOKEN is not set.
Publishing needs a Gitea API token with write access to this
repository's releases. In CI it comes from the RELEASE_TOKEN repository
secret (see .gitea/workflows/release.yml and the Releasing section of
README.md). To build without publishing, use script/release-snapshot.
EOF
exit 1
fi
# goreleaser picks its forge from whichever token variable is
# set and refuses to run when it finds more than one. A CI
# runner may export a GITHUB_TOKEN of its own; this repo lives
# on Gitea and releases only there, so an unrelated token must
# not be allowed to decide where the artifacts are published.
unset GITHUB_TOKEN GITLAB_TOKEN
fi
exec "$bin" release --clean "$@"
}
main "$@"

18
script/release-snapshot Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# script/release-snapshot: build the full set of release artifacts
# without publishing or tagging anything. Our own extension to
# scripts-to-rule-them-all.
#
# This is the dry run for script/release: same goreleaser, same config,
# same cross-compile matrix and checksums, into ./dist. The version it
# stamps is the honest dev-<shortcommit> string rather than an invented
# release number, so a snapshot binary cannot be mistaken for one.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
exec "$SCRIPT_DIR/release" --snapshot "$@"
}
main "$@"

73
script/version Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/sh
# script/version: output the version string to bake into the binary.
# Our own extension to scripts-to-rule-them-all, and the single source
# of truth for the version: the Makefile's LDFLAGS call this rather
# than carrying a hardcoded constant, which is what used to make every
# local build claim to be 1.0.0-rc.1 regardless of git state.
#
# The rules, in order:
#
# HEAD is exactly on an annotated or lightweight tag
# -> that tag, with a leading "v" stripped
# anything else
# -> "dev-<12 chars of HEAD>"
# not a git checkout at all (release tarball, `go install`)
# -> "dev"
#
# Either of the first two gains a "-dirty" suffix when tracked files
# have uncommitted changes, because a modified checkout of v1.0.0 is
# not v1.0.0. Untracked files are ignored, matching `git describe
# --dirty`: a stray scratch file does not change what was compiled.
#
# The "v" is stripped so that a `make` build and a goreleaser build of
# the same tagged commit report the *same* string: goreleaser's
# {{ .Version }} is the tag without the prefix, and the release archive
# names are built from it. A tag named `v1.0.0` therefore produces
# `vaultik 1.0.0`, matching `vaultik_1.0.0_linux_amd64.tar.gz`.
#
# Nothing here ever invents a version number. An untagged build says so
# and names the commit it was built from; it does not round up to the
# nearest plausible release.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Length of the commit prefix in a dev version. Matches
# globals.ShortCommit, so `vaultik version` shows the same 12 chars in
# its version line and its commit line.
SHORT_LEN=12
main() {
cd "$ROOT"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "dev"
return 0
fi
dirty=""
if [ -n "$(git status --porcelain --untracked-files=no 2>/dev/null)" ]; then
dirty="-dirty"
fi
# --exact-match so a *descendant* of a tag is not reported as that
# tag. Plain `git describe --tags` would call a commit 40 patches
# past v1.0.0 "v1.0.0-40-gabc1234", and the leading token of that is
# a released version the build is not.
tag="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
if [ -n "$tag" ]; then
echo "${tag#v}${dirty}"
return 0
fi
sha="$(git rev-parse "--short=$SHORT_LEN" HEAD 2>/dev/null || true)"
if [ -z "$sha" ]; then
# A repo with no commits at all.
echo "dev"
return 0
fi
echo "dev-${sha}${dirty}"
}
main "$@"