From e3f407b440e0ce07513780b9471c7ad39d69f801 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 9 Aug 2026 18:03:18 +0200 Subject: [PATCH] Make the tagged-release path work on Gitea (closes #65) No tag could be cut at all: .goreleaser.yaml had no gitea_urls block, so goreleaser defaulted to the GitHub API, and the repo has zero tags. .goreleaser.yaml now points at git.eeqj.de. Version derives from git via a new script/version - exact tag with any leading v stripped, else dev-<12-char sha>, with a -dirty suffix when tracked files are modified - replacing the hardcoded 1.0.0-rc.1 that every local build was stamping regardless of git state. A tag-triggered .gitea/workflows/release.yml runs goreleaser with a scoped token (RELEASE_TOKEN); script/bootstrap installs a sha256-verified goreleaser, and make release / release-snapshot become script shims like every other target. Two fabrications were removed rather than merely replaced. goreleaser's snapshot.version_template was `{{ incpatch .Version }}-next`, which invents a release number from the last tag - and with no tags, from goreleaser's own fabricated v0.0.0. And internal/cli/version.go gated its development-build notice on Version == "dev" exactly, so the moment untagged builds carried a sha that notice would have gone silent and an unreleased binary would have read as a release. Replaced with a tested IsDevVersion predicate, and closed at both layers: the Makefile now refuses to build when script/version yields nothing, and an empty version counts as a development build - reachable today via `docker build --build-arg VERSION=`. The release workflow installs Go from a sha-pinned actions/setup-go (v5.6.0) using go-version-file, so the compiler that produces released binaries is pinned like every other external reference. Without it the first tag push would either fail at goreleaser's before-hook or compile the published artifacts with whatever unpinned Go the runner happened to carry - the one unpinned thing in a release path that already refuses an unpinned goreleaser. Known gap: the Go tarball setup-go fetches is version-pinned but not checksum-verified against a value in this repo, unlike the goreleaser install and the Dockerfile digest. --- .dockerignore | 2 + .gitea/workflows/release.yml | 60 +++++++++++++ .gitignore | 6 ++ .goreleaser.yaml | 15 +++- Makefile | 21 ++++- README.md | 85 ++++++++++++++++++ TODO.md | 65 +++++++++++++- internal/cli/version.go | 49 ++++++----- internal/cli/version_test.go | 77 +++++++++++++++++ internal/globals/globals.go | 29 ++++++- internal/globals/globals_test.go | 53 ++++++++++++ script/bootstrap | 8 ++ script/install-goreleaser | 144 +++++++++++++++++++++++++++++++ script/release | 92 ++++++++++++++++++++ script/release-snapshot | 18 ++++ script/version | 73 ++++++++++++++++ 16 files changed, 769 insertions(+), 28 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 internal/cli/version_test.go create mode 100755 script/install-goreleaser create mode 100755 script/release create mode 100755 script/release-snapshot create mode 100755 script/version diff --git a/.dockerignore b/.dockerignore index 0b09869..fc84c76 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ *.md LICENSE vaultik +dist +.tool coverage.out coverage.html .DS_Store diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..d2578ea --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 9f1f49f..df0dea6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # Binary /vaultik +# goreleaser output +/dist/ + +# Locally installed pinned tools (script/install-goreleaser) +/.tool/ + # Test artifacts *.out *.test diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d47dd2f..d316d84 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 diff --git a/Makefile b/Makefile index 2782e3c..b753313 100644 --- a/Makefile +++ b/Makefile @@ -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-. 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: diff --git a/README.md b/README.md index 5a0b16e..cff6853 100644 --- a/README.md +++ b/README.md @@ -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-` 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/) diff --git a/TODO.md b/TODO.md index 39e5708..b258764 100644 --- a/TODO.md +++ b/TODO.md @@ -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-`. 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-` + 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-` 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 diff --git a/internal/cli/version.go b/internal/cli/version.go index c8cec85..a6cc20f 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -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.") + } +} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..1d11395 --- /dev/null +++ b/internal/cli/version_test.go @@ -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) + } +} diff --git a/internal/globals/globals.go b/internal/globals/globals.go index bbd2843..09233f9 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -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 " // 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-" (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 diff --git a/internal/globals/globals_test.go b/internal/globals/globals_test.go index 2784428..e0b7c2e 100644 --- a/internal/globals/globals_test.go +++ b/internal/globals/globals_test.go @@ -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) + } +} diff --git a/script/bootstrap b/script/bootstrap index aa6b2c2..3300436 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -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 diff --git a/script/install-goreleaser b/script/install-goreleaser new file mode 100755 index 0000000..f847c28 --- /dev/null +++ b/script/install-goreleaser @@ -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 "$@" diff --git a/script/release b/script/release new file mode 100755 index 0000000..a9e2056 --- /dev/null +++ b/script/release @@ -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 <&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 "$@" diff --git a/script/release-snapshot b/script/release-snapshot new file mode 100755 index 0000000..327b456 --- /dev/null +++ b/script/release-snapshot @@ -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- 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 "$@" diff --git a/script/version b/script/version new file mode 100755 index 0000000..0df9b42 --- /dev/null +++ b/script/version @@ -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 "$@"