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

@@ -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 "$@"