Compare commits

1 Commits

Author SHA1 Message Date
24f6e2f9ef Make script/cibuild unable to report an unearned green (closes #85)
All checks were successful
check / check (pull_request) Successful in 2m21s
script/cibuild was a bare `docker build .` with no cache control. The
Dockerfile does `COPY . .` and then `RUN make fmt-check` / `RUN make
lint` in the lint stage and `COPY . .` / `RUN make test` in the builder
stage. On an unchanged tree Docker served those RUN layers from cache,
so the checks never executed, and the build still exited 0 -- the exit
code, which is the one signal automation trusts, was wrong, and wrong
in the direction that matters: the longer a branch sits unchanged, the
more likely its "verification" is a replay, which is exactly its state
just before a merge.

Reproduced on this branch's base at 3bcdbcf. A changed-tree run took
162132ms and produced 14 `ok` lines and `0 issues.`; the immediately
following run, with nothing touched, took 221ms and produced 0 `ok`
lines and no `0 issues.` line at all, with 19 CACHED layers including
`RUN make fmt-check`, `RUN make lint`, and `RUN make test`. Both
exited 0.

The fix matches the upstream one in sneak/prompts #26 rather than
inventing a local variant: an `ARG CHECK_EPOCH` declared immediately
above the check RUNs, with script/cibuild passing a fresh
`--build-arg CHECK_EPOCH="$(date +%s)"` on every invocation. ARG scope
is per-stage in Docker, so the lint stage and the builder stage each
declare their own; covering only one would leave half the gate fake.

Placement is the substance of the change. The ARG sits below the
`apk add`, `COPY go.mod go.sum`, and `go mod download` layers in both
stages, so only the check layers are invalidated: earlier and every
build would be cold, later and the checks would stay cached. Confirmed
by measurement -- on a post-fix build every `apk add` and `go mod
download` layer is still reported CACHED, and a changed-tree build went
from 162132ms to 176221ms rather than to a cold build's 242727ms.

Verified against the original failure mode, not by trusting an exit
code: two back-to-back script/cibuild runs on an unchanged tree now
take 166745ms and 174025ms, each with 14 `ok` lines and `0 issues.`,
and neither reports CACHED on any of the three check layers.
Cross-checked host-side with `GOFLAGS=-count=1 make check`: exit 0, 14
`ok` lines, `0 issues.`, with no `parallel golangci-lint is running`
and no file paths from outside this worktree, so the lint result is a
real one and not a void or contaminated run.

.golangci.yml is unchanged (sha256 021cc83f4e6f...643346bcb), as is the
lint-stage FROM line that is the single source of truth for the linter
version, script/lint's pinned-image logic, and
.gitea/workflows/check.yml, whose only step is script/cibuild.
2026-08-09 06:08:33 +00:00
37 changed files with 316 additions and 3392 deletions

View File

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

View File

@@ -1,60 +0,0 @@
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,12 +1,6 @@
# Binary # Binary
/vaultik /vaultik
# goreleaser output
/dist/
# Locally installed pinned tools (script/install-goreleaser)
/.tool/
# Test artifacts # Test artifacts
*.out *.out
*.test *.test

View File

@@ -2,13 +2,6 @@ version: 2
project_name: vaultik 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: before:
hooks: hooks:
- go mod tidy - go mod tidy
@@ -44,14 +37,8 @@ checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
algorithm: sha256 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: snapshot:
version_template: "dev-{{ slice .FullCommit 0 12 }}" version_template: "{{ incpatch .Version }}-next"
changelog: changelog:
sort: asc sort: asc

View File

@@ -83,8 +83,8 @@ Version: 2025-06-08
possible to mock or stub these side-effects in tests. possible to mock or stub these side-effects in tests.
9. Always use structured logging. Log any relevant state/context with the 9. Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If the log stream is not a terminal, messages (but do not log secrets). If stdout is not a terminal, output
output the structured logs in jsonl format. the structured logs in jsonl format.
10. Avoid using bare strings or numbers in code, especially if they appear 10. Avoid using bare strings or numbers in code, especially if they appear
anywhere more than once. Always define a constant (usually at the top anywhere more than once. Always define a constant (usually at the top

View File

@@ -10,15 +10,6 @@ FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f01691
RUN apk add --no-cache make build-base RUN apk add --no-cache make build-base
# The context signal for script/lint's native path. This stage runs
# `make lint` with no docker daemon available, so it is the one place
# that must run the golangci-lint on PATH directly. script/lint takes
# that path only when this is set AND the version matches the pin above;
# version equality alone would also admit a developer's locally
# installed copy on a host, bypassing the digest pin (issue #80).
# Nothing outside this stage sets it.
ENV VAULTIK_LINT_IN_CONTAINER=1
WORKDIR /src WORKDIR /src
# Copy go mod files first for better layer caching # Copy go mod files first for better layer caching
@@ -30,34 +21,17 @@ COPY . .
# Run formatting check and linter. # Run formatting check and linter.
# #
# CHECK_EPOCH must stay immediately above these RUNs. These layers are # CHECK_EPOCH must stay immediately above these RUNs. script/cibuild
# keyed on its value, so they are cache-eligible only for a value # passes a fresh value on every build so the check layers can never be
# already built against this same tree. script/cibuild and script/docker # served from the layer cache: without it an unchanged tree replays
# each pass a fresh value on every invocation, which is what makes their # cached layers, the checks never execute, and the build still exits 0:
# green mean the checks really executed. # a green nothing earned. ARG scope is per-stage, so the builder
# # stage declares its own. Everything above this line (apk, go.mod,
# The value is expanded into each check command rather than left to a # `go mod download`) is deliberately outside the busted range and keeps
# bare declaration, so the cache miss does not depend on BuildKit's # caching.
# unreferenced-ARG handling staying as it is. It also puts the epoch in
# the build log, where a reader can see the layer was keyed fresh.
#
# The guard is what makes a build that omits --build-arg fail instead of
# lie. An unset ARG is an empty string, and an empty string is a
# perfectly stable cache key: without the guard the first such build
# runs the checks and every one after it on an unchanged tree replays
# these layers from cache, executes nothing, and still exits 0. Failed
# steps are never cached, so the guard fails on EVERY invocation rather
# than once -- a bare `docker build .` is now a loud error, not a quiet
# green. Do not give CHECK_EPOCH a default value; a default would
# satisfy the guard with a constant and restore the hole.
#
# ARG scope is per-stage, so the builder stage declares its own.
# Everything above this line (apk, go.mod, `go mod download`) is
# deliberately outside the busted range and keeps caching.
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN make fmt-check
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check RUN make lint
RUN echo "check epoch: ${CHECK_EPOCH}" && make lint
# Build stage # Build stage
# golang:1.26.1-alpine, 2026-03-17 # golang:1.26.1-alpine, 2026-03-17
@@ -80,13 +54,11 @@ RUN go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Run tests. See the CHECK_EPOCH comment in the lint stage for the # Run tests. See the CHECK_EPOCH comment in the lint stage; ARG scope
# mechanism; ARG scope is per-stage, so this stage needs its own # is per-stage, so this stage needs its own declaration, and it must
# declaration, its own guard, and its own expansion, and they must stay # stay immediately above the check RUN.
# immediately above the check RUN.
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN make test
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# Build (pure Go, no CGO required since we use modernc.org/sqlite) # Build (pure Go, no CGO required since we use modernc.org/sqlite)
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik

View File

@@ -1,20 +1,7 @@
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks .PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage test-integration local install release release-snapshot docker hooks
# Version number, derived from git by script/version -- the tag when # Version number
# HEAD is on one, otherwise dev-<sha>. This used to be a hardcoded VERSION := 1.0.0-rc.1
# 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 # Build variables
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
@@ -40,13 +27,7 @@ setup:
check: check:
@script/check @script/check
# Run tests only. This runs the ENTIRE suite -- there is no separate # Run tests only.
# integration target and no build-tagged subset held back. In
# particular internal/vaultik/integration_test.go, which does full
# chunk -> pack -> encrypt -> upload -> restore round-trips, runs here.
# A `test-integration` target used to exist and was removed: no file in
# the repo carried a build tag, so `-tags=integration` selected nothing
# extra and the target was an exact duplicate of this one.
test: test:
@script/test @script/test
@@ -66,18 +47,7 @@ lint:
lint-fix: lint-fix:
@script/lint-fix @script/lint-fix
# Build binary. `build` is the name the org convention reaches for and # Build binary.
# the one a caller checks the exit code of; `vaultik` is the file rule
# that does the work, so an unchanged tree still short-circuits.
#
# This alias is not decorative. `build` was listed in .PHONY with no
# rule, and a phony target with no prerequisites and no recipe is
# already satisfied: `make build` printed "Nothing to be done" and
# exited 0 without producing a binary (issue #110). Every name in
# .PHONY needs a rule for that reason; TestPhonyTargetsAllHaveRules in
# cmd/vaultik keeps it that way.
build: vaultik
vaultik: internal/*/*.go cmd/vaultik/*.go vaultik: internal/*/*.go cmd/vaultik/*.go
go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/vaultik go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/vaultik
@@ -94,14 +64,15 @@ clean:
deps: deps:
go mod download go mod download
# Run tests with coverage. -count=1 for the same reason script/test # Run tests with coverage.
# uses it: without it an unchanged package is served from Go's test
# result cache, and a coverage profile assembled from cached results
# describes a run that did not happen.
test-coverage: test-coverage:
go test -v -count=1 -coverprofile=coverage.out ./... go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html go tool cover -html=coverage.out -o coverage.html
# Run integration tests.
test-integration:
go test -v -tags=integration ./...
local: local:
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug list 2>&1 VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug list 2>&1
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug create 2>&1 VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug create 2>&1
@@ -111,11 +82,11 @@ install: vaultik
# Build and publish release artifacts (linux/darwin × amd64/arm64) via goreleaser. # Build and publish release artifacts (linux/darwin × amd64/arm64) via goreleaser.
release: release:
@script/release goreleaser release --clean
# Dry-run a release build without publishing or tagging. # Dry-run a release build without publishing or tagging.
release-snapshot: release-snapshot:
@script/release-snapshot goreleaser release --clean --snapshot
# Build Docker image. # Build Docker image.
docker: docker:

188
README.md
View File

@@ -113,40 +113,11 @@ vaultik version
### global flags ### global flags
* `--config <path>`: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`) * `--config <path>`: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`)
* `--verbose`, `-v`: Enable verbose output (on stderr — see below) * `--verbose`, `-v`: Enable verbose output
* `--debug`: Enable debug output (on stderr — see below) * `--debug`: Enable debug output
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner) * `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
* `--skip-errors`: Continue past per-file errors instead of aborting (applies to `snapshot create` and `restore`) * `--skip-errors`: Continue past per-file errors instead of aborting (applies to `snapshot create` and `restore`)
### stdout and stderr
Log output — everything from `--verbose` and `--debug`, and every
warning and error the logger emits — goes to **stderr**. stdout carries
the output you asked for: tables, and the documents produced by `--json`.
This means `vaultik snapshot list --verbose > out.txt` captures the
listing and leaves the diagnostics on your terminal. To capture both,
redirect stderr as well (`> out.txt 2> log.txt`, or `> out.txt 2>&1` to
interleave them).
The split is what makes `--json` usable from a script. Warnings and
errors are never suppressed — not by `--quiet`, not by `--cron` — so a
logger on stdout would eventually land a log line inside a JSON
document and break the parse. A config file with group- or
world-readable permissions is enough to trigger it.
Format follows the stream: when stderr is a terminal the records are
colorized one-liners, and when it is redirected or piped they are
JSON, one object per line.
Under `--json`, stdout holds the document and nothing else. The startup
banner is suppressed, as `--quiet` and `--cron` suppress it, and the
progress narration a command would otherwise print — such as the stale
local records `prune` reconciles away — is suppressed too, so it cannot
land ahead of the document. Every `--json` command therefore pipes on
its own, with no additional flag: `vaultik snapshot list --json | jq .`
and `vaultik prune --json | jq .` both work as written.
### environment variables ### environment variables
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`) * `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`)
@@ -237,9 +208,8 @@ local index alone, and still exits zero.
(whether the snapshot is in the local index), `remote_key` (the full (whether the snapshot is in the local index), `remote_key` (the full
64-character storage key), and `remote_present` (whether it was seen 64-character storage key), and `remote_present` (whether it was seen
on the destination store, or `null` if the destination could not be on the destination store, or `null` if the destination could not be
listed). Warnings about an unlistable destination, unreadable listed). The warning about an unlistable destination goes to stderr
manifests, and a truncated listing all go to stderr through the so stdout stays a single parseable document.
logger, so stdout stays a single parseable document.
**`snapshot verify`**: Verify snapshot integrity. **`snapshot verify`**: Verify snapshot integrity.
* Default (shallow): checks that all blobs referenced in the manifest exist in storage * Default (shallow): checks that all blobs referenced in the manifest exist in storage
@@ -534,10 +504,6 @@ All user-facing output goes through helpers in `internal/ui` and conforms
to a uniform style. Color is enabled when stdout is a TTY and the to a uniform style. Color is enabled when stdout is a TTY and the
`NO_COLOR` environment variable is unset (https://no-color.org/). `NO_COLOR` environment variable is unset (https://no-color.org/).
`internal/ui` writes to stdout; it is the output the user asked for.
Structured log records are a different thing and go through
`internal/log`, which writes to stderr (see "stdout and stderr" above).
Message classes: Message classes:
| Class | Marker | Alignment | Use for | | Class | Marker | Alignment | Use for |
@@ -597,12 +563,6 @@ regardless of color setting (emoji are not color).
## requirements ## requirements
* Go 1.26 or later * Go 1.26 or later
* Docker, with a reachable daemon, to lint, check, or commit:
`script/lint` runs the digest-pinned `golangci-lint` image declared by
the `Dockerfile` lint stage, and `make check` and the pre-commit hook
both run it. A `golangci-lint` installed on `PATH` is not a substitute
and is never used on a host, whatever its version.
* `sqlite3` CLI, which the test suite shells out to
* S3-compatible object storage (or local filesystem, or rclone remote) * S3-compatible object storage (or local filesystem, or rclone remote)
## development workflow ## development workflow
@@ -640,37 +600,7 @@ them. We provide:
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
* `script/projectname` — print the project name (used for the Docker * `script/projectname` — print the project name (used for the Docker
image tag) image tag)
* `script/version` — print the version string to bake into the binary. * `script/test` — run the test suite (verbose rerun on failure)
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
`internal/vaultik/integration_test.go` run on every invocation. It
passes `-count=1`, which disables Go's test result cache. That is
deliberate and it is not free: on this repo's suite it costs about 11
seconds on every repeat run (measured, back to back: 0.4s cached
versus 11.6s with `-count=1`). That is the price of the run meaning
anything, because without it an unchanged package prints
`ok <pkg> (cached)`, which is indistinguishable from a package that
really ran, so the whole suite can report a full set of `ok` lines in
under half a second having executed nothing. The `-timeout` is a hang
backstop rather than a performance budget — it applies per test binary
to test execution only, not to compilation — and is set well above the
slowest package's measured runtime. Its 120s value deliberately
diverges from the 30s `REPO_POLICIES.md` mandates; the reasoning is in
the comment in the script, and issue #101 proposes amending the policy
text.
* `script/lint` — run `golangci-lint run ./...` at the exact version CI * `script/lint` — run `golangci-lint run ./...` at the exact version CI
uses, by running the digest-pinned `golangci-lint` image declared by uses, by running the digest-pinned `golangci-lint` image declared by
the `Dockerfile` lint stage (requires Docker; it fails loudly rather the `Dockerfile` lint stage (requires Docker; it fails loudly rather
@@ -686,108 +616,22 @@ them. We provide:
the pinned linter: a local `make check` and CI cannot disagree about the pinned linter: a local `make check` and CI cannot disagree about
lint findings. lint findings.
* `script/docker` — build the Docker image tagged via * `script/docker` — build the Docker image tagged via
`script/projectname`. Passes a fresh `--build-arg CHECK_EPOCH` for the `script/projectname`
same reason `script/cibuild` does, so a local image build cannot be * `script/cibuild` — CI entrypoint: `docker build` (the Dockerfile runs
green on checks it replayed from cache. the checks). This is the full CI-equivalent gate — it runs the checks
* `script/cibuild` — CI entrypoint: `docker build` (the `Dockerfile` in the same containers CI does, from a clean copy of the tree, so it
runs `make fmt-check` and `make lint` in its lint stage and `make also catches anything that depends on host state. It passes a fresh
test` in its builder stage). This is the full CI-equivalent gate — it `--build-arg CHECK_EPOCH`, which the `Dockerfile` declares
runs the checks in the same containers CI does, from a clean copy of immediately above the check `RUN`s in both the lint and builder
the tree, so it also catches anything that depends on host state. It stages, so those layers can never be served from the Docker layer
passes a fresh `--build-arg CHECK_EPOCH`, unique per invocation, which cache: a green from this script always means the checks actually
the `Dockerfile` declares immediately above the check `RUN`s in both executed. Dependency and module layers sit above the `ARG` and still
stages and expands into each check command. Those layers are keyed on cache, so a build is not cold.
that value, so a new value re-runs them even on a byte-identical tree,
and a green from this script means the checks executed. Dependency and
module layers sit above the `ARG` and still cache, so a build is not
cold.
A build that supplies no `CHECK_EPOCH` — a bare `docker build .`
fails rather than lying. An unset `ARG` is an empty string and an
empty string is a stable cache key, so without a guard such a build
would serve all three check layers from cache, execute nothing, and
still exit 0. Each check stage therefore asserts the value is
non-empty before running anything, and because failed steps are never
cached that assertion fires on every invocation rather than once. Use
`script/cibuild` (or `script/docker`, which passes the same arg); a
bare `docker build .` is now a loud error.
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must * `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
not change files), then `script/check` not change files), then `script/check`
* `script/install-precommit` — install the git pre-commit hook that * `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit` 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 ## license
[MIT](https://opensource.org/license/mit/) [MIT](https://opensource.org/license/mit/)

390
TODO.md
View File

@@ -14,372 +14,33 @@ pre-1.0
# Next Step # Next Step
Define the remaining scope for the first tagged release under the 1.0.0 Triage the stale remote branches (issue #71): for each, merge the work
milestone, then cut that tag. The mechanism to cut it now exists and is or delete the branch.
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 # Completed Steps
- 2026-08-09: Finished the `--json` stdout contract and gave `make build`
a rule ([issue #108](https://git.eeqj.de/sneak/vaultik/issues/108),
[issue #110](https://git.eeqj.de/sneak/vaultik/issues/110)). Two
unrelated defects of the same shape — a command reporting something it
did not do — landed together because both are small.
`CleanupLocalSnapshots` wrote three prose lines to stdout with no
`--json` awareness, covering every branch of the function, so no input
avoided them and `vaultik prune --json | jq` failed even after
[issue #106](https://git.eeqj.de/sneak/vaultik/issues/106) removed the
banner. `-q` never helped either: `printlnStdout` and `stdoutf` write
straight to `Vaultik.Stdout` and never consult `Vaultik.UI`, which is
what `SetQuiet` affects. The issue offered three fixes and asked for a
decision. Taken: thread `*PruneOptions` into the function and gate each
write on `!opts.JSON`, matching `PruneBlobs` (its sibling phase, which
already takes the same struct), `RemoveSnapshot` and `remote info`, so
the package has one pattern rather than two. Rejected: moving the lines
to `log.Info`, because the logger's default level is `slog.LevelWarn`,
so that would not relocate them to stderr — it would delete them from a
plain `vaultik prune`, and the removal of rows from the local index is
not something to narrate only under `--verbose`. Also rejected: putting
the stale-record count into `PruneBlobsResult`, whose every field is
blob-scoped and which is produced by the later phase; a prune document
covering both phases is a reasonable thing to want, but it is a schema
design question and not a stream-hygiene fix. The narration is
duplicated as `log.Info` records, which `PruneBlobs` already does
alongside its own prints, so the events survive on stderr for anyone
running `--verbose`.
`make build` printed "Nothing to be done for 'build'" and exited 0
without producing a binary: `build` was listed in `.PHONY` with no
`build:` rule anywhere, and declaring a name phony is exactly what
converts make's "No rule to make target" error into a silent success.
Fixed with `build: vaultik`, keeping `vaultik:` as the file rule. The
audit the issue asked for covers all 19 `.PHONY` names; `build` was the
only one without a rule, and `vaultik` is correctly absent from
`.PHONY`, being a real file target.
Tests, each verified to fail with the fix reverted rather than assumed
to: `CleanupLocalSnapshots` leaves stdout untouched under `--json` in
all three branches (stale records, none, empty index) and still emits
every line without it, so the guard cannot be satisfied by deleting the
output; `prune --json` run end to end through `Entry`, cobra and fx
over the process's real stdout descriptor against a `file://` store,
asserting exactly one JSON document, in both the stale and non-stale
branches; and a parse of the `Makefile` asserting every `.PHONY` name
has a rule and that `build` reaches the rule that produces the binary,
which keeps the audit true for names added later. That last one is a
parse rather than an invocation of `make`, since `make test` is what
runs it and shelling back into `make build` would nest a build inside
the test run. The property a parse cannot establish — that the recipe
still fails when the build fails — was verified by hand against a
deliberately broken tree: `make build` exits 2 and produces nothing.
`cmd/vaultik` gains its first test file, so `make test` now reports 16
packages `ok` where it reported 15.
- 2026-08-09: Stopped the startup banner from contaminating `--json`
documents ([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)).
`Entry` writes the banner to stdout before cobra parses anything, and
the flag scan that suppresses it knew `--quiet`, `-q` and `--cron` but
not `--json`, so every `--json` document arrived behind two lines of
prose and a blank line, and `vaultik snapshot list --json | jq` failed.
With the logger already on stderr from
[issue #82](https://git.eeqj.de/sneak/vaultik/issues/82), this was the
last writer that could put something on stdout that the caller did not
ask for. The design question the issue raised — extend the raw-argv
scan, or move the banner after parsing — is answered in favour of the
scan: the banner is printed first deliberately, so that it still
appears when cobra rejects the arguments and on `--help`, and after
parsing there is no single place that covers those paths. The stated
cost of the scan, that `--json` is a subcommand flag matched anywhere
in the vector, is a cost `--cron` already carries — it exists only on
`snapshot create` — so this adds an instance of an accepted
imprecision rather than a new kind, and the two error directions are
not symmetric: a false positive loses a decorative banner, a false
negative corrupts a document. Regression tests at the CLI layer, where
`internal/vaultik`'s existing guard cannot reach: one runs `Entry`
itself over the process's real stdout descriptor, through cobra and fx
to the document, made hermetic by `file://` storage; a second covers
the argument vectors of all five `--json` commands; a third asserts the
banner is still printed without a suppressing flag, so the first
cannot be satisfied by deleting the banner. Also corrected `AGENTS.md`
policy 9, which still keyed the structured-log format on stdout's
TTY-ness after #82 moved that decision to stderr — a rules file that
misdescribes the code misleads exactly the readers who trust it most.
Two smaller findings from the same review: `bytesAttrKey`'s
human-readable byte formatting silently stopped applying under an open
group, because the key reaching the comparison is group-qualified
(`transfer.bytes`), now matched on its final segment and tested both
ways; and `listEnv.stderr` in `snapshot_list_test.go`, assigned but
never read since those tests began capturing the process's stderr, is
removed. `Vaultik.Stderr` is kept — nothing writes to it today, which
its comment now says outright.
- 2026-08-09: Moved the logger to stderr and fixed `TTYHandler`'s
discarded attributes
([issue #82](https://git.eeqj.de/sneak/vaultik/issues/82),
[issue #97](https://git.eeqj.de/sneak/vaultik/issues/97)). Two defects
in `internal/log`, fixed together because both live in the handler
construction path. The first: both handlers were built over
`os.Stdout`, and `WARN`/`ERROR` are never suppressed, so a config file
with group- or world-readable permissions was enough to put a log
record inside a `--json` document and break `jq`. Diagnostics now go
to stderr, and the TTY/JSON format choice follows stderr rather than
stdout — testing the wrong stream would colorize records on a
redirected stderr whenever stdout happened to be a terminal. This is
user-visible: `--verbose` and `--debug` output moves to stderr too,
which is documented in `README.md` under "stdout and stderr". It also
let the local workaround in `internal/vaultik/snapshot_list.go` go:
`warnWhileListing` had been hand-rolling structured-log formatting to
reach a non-stdout writer, and the `jsonOutput` parameter threaded
through the remote-listing helpers existed only to choose between the
two writers. The collect-then-emit machinery around `listingWarning`
stays, but on its remaining merit — warnings emitted in key order
after `group.Wait()` are deterministic run to run, where emitting from
the fetch workers would order them by network timing. The second
defect: `TTYHandler.WithAttrs` and `WithGroup` discarded their
arguments and returned the receiver while their doc comments claimed
otherwise, so `log.With` attributes vanished on a terminal and
appeared correctly in CI — failing precisely when someone is debugging
interactively. Both now return a new handler (the receiver is never
written to, since `slog` permits concurrent derivation), attributes
persist across records, and grouping is implemented as dotted key
prefixes, which is the only honest rendering for a format with nowhere
to nest. New tests cover both, including one that feeds the same
derivation chain to the TTY and JSON handlers and compares the
attribute sets, so the two paths cannot drift apart again. Found and
filed while verifying: the startup banner is written to stdout and
`--json` does not suppress it
([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)), which is
a separate writer on a separate path and the remaining source of
stdout contamination.
- 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
what version was on `PATH` rather than where it was running, and cache
isolation is part of that same question. The cache was one directory
per repo, shared by every worktree on the host, so two checkouts with
identical Go file contents collided and golangci-lint replayed the
stored analysis — paths and all. The loud direction of that failure
(a clean tree failed by a dirty sibling) is the harmless one; the
silent direction, a dirty tree **passed** by a clean sibling, is a
sixth way for a gate here to report a green it did not earn. The cache
is now keyed on a digest of the worktree path, and every run is
audited by the new `script/lint-audit`, which rejects output citing any
file that is not in the tree being linted — a backstop that runs on
clean output too, because that is the case nobody investigates. Caches
record the worktree they belong to and are collected when it
disappears, so throwaway worktrees do not accumulate them; the whole
tree lives under `XDG_CACHE_HOME` and is disposable. The
`parallel golangci-lint is running` refusal is now a bounded retry
rather than a verdict: it is not a lint result, and exiting non-zero
on it is indistinguishable to a caller from real findings (#88 showed
a private cache does not remove that contention). The native path now
requires `VAULTIK_LINT_IN_CONTAINER=1`, set only by the `Dockerfile`
lint stage, in addition to matching the pin, so a developer's locally
installed 2.12.2 no longer bypasses the digest pin; `/.dockerenv` was
rejected as the signal because `dockerd` creates it for `docker run`
and it is not reliably present during a BuildKit `docker build`, which
is the case the exception exists for. Version detection uses
`golangci-lint version --short` with the old banner scrape kept only
as a fallback. `script/bootstrap` no longer prints `bootstrap
complete` on a machine that cannot run the gate: a missing docker, or
one whose daemon is unreachable, is a hard failure naming exactly what
breaks. Verification was by reproduction rather than inspection — two
concurrent lints from two worktrees of differing cleanliness, a real
run made to report an outside path, a matching linter shimmed onto
`PATH`, and a `PATH` with docker removed — and is recorded on the pull
request.
- 2026-08-09: Closed the fifth false-green mechanism (issues #93, #69).
`script/test` omitted `-count=1`, so Go's test result cache could
satisfy the gate outright: a second back-to-back `make test` printed
the full set of 14 `ok` lines, every one marked `(cached)`, having
executed no test at all. Since `ok <pkg> (cached)` is an `ok` line,
the "14 `ok` lines means the suite ran" signal this repo leans on was
forgeable, one level below the Docker layer cache that #85 addressed.
Fixed with `-count=1` unconditionally rather than only in the
container, because the pre-commit hook runs the same script and a
gate honest only in CI is dishonest where people rely on it most;
`test-coverage` got the same flag, and `script/check` inherits it by
calling `script/test`. In the same area, `make test-integration` was
deleted rather than made real: no file in the repo carried a build
tag, so `-tags=integration` selected nothing and the target was an
exact duplicate of `make test`. Tagging a subset was rejected because
the entire suite runs in well under a minute, and a scheme whose
failure mode is "some tests silently stopped running" is a poor trade
for those seconds in a repo with this particular history. The
`-timeout` was raised from 30s after measuring rather than after
assuming: the standing claim that cold-cache compilation is charged
against `-timeout` is **false**, disproved by a containerised run
that spent 46s compiling and still reported per-package durations
within noise of a warm host run. `-timeout` reaches the test binary
as `-test.timeout` and its clock starts inside `testing.M.Run`, after
the build. The real exposure was margin, not compilation. The 120s
landed on is a **deliberate, documented divergence** from
`REPO_POLICIES.md:192`, which mandates 30s, and from that file's
canonical recipe at `:212-214`; the divergence is recorded in
`script/test`'s comment because `REPO_POLICIES.md` is org-canonical
and not editable here, and issue #101 proposes amending the policy
text upstream. Numbers and the full verification are recorded once,
on the pull request, and are deliberately not restated here.
- 2026-08-09: Triaged all fifteen stale remote branches (issue #71) and
deleted fourteen of them; the full per-branch disposition with
evidence is recorded on that issue. Method mattered more than the
outcome here: a three-dot `git diff main...branch` diffs from the
merge base, so it replays everything that landed on `main` after the
branch diverged and makes any old branch look like it holds unlanded
work. That artifact is what made `golangci-v2.12.2` appear to carry
126 files of unpushed changes when its tree was byte-identical to
`main`'s. Every containment claim here therefore rests on two-dot tip
diffs, tree-hash equality, `git cherry`, and `git branch -r --merged`.
Nine branches were plain ancestors of `main` with zero `git cherry`
`+` commits. `golangci-v2.12.2` had landed squashed as `cc58583`,
whose tree hash equals the branch tip's exactly; note the hash
recorded in the issue had gone stale because `main` advanced, so the
check had to be redone rather than repeated.
`fix/sync-snapshot-cleanup` was redundant, its one line already on
`main` in `syncWithRemote`. `feature/restore-progress-bar` was
superseded by `printRestoreProgress` and the disk-backed blob cache,
and had become actively regressive — it would have deleted
`internal/blobgen/compress_test.go`, the #28 regression test that
landed separately. The two branches this issue was filed for both
turned out to be closed questions that `main` had already moved past
by a recorded decision, so neither was landed and no regression test
was owed: `ctime` no longer exists anywhere in the codebase after
`1c72a37` removed the column, the `File.CTime` field and every use
(#54/#55), and change detection compares size, mtime, mode, uid and
gid only, exactly as `ARCHITECTURE.md` documents — so the
silently-skipped-file data-loss risk that made this a 1.0 item does
not exist. The SQL allow-list branch would have reverted `bfd7334`,
which replaced that very allow-list with regex sanitisation on review
feedback, and would have broken `getTableCount("snapshots")` because
its allow-list omits that table. `feature/daemon-mode` is untouched
and deferred to #94 pending an owner decision, so it is the one
branch besides `main` still on the remote. The stale `TODO.md` entry
named in the issue needed no fix: `e496aa3` had already removed it.
No product code changed.
- 2026-08-09: Adopted the remaining upstream `CHECK_EPOCH` hardening
(issue #91), closing the gap #85 knowingly left open. Four changes,
all four decided as adopt upstream in `sneak/prompts` #26. (1) Each
check stage now asserts `[ -n "$CHECK_EPOCH" ] || exit 1` before
running anything, so a build that supplies no `--build-arg` fails
instead of lying. This is the item that mattered: an unset `ARG` is
an empty string and an empty string is a stable cache key, so the
second and every later bare `docker build .` on an unchanged tree
replayed all three check layers and still exited 0 — and `docker
build .` is the command `REPO_POLICIES.md` names verbatim as a thing
that must be green, so the documented command was precisely the one
that lied. Failed steps are never cached, which is what makes the
guard fire on every invocation rather than once. (2) The epoch is now
expanded into each check command rather than left as a bare
declaration, so the cache miss no longer depends on BuildKit's
unreferenced-`ARG` handling staying as it is, and the value appears
in the build log. (3) `script/cibuild` uses
`epoch="$(date +%s%N)$$"`, unique per invocation rather than per
second; `%N` alone is insufficient because busybox drops it silently
and exits 0, and `$$` is what makes the guarantee hold regardless.
The bare-assignment form is kept deliberately — inlined in an
argument, a failing substitution does not abort under `set -eu` and
would yield an empty constant epoch, restoring the exact false green
being fixed. (4) `script/docker` passes the same fresh arg, so the
two entrypoints cannot disagree about whether the tree is green;
local builds are almost always warm, which made it the likelier
fooling in practice. The `ARG` placement from #85 is unchanged, below
`apk add`, `COPY go.mod go.sum` and `go mod download`, so dependency
layers still cache and the build is not cold. Verified by negative
control rather than inspection — a bare `docker build .` run twice
back to back, plus back-to-back pairs of both scripts and a host-side
`make check`; the measurements are recorded once, in the PR
verification comment, rather than restated here. `.golangci.yml`, the
lint-stage `FROM` line and its digest, `script/lint`,
`REPO_POLICIES.md` and `.gitea/workflows/check.yml` are all
untouched.
- 2026-08-09: Stopped `script/cibuild` from reporting a green it did - 2026-08-09: Stopped `script/cibuild` from reporting a green it did
not earn (issue #85). A bare `docker build .` let Docker serve the not earn (issue #85). A bare `docker build .` let Docker serve the
check layers from the layer cache whenever the tree had not changed: check layers from the layer cache whenever the tree had not changed:
the checks never executed and the build still exited 0. The fix is an the checks never executed and the build still exited 0. Reproduced on
`ARG CHECK_EPOCH` declared immediately above the check `RUN`s in both this branch's base — a genuine changed-tree run took 162s with 14
the lint stage and the builder stage (`ARG` scope is per-stage, so `ok` lines, and the immediately following unchanged-tree run took
each declares its own), with `script/cibuild` assigning 221ms with 0 `ok` lines, 19 cached layers, and the same exit 0, with
`epoch="$(date +%s)"` and passing `--build-arg CHECK_EPOCH="$epoch"`. `RUN make fmt-check`, `RUN make lint`, and `RUN make test` all
The assignment is separate on purpose: under `set -eu` a command reported `CACHED`. The fix matches the upstream one in
substitution that fails inside an argument does not abort the script, `sneak/prompts` #26: an `ARG CHECK_EPOCH` declared immediately above
which would leave an empty constant `CHECK_EPOCH` and restore the the check `RUN`s in both the lint stage and the builder stage (`ARG`
very false green being fixed. Placement is the rest of the point — scope is per-stage, so each declares its own), with `script/cibuild`
the `ARG` sits below the `apk add`, `COPY go.mod go.sum`, and `go mod passing `--build-arg CHECK_EPOCH="$(date +%s)"`. Placement is the
download` layers, so only the checks are invalidated and the whole point — the `ARG` sits below the `apk add`, `COPY go.mod
dependency layers still cache. The guarantee is conditional on a go.sum`, and `go mod download` layers, so only the checks are
fresh value rather than absolute: a bare `docker build .` gets an invalidated and the dependency layers still cache. Verified by
empty `CHECK_EPOCH` and can still serve the check layers from cache, re-running the reproduction: two back-to-back runs on an unchanged
which `README.md` and the `Dockerfile` now say plainly, with issue tree took 167s and 174s, each with 14 `ok` lines and `0 issues.`, no
#91 tracking the upstream hardening (expanded `ARG` form, unset `CACHED` on any of the three check layers, while every `apk add` and
guard, per-invocation epoch, `script/docker`) that would close it. `go mod download` layer stayed `CACHED` in both. A changed-tree build
Verified by re-running the reproduction plus the withheld-`--build-arg` went from 162s to 176s, so this is not a cold build. `.golangci.yml`,
counterfactual; the measurements are recorded once, in the PR #89 the lint-stage `FROM` line and its digest, `script/lint`, and
verification comment, rather than restated here. `.golangci.yml`, the
lint-stage `FROM` line and its digest, `script/lint`, and
`.gitea/workflows/check.yml` are all untouched. `.gitea/workflows/check.yml` are all untouched.
- 2026-08-09: Corrected the `Vaultik.UI` doc comment (issue #84). It - 2026-08-09: Corrected the `Vaultik.UI` doc comment (issue #84). It
claimed the cli layer replaces the writer with a discarding one in claimed the cli layer replaces the writer with a discarding one in
@@ -431,11 +92,8 @@ release" is exactly the contradiction
exactly the pinned one (which is how the lint stage runs it inside the exactly the pinned one (which is how the lint stage runs it inside the
container); anything else goes through Docker, and a missing or container); anything else goes through Docker, and a missing or
unreachable Docker daemon is a hard error rather than a silent unreachable Docker daemon is a hard error rather than a silent
fallback. Only the **lint** leg of `make check` became equivalent to fallback. `make check` is therefore now as trustworthy as
`script/cibuild`; its tests and `gofmt` still run on the host against `script/cibuild`.
the host toolchain, as `README.md` states. An earlier version of this
entry claimed `make check` was "as trustworthy as `script/cibuild`"
outright, which overstated it; corrected under issue #80.
- 2026-08-09: Finished the lint remediation under the canonical - 2026-08-09: Finished the lint remediation under the canonical
`.golangci.yml` (issue #61, which also unblocks issue #59). The `.golangci.yml` (issue #61, which also unblocks issue #59). The
remaining findings were fixed behavior-preservingly: `wsl_v5` remaining findings were fixed behavior-preservingly: `wsl_v5`
@@ -486,4 +144,4 @@ release" is exactly the contradiction
# Future Steps # Future Steps
None queued; the release-scoping item is now the Next Step. - Define remaining scope for a first tagged release and cut v0.1.0.

View File

@@ -1,169 +0,0 @@
package main_test
import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This file guards the Makefile that builds this program, which is why
// it lives beside it rather than in a package of its own.
//
// Issue #110: `build` was listed in .PHONY with no `build:` rule
// anywhere in the file. That combination is silently successful — make
// considers a phony target with no prerequisites and no recipe already
// satisfied, so `rm -f vaultik && make build` printed "Nothing to be
// done for 'build'" and exited 0 with no binary produced. Declaring the
// name phony is precisely what converts the "No rule to make target"
// error into a green.
//
// The guard is a parse of the Makefile rather than an invocation of
// make. `make test` is what runs these tests, so shelling back into
// `make build` here would nest a build inside the test run and drop a
// binary into the tree as a side effect of testing. The one property a
// parse cannot establish — that the recipe still fails when the build
// fails — is not testable from inside the build either; it is verified
// by hand against a deliberately broken tree.
// phonyDirective introduces the list of phony target names.
const phonyDirective = ".PHONY:"
// ruleLine matches a rule's target list: a target starts in column
// zero, so recipe lines (tab-indented) and the continuation lines of a
// variable assignment (space-indented) are excluded by construction.
//
// The trailing (?:[^=]|$) rejects `:=` assignments such as
// `VERSION := $(shell script/version)`, which are not rules. Directives
// and function calls (`.PHONY:`, `ifeq`, `$(error ...)`) do not match
// because a target here must begin with a letter, digit or underscore.
var ruleLine = regexp.MustCompile(`^([A-Za-z0-9_][A-Za-z0-9_./ -]*):(?:[^=]|$)`)
// TestPhonyTargetsAllHaveRules fails on any name in .PHONY that has no
// rule in the Makefile. Such a name is not a build target at all: it is
// a command that reports success without doing anything, which is worse
// than one that does not exist, because a caller checking the exit code
// cannot tell the difference.
func TestPhonyTargetsAllHaveRules(t *testing.T) {
t.Parallel()
makefile := readMakefile(t)
phony := phonyTargets(makefile)
require.NotEmpty(t, phony, "no .PHONY names found; the parser is broken")
rules := declaredRules(makefile)
// Sanity check on the rule parser before trusting its verdict: a
// parser that found nothing would pass this test by accident.
require.Contains(t, rules, "vaultik",
"the file rule that builds the binary must be recognized")
for _, target := range phony {
assert.Contains(t, rules, target,
"`.PHONY` lists %q but the Makefile declares no %q rule, so "+
"`make %s` exits 0 without doing anything", target, target, target)
}
}
// TestBuildTargetBuildsTheBinary pins the specific shape of issue #110:
// `make build` has to reach the rule that produces the binary. The test
// above would also pass if `build:` were given an empty recipe of its
// own, which would be the same silent success under a different
// spelling.
func TestBuildTargetBuildsTheBinary(t *testing.T) {
t.Parallel()
prerequisites := rulePrerequisites(readMakefile(t), "build")
require.NotNil(t, prerequisites, "the Makefile declares no `build` rule")
assert.Contains(t, prerequisites, "vaultik",
"`make build` must depend on the rule that builds the binary")
}
// readMakefile returns the contents of the repository's Makefile. The
// test binary runs with its package directory as the working directory,
// so the root is found by walking up until the Makefile appears.
func readMakefile(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
require.NoError(t, err)
for {
//nolint:gosec // G304: the path is this test's own directory walk
contents, err := os.ReadFile(filepath.Join(dir, "Makefile"))
if err == nil {
return string(contents)
}
parent := filepath.Dir(dir)
require.NotEqual(t, dir, parent,
"walked to the filesystem root without finding a Makefile")
dir = parent
}
}
// phonyTargets returns every name declared phony, across all .PHONY
// lines.
func phonyTargets(makefile string) []string {
var targets []string
for line := range strings.SplitSeq(makefile, "\n") {
if !strings.HasPrefix(line, phonyDirective) {
continue
}
targets = append(targets,
strings.Fields(strings.TrimPrefix(line, phonyDirective))...)
}
return targets
}
// declaredRules returns the set of target names that have a rule.
func declaredRules(makefile string) map[string]bool {
rules := make(map[string]bool)
for line := range strings.SplitSeq(makefile, "\n") {
match := ruleLine.FindStringSubmatch(line)
if match == nil {
continue
}
// One rule may name several targets: `a b: prereq`.
for target := range strings.FieldsSeq(match[1]) {
rules[target] = true
}
}
return rules
}
// rulePrerequisites returns the prerequisites of the named rule, or nil
// if no such rule exists. A rule with none returns an empty slice, so
// "declared with nothing to do" is distinguishable from "not declared".
func rulePrerequisites(makefile, target string) []string {
for line := range strings.SplitSeq(makefile, "\n") {
match := ruleLine.FindStringSubmatch(line)
if match == nil {
continue
}
if !slices.Contains(strings.Fields(match[1]), target) {
continue
}
_, after, _ := strings.Cut(line, ":")
return append([]string{}, strings.Fields(after)...)
}
return nil
}

View File

@@ -1,7 +1,6 @@
package cli package cli
import ( import (
"io"
"os" "os"
"strings" "strings"
"time" "time"
@@ -15,12 +14,18 @@ import (
const shortCommitLen = 12 const shortCommitLen = 12
// Entry is the main entry point for the CLI application. // Entry is the main entry point for the CLI application.
// It prints the startup banner to stdout (unless a banner-suppressing // It prints the startup banner (unless a quiet flag is present in os.Args),
// flag is present in os.Args — see bannerSuppressedInArgs), executes the // executes the root cobra command, and routes any returned error through
// root cobra command, and routes any returned error through the // the ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
// ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
func Entry() { func Entry() {
emitStartupBanner(os.Args[1:], os.Stdout) if !bannerSuppressedInArgs(os.Args[1:]) {
short := globals.Commit
if len(short) > shortCommitLen {
short = short[:shortCommitLen]
}
writeStartupBanner(ui.New(os.Stdout), time.Now().UTC(), short)
}
rootCmd := NewRootCommand() rootCmd := NewRootCommand()
rootCmd.SilenceErrors = true rootCmd.SilenceErrors = true
@@ -32,24 +37,6 @@ func Entry() {
} }
} }
// emitStartupBanner writes the startup banner to w unless args (the
// argument vector with the program name already stripped) contains a
// flag that suppresses it. Split out of Entry so that the decision — the
// only thing standing between a --json invocation and a parseable
// stdout — is reachable from a test without running the whole CLI.
func emitStartupBanner(args []string, w io.Writer) {
if bannerSuppressedInArgs(args) {
return
}
short := globals.Commit
if len(short) > shortCommitLen {
short = short[:shortCommitLen]
}
writeStartupBanner(ui.New(w), time.Now().UTC(), short)
}
// ReportErrorf emits a user-facing error to stderr in the standard // ReportErrorf emits a user-facing error to stderr in the standard
// 🛑 ERROR: format. Use it from goroutine error paths (where returning // 🛑 ERROR: format. Use it from goroutine error paths (where returning
// an error to cobra isn't an option) and anywhere else a CLI command // an error to cobra isn't an option) and anywhere else a CLI command
@@ -59,20 +46,9 @@ func ReportErrorf(format string, args ...any) {
} }
// bannerSuppressedInArgs reports whether any of args is a flag that // bannerSuppressedInArgs reports whether any of args is a flag that
// should suppress the startup banner (--quiet/-q/--cron/--json). Stops // should suppress the startup banner (--quiet/-q/--cron). Stops at the
// at the "--" argument terminator. Recognizes both long forms and short // "--" argument terminator. Recognizes both long forms and short -q,
// -q, including combined short flags like "-qv". // including combined short flags like "-qv".
//
// This scans the raw argument vector because the banner is printed
// before cobra parses anything — deliberately, so that it still appears
// when cobra rejects the arguments and on --help. The consequence is
// that a flag is matched wherever it occurs in the vector, including
// positions where the command it belongs to would not accept it.
// --json is a subcommand flag rather than a persistent one, but so is
// --cron (it exists only on `snapshot create`), so this adds no new
// class of imprecision. The only cost of a false positive is a missing
// decorative banner; the cost of a false negative is a corrupt document
// on stdout, so the scan errs deliberately in that direction.
func bannerSuppressedInArgs(args []string) bool { func bannerSuppressedInArgs(args []string) bool {
for _, a := range args { for _, a := range args {
if a == "--" { if a == "--" {
@@ -80,13 +56,11 @@ func bannerSuppressedInArgs(args []string) bool {
} }
switch a { switch a {
case "--quiet", "-q", "--cron", "--json": case "--quiet", "-q", "--cron":
return true return true
} }
if strings.HasPrefix(a, "--quiet=") || if strings.HasPrefix(a, "--quiet=") || strings.HasPrefix(a, "--cron=") {
strings.HasPrefix(a, "--cron=") ||
strings.HasPrefix(a, "--json=") {
return true return true
} }
// Combined short flags like -qv or -vq. // Combined short flags like -qv or -vq.

View File

@@ -1,300 +0,0 @@
package cli //nolint:testpackage // needs access to unexported emitStartupBanner
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Command words and flags used to build argument vectors below. They are
// constants rather than repeated literals so that a rename shows up as a
// compile error in one place.
const (
cmdSnapshot = "snapshot"
cmdList = "list"
cmdCreate = "create"
cmdVerify = "verify"
cmdRemove = "remove"
cmdPrune = "prune"
cmdRemote = "remote"
cmdInfo = "info"
flagJSON = "--json"
flagQuiet = "--quiet"
flagConfig = "--config"
// programName is argv[0] as the real process receives it. Entry
// strips it before scanning, so it has to be present.
programName = "vaultik"
// someSnapshotID is any snapshot identifier: these tests never run
// the command, so it only has to occupy the positional argument.
someSnapshotID = "host_2026-01-01T00:00:00Z"
)
// placeholderJSONDocument stands in for whatever document a --json
// command writes to stdout. `snapshot list --json` with no snapshots
// prints exactly this; the other --json commands print an object rather
// than an array, but this test is not about their shape. It is about
// what is on stdout *before* them, which is the same for all of them
// because Entry prints the banner before cobra has parsed anything and
// therefore before it can know which command is running.
const placeholderJSONDocument = "[]\n"
// jsonArgumentVectors are the argument vectors of every --json
// invocation the CLI accepts, with the program name stripped exactly as
// Entry strips it. Each one must leave stdout untouched by the banner.
//
//nolint:gochecknoglobals // read-only test fixture shared by two tests
var jsonArgumentVectors = map[string][]string{
"snapshot list": {cmdSnapshot, cmdList, flagJSON},
"snapshot verify": {cmdSnapshot, cmdVerify, someSnapshotID, flagJSON},
"snapshot remove": {cmdSnapshot, cmdRemove, someSnapshotID, flagJSON},
"prune": {cmdPrune, flagJSON},
"remote info": {cmdRemote, cmdInfo, flagJSON},
// --json before the subcommand, and with an explicit value: the
// scan is positional, so both forms have to be recognized.
"json first": {flagJSON, cmdSnapshot, cmdList},
"json with value": {cmdSnapshot, cmdList, flagJSON + "=true"},
// A --json invocation that also carries a flag with a value, so the
// scan cannot be fooled by an argument that consumes the next one.
"json with config": {
flagConfig, "/nonexistent/vaultik.yml", cmdSnapshot, cmdList, flagJSON,
},
}
// TestJSONInvocationStdoutIsExactlyOneDocument is the CLI-layer
// regression guard for issue #106: `vaultik snapshot list --json | jq`
// must work with no other flags.
//
// internal/vaultik's TestListSnapshots_JSONStdoutIsOnlyTheDocument
// guards the same contract one layer down, but it calls the library
// function directly and so cannot see Entry, which is where the
// contamination was: the startup banner is written to stdout before
// cobra parses anything, and the suppression scan did not know about
// --json. The two banner lines and the blank line landed ahead of the
// document and `jq` refused the result.
//
// The document is a constant here because this test is about the
// argument vectors, one per --json command; the one that runs a real
// command end to end is TestEntryJSONStdoutIsExactlyOneDocument below.
func TestJSONInvocationStdoutIsExactlyOneDocument(t *testing.T) {
t.Parallel()
for name, argv := range jsonArgumentVectors {
t.Run(name, func(t *testing.T) {
t.Parallel()
var stdout bytes.Buffer
emitStartupBanner(argv, &stdout)
require.Empty(t, stdout.String(),
"nothing may reach stdout ahead of a --json document")
_, err := stdout.WriteString(placeholderJSONDocument)
require.NoError(t, err)
requireExactlyOneJSONDocument(t, stdout.String())
})
}
}
// TestBannerStillPrintedWithoutSuppressingFlag pins the other half of
// the contract. Without it, deleting the banner outright would satisfy
// the test above, and the banner is wanted on interactive invocations.
func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
t.Parallel()
for name, argv := range map[string][]string{
"no flags": {cmdSnapshot, cmdList},
"verbose": {cmdSnapshot, cmdList, "--verbose"},
"after the terminator": {
cmdSnapshot, "restore", "--", flagJSON,
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
var stdout bytes.Buffer
emitStartupBanner(argv, &stdout)
assert.Contains(t, stdout.String(), "starting up at",
"the banner belongs on invocations that did not opt out")
})
}
}
// TestBannerSuppressedInArgs covers the suppression scan directly,
// including the flags that suppressed the banner before --json joined
// them, so that adding --json cannot regress them.
func TestBannerSuppressedInArgs(t *testing.T) {
t.Parallel()
for name, testCase := range map[string]struct {
args []string
suppressed bool
}{
"quiet long": {[]string{cmdSnapshot, cmdCreate, flagQuiet}, true},
"quiet short": {[]string{cmdSnapshot, cmdCreate, "-q"}, true},
"quiet combined": {[]string{cmdSnapshot, cmdCreate, "-qv"}, true},
"cron": {[]string{cmdSnapshot, cmdCreate, "--cron"}, true},
"json": {[]string{cmdSnapshot, cmdList, flagJSON}, true},
"nothing": {[]string{cmdSnapshot, cmdList}, false},
"empty": {nil, false},
"json after dashes": {
[]string{cmdSnapshot, cmdList, "--", flagJSON}, false,
},
"quiet after dashes": {
[]string{cmdSnapshot, cmdCreate, "--", "-q"}, false,
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, testCase.suppressed,
bannerSuppressedInArgs(testCase.args))
})
}
}
// hermeticConfig is a complete, valid config that needs no network and
// no credentials: file:// storage is exempt from the S3 credential
// checks, and FileStorer over a directory that does not exist lists
// zero objects without erroring. Chunk, blob and compression settings
// are filled in by config.Load.
const hermeticConfig = `age_recipients:
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
snapshots:
test:
paths:
- %s
storage_url: file://%s
index_path: %s
hostname: test-host
`
// TestEntryJSONStdoutIsExactlyOneDocument runs the real thing: Entry,
// with a real argument vector, over the process's real stdout file
// descriptor, all the way through cobra and the fx graph to the
// document. It is the assertion the issue asks for — `vaultik snapshot
// list --json | jq .` with no other flags — with the pipe replaced by a
// decoder.
//
// `snapshot list` is the command chosen because it is the only --json
// command that reaches its document without a populated destination
// store: it reads the local index, streams `metadata/` (empty here),
// and treats a barren destination as an empty list rather than a
// failure.
//
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
contents := fmt.Sprintf(hermeticConfig,
filepath.Join(dir, "source"),
filepath.Join(dir, "store"),
filepath.Join(dir, "index.sqlite"))
require.NoError(t,
os.WriteFile(configPath, []byte(contents), configFileMode))
// The PID lock lives under xdg.DataHome, which xdg resolves at
// package init; point it at the temp dir so the test neither
// touches nor collides with the real one.
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
xdg.Reload()
t.Cleanup(xdg.Reload)
previousArgs := os.Args
t.Cleanup(func() {
os.Args = previousArgs
rootFlags = RootFlags{}
})
os.Args = []string{
programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON,
}
stdout := captureProcessStdout(t, Entry)
requireExactlyOneJSONDocument(t, stdout)
var snapshots []any
require.NoError(t, json.Unmarshal([]byte(stdout), &snapshots))
assert.Empty(t, snapshots,
"a destination store with no snapshots lists none")
}
// captureProcessStdout redirects the process's own stdout to a pipe for
// the duration of fn and returns what was written to it. The redirection
// has to be at the file-descriptor level rather than through an injected
// writer, because the banner and the JSON encoder reach os.Stdout
// independently and the point of the test is that both land in the same
// place.
//
// Not parallel-safe: os.Stdout is process-global.
func captureProcessStdout(t *testing.T, fn func()) string {
t.Helper()
reader, writer, err := os.Pipe()
require.NoError(t, err)
previous := os.Stdout
os.Stdout = writer
captured := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, reader)
captured <- buf.String()
}()
fn()
os.Stdout = previous
require.NoError(t, writer.Close())
out := <-captured
require.NoError(t, reader.Close())
return out
}
// requireExactlyOneJSONDocument fails unless stdout decodes as a single
// JSON value with nothing before or after it — the property that makes
// `| jq` work.
func requireExactlyOneJSONDocument(t *testing.T, stdout string) {
t.Helper()
decoder := json.NewDecoder(strings.NewReader(stdout))
var document any
err := decoder.Decode(&document)
require.NoError(t, err,
"stdout must parse as JSON, got:\n%s", stdout)
_, err = decoder.Token()
require.ErrorIs(t, err, io.EOF,
"stdout must hold exactly one JSON document, got:\n%s", stdout)
}

View File

@@ -1,165 +0,0 @@
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/types"
)
// pruneJSONDocument is the shape `prune --json` writes: the
// PruneBlobsResult document, and nothing else.
//
//nolint:tagliatelle // snake_case is the established JSON output format
type pruneJSONDocument struct {
BlobsFound int `json:"blobs_found"`
BlobsDeleted int `json:"blobs_deleted"`
BytesFreed int64 `json:"bytes_freed"`
}
// stalePruneSnapshotID is seeded into the local index with no manifest
// on the destination store, which is exactly what makes it stale.
const stalePruneSnapshotID = "test-host_test_2026-04-01T09:00:00Z"
// TestEntryPruneJSONStdoutIsExactlyOneDocument is the end-to-end
// regression guard for issue #108: `vaultik prune --json | jq .` must
// work with no other flags.
//
// It runs Entry over the process's real stdout descriptor, through
// cobra and the fx graph, against a hermetic file:// destination store
// — the same construction TestEntryJSONStdoutIsExactlyOneDocument uses
// for `snapshot list`, with the pipe to jq replaced by a decoder.
//
// Both branches of the local-snapshot reconciliation are exercised
// because the three stdout writes that broke this covered all of them:
// one line per stale record and a summary when there were any, and a
// "No stale local snapshots found." line when there were none. No input
// avoided the contamination, so no single branch demonstrates the fix.
//
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
//
//nolint:paralleltest // replaces os.Args, os.Stdout and the xdg globals
func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
for _, testCase := range []struct {
name string
seedStale bool
description string
}{
{
name: "no stale local records",
seedStale: false,
description: "the empty-index branch used to print a 'No stale' line",
},
{
name: "stale local records present",
seedStale: true,
description: "the removal branch used to print a line per record " +
"plus a summary",
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeHermeticPruneConfig(t, testCase.seedStale)
previousArgs := os.Args
t.Cleanup(func() {
os.Args = previousArgs
rootFlags = RootFlags{}
})
os.Args = []string{
programName, flagConfig, configPath, cmdPrune, flagJSON,
}
stdout := captureProcessStdout(t, Entry)
requireExactlyOneJSONDocument(t, stdout)
var document pruneJSONDocument
require.NoError(t, json.Unmarshal([]byte(stdout), &document),
testCase.description)
// A destination store with no blobs has none to prune. The
// assertion that matters is the one above; this one keeps the
// test honest about which document it decoded.
assert.Equal(t, 0, document.BlobsFound)
})
}
}
// writeHermeticPruneConfig builds a config over a temp directory and, if
// seedStale is set, creates the index database up front with one
// snapshot record that has no counterpart on the destination store.
// Returns the config path.
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
indexPath := filepath.Join(dir, "index.sqlite")
contents := fmt.Sprintf(hermeticConfig,
filepath.Join(dir, "source"),
filepath.Join(dir, "store"),
indexPath)
require.NoError(t,
os.WriteFile(configPath, []byte(contents), configFileMode))
// The PID lock lives under xdg.DataHome, which xdg resolves at
// package init; point it at the temp dir so the test neither
// touches nor collides with the real one.
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
xdg.Reload()
t.Cleanup(xdg.Reload)
if seedStale {
seedStaleSnapshotRecord(t, indexPath)
}
return configPath
}
// seedStaleSnapshotRecord creates the index database at path and
// inserts one completed snapshot into it. Nothing is written to the
// destination store, so `prune` finds the record stale and removes it —
// the branch that printed a line per record.
func seedStaleSnapshotRecord(t *testing.T, path string) {
t.Helper()
ctx := context.Background()
db, err := database.New(ctx, path)
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
startedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
completedAt := startedAt.Add(time.Minute)
snap := &database.Snapshot{
ID: types.SnapshotID(stalePruneSnapshotID),
Hostname: "test-host",
VaultikVersion: "test",
StartedAt: startedAt,
CompletedAt: &completedAt,
}
repos := database.NewRepositories(db)
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
return repos.Snapshots.Create(ctx, tx, snap)
})
require.NoError(t, err)
}

View File

@@ -2,7 +2,7 @@ package cli
import ( import (
"fmt" "fmt"
"io" "os"
"runtime" "runtime"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -16,35 +16,28 @@ func NewVersionCommand() *cobra.Command {
Short: "Print version information", Short: "Print version information",
Long: `Print version, git commit, and build information for vaultik.`, Long: `Print version, git commit, and build information for vaultik.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
writeVersion(cmd.OutOrStdout()) _, _ = 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.")
}
}, },
} }
return cmd 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

@@ -1,77 +0,0 @@
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,23 +3,14 @@
package globals package globals
import ( import (
"strings"
"time" "time"
) )
// Appname is the application name, populated from main(). // Appname is the application name, populated from main().
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time 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(). // Version is the application version, populated from main().
var Version = DevVersion //nolint:gochecknoglobals // set via -ldflags at build time var Version = "dev" //nolint:gochecknoglobals // set via -ldflags at build time
// Commit is the git commit hash, populated from main(). // Commit is the git commit hash, populated from main().
var Commit = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time var Commit = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time
@@ -33,9 +24,6 @@ const Author = "Jeffrey Paul <sneak@sneak.berlin>"
// Homepage is the canonical URL for vaultik. // Homepage is the canonical URL for vaultik.
const Homepage = "https://sneak.berlin/go/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. // License is the SPDX identifier for the project license.
const License = "MIT" const License = "MIT"
@@ -59,21 +47,6 @@ func New() (*Globals, error) {
}, nil }, 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. // shortCommitLen is the number of commit-hash characters ShortCommit keeps.
const shortCommitLen = 12 const shortCommitLen = 12

View File

@@ -32,56 +32,3 @@ func TestGlobalsNew(t *testing.T) {
t.Error("Commit should not be empty") 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

@@ -1,9 +1,5 @@
// Package log provides the application-wide structured logger: slog // Package log provides the application-wide structured logger: slog
// writing to stderr, with a colorized TTY handler when stderr is a // with a colorized TTY handler on terminals and JSON output otherwise.
// terminal and JSON output otherwise.
//
// Everything this package emits is a diagnostic, so it all goes to
// stderr. stdout belongs to the output the user asked for.
package log //nolint:revive,nolintlint // stdlib log unused here; see #76 package log //nolint:revive,nolintlint // stdlib log unused here; see #76
import ( import (
@@ -73,27 +69,13 @@ func Initialize(cfg Config) {
Level: level, Level: level,
} }
// Diagnostics go to stderr, never to stdout. stdout is reserved for // Check if stdout is a TTY.
// the output the user asked for: every --json subcommand writes its if term.IsTerminal(int(os.Stdout.Fd())) {
// document there, and WARN/ERROR are never suppressed, so a logger
// on stdout puts log records inside that document and makes it
// unparseable. A config file with group- or world-readable
// permissions is enough to trigger it (see internal/config), so this
// was not a theoretical collision.
//
// The format is chosen by the TTY-ness of the stream the records
// actually land on. AGENTS.md policy 9 says "if stdout is not a
// terminal, emit jsonl"; it says stdout because that is where logs
// used to go, and the property it is really asking for is that
// output nobody is watching be machine-readable. Testing stdout here
// would colorize records on a redirected stderr whenever stdout
// happened to be a terminal, and vice versa.
if term.IsTerminal(int(os.Stderr.Fd())) {
// Use colorized TTY handler // Use colorized TTY handler
logger = slog.New(NewTTYHandler(os.Stderr, opts)) logger = slog.New(NewTTYHandler(os.Stdout, opts))
} else { } else {
// Use JSON format for non-TTY output // Use JSON format for non-TTY output
logger = slog.New(slog.NewJSONHandler(os.Stderr, opts)) logger = slog.New(slog.NewJSONHandler(os.Stdout, opts))
} }
// Set as default logger // Set as default logger

View File

@@ -5,34 +5,10 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"strings"
"sync" "sync"
"time" "time"
) )
// groupSeparator joins an open group path to an attribute key. This
// format has no nesting, so a group becomes a dotted key prefix:
// slog.New(h).WithGroup("db").With("rows", 3) renders "db.rows=3".
const groupSeparator = "."
// bytesAttrKey is the attribute key whose int64 value is rendered as a
// human-readable byte count rather than a bare number. Keys reaching
// writeAttr are group-qualified, so the match is made against the final
// dot-separated segment: without that, a "bytes" attribute logged under
// an open group would arrive as "transfer.bytes" and silently lose its
// formatting.
const bytesAttrKey = "bytes"
// isBytesAttr reports whether a group-qualified attribute key names the
// byte-count attribute, i.e. whether its last segment is bytesAttrKey.
func isBytesAttr(key string) bool {
if idx := strings.LastIndex(key, groupSeparator); idx >= 0 {
key = key[idx+len(groupSeparator):]
}
return key == bytesAttrKey
}
// ANSI color codes // ANSI color codes
const ( const (
colorReset = "\033[0m" colorReset = "\033[0m"
@@ -46,26 +22,10 @@ const (
) )
// TTYHandler is a custom slog handler for TTY output with colors. // TTYHandler is a custom slog handler for TTY output with colors.
//
// A handler and the handlers derived from it via WithAttrs/WithGroup
// all write to the same stream, so they share one mutex; that is why mu
// is a pointer. A value mutex would give every derived handler its own
// lock and stop serializing writes to the stream they have in common.
type TTYHandler struct { type TTYHandler struct {
opts slog.HandlerOptions opts slog.HandlerOptions
mu *sync.Mutex mu sync.Mutex
out io.Writer out io.Writer
// attrs are the attributes accumulated through WithAttrs, emitted
// ahead of each record's own attributes. Their keys already carry
// the group path that was open when they were added, so no
// qualification happens at write time.
attrs []slog.Attr
// groups is the group path opened by WithGroup, applied as a key
// prefix to attributes that arrive later — both on a record and
// through a further WithAttrs.
groups []string
} }
// NewTTYHandler creates a new TTY handler with colored output. // NewTTYHandler creates a new TTY handler with colored output.
@@ -77,7 +37,6 @@ func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler {
return &TTYHandler{ return &TTYHandler{
out: out, out: out,
opts: *opts, opts: *opts,
mu: &sync.Mutex{},
} }
} }
@@ -122,126 +81,8 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
levelColor, level, colorReset, levelColor, level, colorReset,
colorBold, r.Message, colorReset) colorBold, r.Message, colorReset)
// Attributes carried by the handler come first, then the record's // Print attributes
// own. Handler attributes were qualified when they were added; the
// record's are qualified now, against whatever group path is open.
for _, a := range h.attrs {
h.writeAttr(a)
}
prefix := strings.Join(h.groups, groupSeparator)
r.Attrs(func(a slog.Attr) bool { r.Attrs(func(a slog.Attr) bool {
for _, flat := range appendAttr(nil, prefix, a) {
h.writeAttr(flat)
}
return true
})
_, _ = fmt.Fprintln(h.out)
return nil
}
// appendAttr flattens a into dst, folding prefix into its key and
// expanding group values into further dotted keys. Following the
// slog.Handler contract: an empty Attr is dropped, a group with no
// attributes is dropped, and a group with an empty key is inlined into
// its parent rather than contributing a level.
func appendAttr(dst []slog.Attr, prefix string, a slog.Attr) []slog.Attr {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return dst
}
key := a.Key
switch {
case prefix == "":
// key stands alone.
case key == "":
key = prefix
default:
key = prefix + groupSeparator + key
}
if a.Value.Kind() != slog.KindGroup {
return append(dst, slog.Attr{Key: key, Value: a.Value})
}
for _, member := range a.Value.Group() {
dst = appendAttr(dst, key, member)
}
return dst
}
// WithAttrs returns a new handler that emits attrs on every record it
// handles, in addition to whatever the handler already carried. Keys
// are qualified by the group path open at the time of the call, so
// WithGroup("db").WithAttrs(rows=3) later renders "db.rows=3".
//
// The receiver is not modified.
func (h *TTYHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
prefix := strings.Join(h.groups, groupSeparator)
next := h.clone()
for _, a := range attrs {
next.attrs = appendAttr(next.attrs, prefix, a)
}
return next
}
// WithGroup returns a new handler that qualifies every subsequent
// attribute key with name. This format is a single line with nowhere to
// nest, so grouping is rendered as a dotted key prefix: after
// WithGroup("db"), an attribute "rows" is emitted as "db.rows".
//
// An empty name returns the receiver unchanged, per the slog.Handler
// contract. The receiver is not modified.
func (h *TTYHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
next := h.clone()
next.groups = append(next.groups, name)
return next
}
// clone returns a copy of h that shares its output stream and mutex but
// owns its attribute and group slices.
//
// The slices are copied rather than resliced on purpose. slog permits
// one handler to be derived from concurrently, and two derivations that
// appended into a shared backing array would each overwrite the other's
// attribute — a data race with a silent wrong-output failure mode.
func (h *TTYHandler) clone() *TTYHandler {
next := &TTYHandler{
opts: h.opts,
mu: h.mu,
out: h.out,
attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1),
groups: make([]string, len(h.groups), len(h.groups)+1),
}
copy(next.attrs, h.attrs)
copy(next.groups, h.groups)
return next
}
// writeAttr renders one already-flattened, already-qualified attribute
// as " key=value". Callers hold h.mu.
func (h *TTYHandler) writeAttr(a slog.Attr) {
value := a.Value.String() value := a.Value.String()
// Special handling for certain attribute types // Special handling for certain attribute types
switch a.Value.Kind() { switch a.Value.Kind() {
@@ -250,7 +91,7 @@ func (h *TTYHandler) writeAttr(a slog.Attr) {
value = formatDuration(d) value = formatDuration(d)
} }
case slog.KindInt64: case slog.KindInt64:
if isBytesAttr(a.Key) { if a.Key == "bytes" {
value = formatBytes(a.Value.Int64()) value = formatBytes(a.Value.Int64())
} }
case slog.KindAny, slog.KindBool, slog.KindFloat64, slog.KindString, case slog.KindAny, slog.KindBool, slog.KindFloat64, slog.KindString,
@@ -263,6 +104,23 @@ func (h *TTYHandler) writeAttr(a slog.Attr) {
_, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s", _, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s",
colorCyan, a.Key, colorReset, colorCyan, a.Key, colorReset,
colorBlue, value, colorReset) colorBlue, value, colorReset)
return true
})
_, _ = fmt.Fprintln(h.out)
return nil
}
// WithAttrs returns a new handler with the given attributes.
func (h *TTYHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return h // Simplified for now
}
// WithGroup returns a new handler with the given group name.
func (h *TTYHandler) WithGroup(_ string) slog.Handler {
return h // Simplified for now
} }
// formatDuration formats a duration in a human-readable way // formatDuration formats a duration in a human-readable way

View File

@@ -1,422 +0,0 @@
package log_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
)
// ansiEscape matches the SGR sequences TTYHandler wraps every field in.
// Stripping them is what lets a test compare TTYHandler's rendering with
// slog.JSONHandler's.
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// countKey is an attribute key reused across the comparison cases.
const countKey = "count"
// debugHandlerOptions enables every level, so a test never has to reason
// about the default level while reasoning about attributes.
func debugHandlerOptions() *slog.HandlerOptions {
return &slog.HandlerOptions{Level: slog.LevelDebug}
}
// ttyAttrs renders one record through a TTYHandler and returns its
// attributes as key -> value, with color stripped.
//
// TTYHandler emits " key=value" per attribute after the message, and the
// message itself is the last thing before the first attribute, so
// splitting on spaces and keeping the tokens containing "=" recovers the
// attribute set. Test values below therefore avoid spaces and "=".
func ttyAttrs(t *testing.T, derive func(*slog.Logger) *slog.Logger,
msg string, args ...any,
) map[string]string {
t.Helper()
var buf bytes.Buffer
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
derive(logger).Info(msg, args...)
line := ansiEscape.ReplaceAllString(buf.String(), "")
attrs := make(map[string]string)
for token := range strings.FieldsSeq(line) {
key, value, found := strings.Cut(token, "=")
if !found {
continue
}
attrs[key] = value
}
return attrs
}
// jsonAttrs renders one record through slog.JSONHandler and returns its
// attributes flattened to the same dotted-key form TTYHandler uses, so
// the two are directly comparable. The built-in time/level/msg fields
// are dropped: they are the record, not its attributes.
func jsonAttrs(t *testing.T, derive func(*slog.Logger) *slog.Logger,
msg string, args ...any,
) map[string]string {
t.Helper()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, debugHandlerOptions()))
derive(logger).Info(msg, args...)
var decoded map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
delete(decoded, slog.TimeKey)
delete(decoded, slog.LevelKey)
delete(decoded, slog.MessageKey)
attrs := make(map[string]string)
flattenJSON(attrs, "", decoded)
return attrs
}
// flattenJSON turns JSONHandler's nested group objects into the dotted
// keys TTYHandler writes.
func flattenJSON(dst map[string]string, prefix string, src map[string]any) {
for key, value := range src {
full := key
if prefix != "" {
full = prefix + "." + key
}
nested, ok := value.(map[string]any)
if ok {
flattenJSON(dst, full, nested)
continue
}
dst[full] = valueString(value)
}
}
// valueString renders a decoded JSON scalar the way slog.Value.String
// renders the corresponding Go value, so the two handlers' outputs can
// be compared as strings. encoding/json decodes every number as
// float64, so an integral one is rendered back as an integer — which is
// what the Go value that produced it was.
func valueString(v any) string {
switch typed := v.(type) {
case string:
return typed
case bool:
return strconv.FormatBool(typed)
case float64:
if typed == math.Trunc(typed) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'g', -1, 64)
default:
return fmt.Sprint(v)
}
}
// TestTTYHandlerWithAttrsEmitsAttributes is the direct regression test
// for the reported defect: WithAttrs discarded its argument, so an
// attribute attached to a logger never reached the output.
func TestTTYHandlerWithAttrsEmitsAttributes(t *testing.T) {
t.Parallel()
attrs := ttyAttrs(t, func(l *slog.Logger) *slog.Logger {
return l.With("key", "value")
}, "hello")
assert.Equal(t, "value", attrs["key"],
"an attribute attached with With must appear on every record")
}
// TestTTYHandlerWithAttrsPersistsAcrossRecords checks that the
// attributes are retained rather than emitted once. A handler that
// stored them but consumed them would pass the test above.
func TestTTYHandlerWithAttrsPersistsAcrossRecords(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions())).
With("request", "abc123")
logger.Info("first")
logger.Info("second")
plain := ansiEscape.ReplaceAllString(buf.String(), "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
require.Len(t, lines, 2)
for _, line := range lines {
assert.Contains(t, line, "request=abc123")
}
}
// TestTTYHandlerWithGroupQualifiesKeys checks that WithGroup does
// something real rather than being discarded. This format has no
// nesting, so grouping shows up as a dotted key prefix.
func TestTTYHandlerWithGroupQualifiesKeys(t *testing.T) {
t.Parallel()
attrs := ttyAttrs(t, func(l *slog.Logger) *slog.Logger {
return l.WithGroup("db").With("rows", 3)
}, "queried", "table", "chunks")
assert.Equal(t, "3", attrs["db.rows"],
"an attribute added under a group must be qualified by it")
assert.Equal(t, "chunks", attrs["db.table"],
"a record attribute must also be qualified by the open group")
assert.NotContains(t, attrs, "rows")
}
// TestTTYHandlerByteFormattingSurvivesGrouping guards the interaction
// between the two features. The human-readable rendering of a "bytes"
// attribute is selected by comparing the key, and keys reaching that
// comparison are group-qualified, so a "bytes" attribute logged under an
// open group arrived as "transfer.bytes" and fell back to a bare number.
// No caller groups a byte count today, which is exactly why this needs a
// test rather than a bug report.
func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
t.Parallel()
const oneAndAHalfKiB = 1536
for name, testCase := range map[string]struct {
derive func(*slog.Logger) *slog.Logger
key string
}{
"ungrouped": {
derive: func(l *slog.Logger) *slog.Logger { return l },
key: "bytes",
},
"grouped": {
derive: func(l *slog.Logger) *slog.Logger {
return l.WithGroup("transfer")
},
key: "transfer.bytes",
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
testCase.derive(logger).Info("uploaded", "bytes", oneAndAHalfKiB)
line := ansiEscape.ReplaceAllString(buf.String(), "")
assert.Contains(t, line, testCase.key+"=1.5 KB",
"a byte count must be human-readable however it is qualified")
assert.NotContains(t, line, strconv.Itoa(oneAndAHalfKiB),
"the raw number must not survive the formatting")
})
}
}
// TestTTYHandlerMatchesJSONHandlerAttributes is the drift guard. The
// handler is chosen by TTY-ness, so a difference between these two is
// invisible in whichever environment the developer is not in — which is
// how the original defect survived: attributes vanished on a terminal
// and were correct in CI.
func TestTTYHandlerMatchesJSONHandlerAttributes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
derive func(*slog.Logger) *slog.Logger
args []any
}{
{
name: "record attributes only",
derive: func(l *slog.Logger) *slog.Logger { return l },
args: []any{"path", "/etc/vaultik", countKey, 7},
},
{
name: "handler attributes",
derive: func(l *slog.Logger) *slog.Logger {
return l.With("host", "alpha")
},
args: []any{countKey, 7},
},
{
name: "handler attributes accumulate",
derive: func(l *slog.Logger) *slog.Logger {
return l.With("host", "alpha").With("snapshot", "s1")
},
args: []any{countKey, 7},
},
{
name: "group qualifies later attributes",
derive: func(l *slog.Logger) *slog.Logger {
return l.WithGroup("db").With("rows", 3)
},
args: []any{"table", "chunks"},
},
{
name: "nested groups",
derive: func(l *slog.Logger) *slog.Logger {
return l.WithGroup("outer").WithGroup("inner").
With("leaf", "v")
},
args: []any{"other", "w"},
},
{
name: "attributes before and after a group",
derive: func(l *slog.Logger) *slog.Logger {
return l.With("top", "t").WithGroup("g").With("in", "i")
},
args: []any{"rec", "r"},
},
{
name: "inline group value on the record",
derive: func(l *slog.Logger) *slog.Logger { return l },
args: []any{slog.Group("net",
slog.String("proto", "s3"), slog.Int("retries", 2))},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
tty := ttyAttrs(t, testCase.derive, "message", testCase.args...)
js := jsonAttrs(t, testCase.derive, "message", testCase.args...)
assert.Equal(t, sortedKeys(js), sortedKeys(tty),
"TTY and JSON handlers must emit the same attribute keys")
assert.Equal(t, js, tty,
"TTY and JSON handlers must emit the same attribute values")
})
}
}
// sortedKeys returns m's keys in order, for a stable comparison message.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// TestTTYHandlerWithAttrsDoesNotMutateReceiver checks that deriving does
// not write through to the parent or to a sibling. slog permits a
// handler to be shared, so a WithAttrs that appended into the receiver's
// state would leak attributes between unrelated loggers.
func TestTTYHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
base := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
first := base.With("branch", "one")
second := base.With("branch", "two")
base.Info("base")
first.Info("first")
second.Info("second")
plain := ansiEscape.ReplaceAllString(buf.String(), "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
require.Len(t, lines, 3)
assert.NotContains(t, lines[0], "branch=",
"deriving must not add attributes to the handler derived from")
assert.Contains(t, lines[1], "branch=one")
assert.NotContains(t, lines[1], "branch=two")
assert.Contains(t, lines[2], "branch=two")
assert.NotContains(t, lines[2], "branch=one")
}
// TestTTYHandlerConcurrentDerivation exercises the same handler being
// derived from and written through by several goroutines at once, which
// is what slog permits and what a mutating WithAttrs would make a data
// race. Run under -race by script/test.
func TestTTYHandlerConcurrentDerivation(t *testing.T) {
t.Parallel()
const workers = 16
var buf bytes.Buffer
base := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions())).
With("shared", "yes")
var group sync.WaitGroup
group.Add(workers)
for worker := range workers {
go func() {
defer group.Done()
base.With("worker", worker).
WithGroup("g").
With("nested", worker).
Info("concurrent")
}()
}
group.Wait()
plain := ansiEscape.ReplaceAllString(buf.String(), "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
require.Len(t, lines, workers)
for _, line := range lines {
assert.Contains(t, line, "shared=yes")
assert.Contains(t, line, "worker=")
assert.Contains(t, line, "g.nested=")
}
}
// TestTTYHandlerEmptyGroupAndAttrsAreNoOps covers the slog.Handler
// contract corners: WithGroup("") and WithAttrs(nil) change nothing, and
// an empty Attr is dropped rather than rendered as "=".
func TestTTYHandlerEmptyGroupAndAttrsAreNoOps(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
handler := log.NewTTYHandler(&buf, debugHandlerOptions())
assert.Same(t, handler, handler.WithGroup(""),
"an empty group name must not open a group")
assert.Same(t, handler, handler.WithAttrs(nil),
"deriving with no attributes must not allocate a handler")
slog.New(handler).LogAttrs(context.Background(), slog.LevelInfo, "msg",
slog.Attr{}, slog.String("kept", "yes"))
plain := ansiEscape.ReplaceAllString(buf.String(), "")
assert.Contains(t, plain, "kept=yes")
assert.NotContains(t, plain, " =")
}

View File

@@ -1,64 +0,0 @@
//nolint:testpackage // needs the package logger; see TestWithAttributesReachTTYOutput
package log //nolint:revive,nolintlint // stdlib log unused here; see #76
import (
"bytes"
"log/slog"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withTestANSIEscape matches the SGR sequences TTYHandler emits.
var withTestANSIEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// TestWithAttributesReachTTYOutput exercises the exported package-level
// With through a TTYHandler, which is the path the reported defect was
// on: the handler is selected by TTY-ness, so on a terminal With's
// attributes were silently dropped while the same code printed them
// correctly in CI.
//
// This is an in-package test so it can point the package logger at a
// buffer. Building an slog.Logger over a TTYHandler by hand would test
// slog, not this package's With, and there is no injectable sink to
// reach it from outside. The package logger is process-global, so this
// test must not run in parallel.
//
//nolint:paralleltest // replaces the process-global package logger
func TestWithAttributesReachTTYOutput(t *testing.T) {
var buf bytes.Buffer
previous := logger
t.Cleanup(func() { logger = previous })
logger = slog.New(NewTTYHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
With("key", "value").Info("hello")
plain := withTestANSIEscape.ReplaceAllString(buf.String(), "")
require.NotEmpty(t, plain)
assert.Contains(t, plain, "hello")
assert.Contains(t, plain, "key=value",
"log.With attributes must reach TTYHandler output")
}
// TestWithoutInitializedLoggerFallsBack pins the documented behavior of
// With before Initialize has run: it hands back the slog default rather
// than a nil logger that would panic at the call site.
//
//nolint:paralleltest // replaces the process-global package logger
func TestWithoutInitializedLoggerFallsBack(t *testing.T) {
previous := logger
t.Cleanup(func() { logger = previous })
logger = nil
assert.NotNil(t, With("key", "value"))
}

View File

@@ -79,7 +79,7 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
// store is treated as gone. This used to be the separate 'snapshot // store is treated as gone. This used to be the separate 'snapshot
// cleanup' command and is now folded in so a single 'vaultik prune' // cleanup' command and is now folded in so a single 'vaultik prune'
// gets the local index fully back in sync with the destination. // gets the local index fully back in sync with the destination.
err = v.CleanupLocalSnapshots(opts) err = v.CleanupLocalSnapshots()
if err != nil { if err != nil {
return fmt.Errorf("reconciling local snapshots with remote: %w", err) return fmt.Errorf("reconciling local snapshots with remote: %w", err)
} }

View File

@@ -1,134 +0,0 @@
package vaultik_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// cleanupStaleID is a local snapshot record with no remote manifest —
// the record CleanupLocalSnapshots exists to remove.
const cleanupStaleID = "testhost_home_2026-04-01T09:00:00Z"
// remainingSnapshotLimit bounds the post-cleanup listing. ListRecent
// takes a SQL LIMIT, so it must be positive; the fixtures never exceed
// a handful of rows.
const remainingSnapshotLimit = 100
// cleanupStart is the fixture snapshot's start time. Its exact value is
// irrelevant; only presence in the index matters here.
//
//nolint:gochecknoglobals // read-only fixture shared by the tests below
var cleanupStart = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
// TestCleanupLocalSnapshots_JSONWritesNothingToStdout is the regression
// guard for issue #108: `vaultik prune --json | jq` failed because this
// function wrote prose to stdout on every branch, ahead of the
// PruneBlobsResult document, with no --json awareness at all.
//
// Both branches are covered because the three writes between them left
// no input that avoided the contamination: with stale records there was
// a line per record plus a summary, and with none there was still the
// "No stale local snapshots found." line.
func TestCleanupLocalSnapshots_JSONWritesNothingToStdout(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
for name, seed := range map[string]func(*listEnv){
"no stale records": func(env *listEnv) {
// A snapshot present both locally and remotely: nothing to
// remove, which used to print the "No stale" line.
env.addLocal(t, listLocalID, cleanupStart)
env.addRemote(t, listLocalID, cleanupStart)
},
"stale records present": func(env *listEnv) {
env.addLocal(t, cleanupStaleID, cleanupStart)
},
"nothing at all": func(_ *listEnv) {},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
env := newListEnv(t)
seed(env)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{JSON: true})
require.NoError(t, err)
assert.Empty(t, env.stdout.String(),
"stdout carries the --json document and nothing else")
})
}
}
// TestCleanupLocalSnapshots_HumanOutputRetained pins the other half of
// the contract. Without it the test above would be satisfied by
// deleting the three lines outright, and a `vaultik prune` with no
// flags must still say that it removed records from the local index —
// that is the deletion of local state, not decoration.
func TestCleanupLocalSnapshots_HumanOutputRetained(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
t.Run("stale records present", func(t *testing.T) {
t.Parallel()
env := newListEnv(t)
env.addLocal(t, cleanupStaleID, cleanupStart)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{})
require.NoError(t, err)
out := env.stdout.String()
assert.Contains(t, out, "Removing stale local record: "+cleanupStaleID)
assert.Contains(t, out, "Removed 1 stale local snapshot record(s).")
})
t.Run("no stale records", func(t *testing.T) {
t.Parallel()
env := newListEnv(t)
env.addLocal(t, listLocalID, cleanupStart)
env.addRemote(t, listLocalID, cleanupStart)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{})
require.NoError(t, err)
assert.Contains(t, env.stdout.String(),
"No stale local snapshots found.")
})
}
// TestCleanupLocalSnapshots_RemovesOnlyStaleRecords checks that the
// --json gate did not change what the function does, only what it
// says: the stale record is gone from the index and the one with a
// remote manifest is untouched.
func TestCleanupLocalSnapshots_RemovesOnlyStaleRecords(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.addLocal(t, listLocalID, cleanupStart)
env.addRemote(t, listLocalID, cleanupStart)
env.addLocal(t, cleanupStaleID, cleanupStart)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{JSON: true})
require.NoError(t, err)
remaining, err := env.v.Repositories.Snapshots.ListRecent(
env.v.Context(), remainingSnapshotLimit)
require.NoError(t, err)
ids := make([]string, 0, len(remaining))
for _, snap := range remaining {
ids = append(ids, snap.ID.String())
}
assert.Equal(t, []string{listLocalID}, ids,
"only the record with no remote manifest may be removed")
}

View File

@@ -829,15 +829,7 @@ func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
// behind by incomplete or interrupted backups. Each local snapshot's // behind by incomplete or interrupted backups. Each local snapshot's
// human ID is hashed via RemoteSnapshotKey and compared against the // human ID is hashed via RemoteSnapshotKey and compared against the
// remote listing. // remote listing.
// func (v *Vaultik) CleanupLocalSnapshots() error {
// It takes the whole *PruneOptions, symmetric with PruneBlobs, because
// it is the other half of one command: Prune runs this phase and then
// that one. Only JSON is read here. Under --json every write below is
// suppressed, because stdout carries the PruneBlobsResult document and
// nothing else — prose ahead of it is what made `vaultik prune --json |
// jq` fail (issue #108). The narration is duplicated as log records,
// which go to stderr and so cannot corrupt the document.
func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
err := v.EnsureStorageBinding() err := v.EnsureStorageBinding()
if err != nil { if err != nil {
return err return err
@@ -863,11 +855,7 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
for _, snap := range localSnapshots { for _, snap := range localSnapshots {
id := snap.ID.String() id := snap.ID.String()
if !remoteSet[snapshot.RemoteSnapshotKey(id)] { if !remoteSet[snapshot.RemoteSnapshotKey(id)] {
log.Info("Removing stale local snapshot record", "snapshot_id", id)
if !opts.JSON {
v.stdoutf("Removing stale local record: %s\n", id) v.stdoutf("Removing stale local record: %s\n", id)
}
err = v.deleteSnapshotFromLocalDB(id) err = v.deleteSnapshotFromLocalDB(id)
if err != nil { if err != nil {
@@ -881,13 +869,6 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
} }
} }
log.Info("Reconciled local snapshot records against remote metadata",
"removed", removed, "examined", len(localSnapshots))
if opts.JSON {
return nil
}
if removed == 0 { if removed == 0 {
v.printlnStdout("No stale local snapshots found.") v.printlnStdout("No stale local snapshots found.")
} else { } else {

View File

@@ -87,7 +87,7 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
snapshots = append(snapshots, info) snapshots = append(snapshots, info)
} }
listing, remoteErr := v.collectRemoteSnapshots(localKeys) listing, remoteErr := v.collectRemoteSnapshots(localKeys, jsonOutput)
if remoteErr != nil { if remoteErr != nil {
v.warnRemoteListingFailed(remoteErr, jsonOutput) v.warnRemoteListingFailed(remoteErr, jsonOutput)
} else { } else {
@@ -131,23 +131,23 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
// still worth printing, and `snapshot list` exiting non-zero because a // still worth printing, and `snapshot list` exiting non-zero because a
// volume is unmounted would be worse than useless. // volume is unmounted would be worse than useless.
// //
// The two output modes report it through different channels. Table mode // In --json mode the warning goes to stderr rather than through the
// uses the UI writer, whose prose and color match the table it sits // logger or the UI writer, both of which emit on stdout — the JSON
// under. The UI writer emits on stdout, though, so --json mode uses the // document has to be the only thing on stdout for `snapshot list --json
// logger instead: stdout has to hold nothing but the JSON document for // | jq` to work. The failure is also representable in the document
// `snapshot list --json | jq` to work. Both channels are chosen once, // itself: every row's remote_present is null when the destination could
// never both, so the user is not told the same thing twice. // not be listed.
//
// The failure is also representable in the document itself: every row's
// remote_present is null when the destination could not be listed.
func (v *Vaultik) warnRemoteListingFailed(err error, jsonOutput bool) { func (v *Vaultik) warnRemoteListingFailed(err error, jsonOutput bool) {
if jsonOutput { if jsonOutput {
log.Warn("Could not list backup destination store; "+ _, _ = fmt.Fprintf(v.Stderr,
"showing snapshots from the local index only", "error", err) "Warning: could not list backup destination store: %v. "+
"Showing snapshots from the local index only.\n", err)
return return
} }
// Once only: the logger also writes to stdout, so emitting through
// both it and the UI would print the same sentence to the user twice.
v.UI.Warningf("Could not list backup destination store: %v.", err) v.UI.Warningf("Could not list backup destination store: %v.", err)
v.UI.Infof("Showing snapshots from the local index only.") v.UI.Infof("Showing snapshots from the local index only.")
} }
@@ -156,29 +156,69 @@ func (v *Vaultik) warnRemoteListingFailed(err error, jsonOutput bool) {
// is about to read is incomplete: manifests that could not be read, and // is about to read is incomplete: manifests that could not be read, and
// remote-only snapshots dropped by the maxRemoteOnlyRows cap. // remote-only snapshots dropped by the maxRemoteOnlyRows cap.
// //
// Table mode reports both below the table (see reportListDrift) through // Table mode reports both below the table (see reportListDrift). In
// the UI writer, which emits on stdout. In --json mode stdout has to // --json mode they cannot go on stdout — the document has to be the
// hold nothing but the document for `snapshot list --json | jq` to // only thing there for `snapshot list --json | jq` to work — and the
// work, and the document's shape is deliberately left alone so existing // document's shape is deliberately left alone so existing consumers
// consumers keep parsing — so these go to the logger, which writes to // keep parsing. So they go to stderr, the same place the
// stderr. A consumer that must react to truncation can treat any output // unreachable-destination warning already goes. A consumer that must
// on that stream as "this listing is not the whole picture"; silent // react to truncation can treat any output on this stream as "this
// truncation of a listing whose whole purpose is disaster recovery is // listing is not the whole picture"; silent truncation of a listing
// the worse failure. // whose whole purpose is disaster recovery is the worse failure.
func (v *Vaultik) reportJSONListingLimits(listing *remoteSnapshotListing) { func (v *Vaultik) reportJSONListingLimits(listing *remoteSnapshotListing) {
if listing.unreadable > 0 { if listing.unreadable > 0 {
log.Warn("Some remote snapshot(s) could not be described: "+ _, _ = fmt.Fprintf(v.Stderr,
"manifest missing or unreadable; they are missing from "+ "Warning: %d remote snapshot(s) could not be described: "+
"this listing", "unreadable", listing.unreadable) "manifest missing or unreadable. They are missing from "+
"this listing.\n", listing.unreadable)
} }
if listing.omitted > 0 { if listing.omitted > 0 {
log.Warn("Listing truncated: further remote-only snapshot(s) "+ _, _ = fmt.Fprintf(v.Stderr,
"not shown", "omitted", listing.omitted, "Warning: listing truncated: %d further remote-only "+
"limit", maxRemoteOnlyRows) "snapshot(s) not shown (limit %d per listing).\n",
listing.omitted, maxRemoteOnlyRows)
} }
} }
// kvPairSize is the number of variadic arguments that make up one
// structured logging key/value pair.
const kvPairSize = 2
// warnWhileListing reports a per-snapshot problem found while
// describing the destination store, through a writer that is safe for
// the current output mode.
//
// In --json mode it writes to v.Stderr rather than calling log.Warn,
// for the same reason warnRemoteListingFailed does: internal/log builds
// its logger over os.Stdout and defaults to level Warn, so one warning
// there would put a log line on stdout ahead of the JSON document and
// break `snapshot list --json | jq`. A single corrupt manifest is
// precisely the degradation this listing is built to survive, so it
// must not be the thing that corrupts the output.
//
// This is a local workaround. Remove it, and the branch in
// warnRemoteListingFailed, once issue #82 makes the logger's sink
// configurable.
func (v *Vaultik) warnWhileListing(jsonOutput bool, msg string, args ...any) {
if !jsonOutput {
log.Warn(msg, args...)
return
}
var line strings.Builder
_, _ = fmt.Fprintf(&line, "Warning: %s", msg)
for i := 0; i+kvPairSize <= len(args); i += kvPairSize {
pair := args[i : i+kvPairSize]
_, _ = fmt.Fprintf(&line, " %v=%v", pair[0], pair[1])
}
_, _ = fmt.Fprintln(v.Stderr, line.String())
}
// remoteSnapshotListing is the result of one pass over the destination // remoteSnapshotListing is the result of one pass over the destination
// store's metadata/ prefix. // store's metadata/ prefix.
type remoteSnapshotListing struct { type remoteSnapshotListing struct {
@@ -207,8 +247,11 @@ type remoteSnapshotListing struct {
// Manifest reads scale only with the number of snapshots the local // Manifest reads scale only with the number of snapshots the local
// index does not already know about, and are capped at // index does not already know about, and are capped at
// maxRemoteOnlyRows. // maxRemoteOnlyRows.
//
// jsonOutput only selects where per-snapshot warnings are written; see
// warnWhileListing.
func (v *Vaultik) collectRemoteSnapshots( func (v *Vaultik) collectRemoteSnapshots(
localKeys map[string]bool, localKeys map[string]bool, jsonOutput bool,
) (*remoteSnapshotListing, error) { ) (*remoteSnapshotListing, error) {
keys, err := v.listAllRemoteSnapshotKeys() keys, err := v.listAllRemoteSnapshotKeys()
if err != nil { if err != nil {
@@ -239,22 +282,17 @@ func (v *Vaultik) collectRemoteSnapshots(
} }
listing.remoteOnly, listing.unreadable = v.describeRemoteOnlySnapshots( listing.remoteOnly, listing.unreadable = v.describeRemoteOnlySnapshots(
unknown) unknown, jsonOutput)
return listing, nil return listing, nil
} }
// listingWarning is a problem found with one remote snapshot, recorded // listingWarning is a problem found with one remote snapshot, recorded
// rather than emitted on the spot. Manifest reads run concurrently, so // rather than emitted on the spot. Manifest reads run concurrently and
// emitting from the worker that found the problem would order the // the writer chosen by warnWhileListing is not guaranteed to be safe
// warnings by fetch completion — which varies run to run with network // for concurrent use, so warnings are held until every read has
// timing and tells the reader nothing. Holding them and emitting in key // finished and then emitted in key order from a single goroutine. That
// order from a single goroutine after every read has finished makes two // also makes the warning order deterministic run to run.
// runs over the same damaged store produce the same diagnostics in the
// same order.
//
// Concurrency safety is no longer part of the reason: these are emitted
// through log.Warn, and slog handlers are safe for concurrent use.
type listingWarning struct { type listingWarning struct {
msg string msg string
args []any args []any
@@ -268,7 +306,7 @@ type listingWarning struct {
// failing the listing: one bad snapshot directory must not hide every // failing the listing: one bad snapshot directory must not hide every
// other snapshot the user has. // other snapshot the user has.
func (v *Vaultik) describeRemoteOnlySnapshots( func (v *Vaultik) describeRemoteOnlySnapshots(
keys []string, keys []string, jsonOutput bool,
) ([]SnapshotInfo, int) { ) ([]SnapshotInfo, int) {
found := make([]SnapshotInfo, len(keys)) found := make([]SnapshotInfo, len(keys))
ok := make([]bool, len(keys)) ok := make([]bool, len(keys))
@@ -311,7 +349,7 @@ func (v *Vaultik) describeRemoteOnlySnapshots(
for i := range keys { for i := range keys {
if warnings[i] != nil { if warnings[i] != nil {
log.Warn(warnings[i].msg, warnings[i].args...) v.warnWhileListing(jsonOutput, warnings[i].msg, warnings[i].args...)
} }
if !ok[i] { if !ok[i] {

View File

@@ -108,6 +108,7 @@ type listEnv struct {
v *vaultik.Vaultik v *vaultik.Vaultik
store *observingStorer store *observingStorer
stdout *bytes.Buffer stdout *bytes.Buffer
stderr *bytes.Buffer
} }
func newListEnv(t *testing.T) *listEnv { func newListEnv(t *testing.T) *listEnv {
@@ -121,6 +122,7 @@ func newListEnv(t *testing.T) *listEnv {
store := newObservingStorer() store := newObservingStorer()
stdout := &bytes.Buffer{} stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
v := &vaultik.Vaultik{ v := &vaultik.Vaultik{
Config: &config.Config{ Config: &config.Config{
@@ -133,13 +135,13 @@ func newListEnv(t *testing.T) *listEnv {
Repositories: database.NewRepositories(db), Repositories: database.NewRepositories(db),
DB: db, DB: db,
Stdout: stdout, Stdout: stdout,
Stderr: &bytes.Buffer{}, Stderr: stderr,
Stdin: &bytes.Buffer{}, Stdin: &bytes.Buffer{},
UI: ui.NewWithColor(stdout, false), UI: ui.NewWithColor(stdout, false),
} }
v.SetContext(ctx) v.SetContext(ctx)
return &listEnv{v: v, store: store, stdout: stdout} return &listEnv{v: v, store: store, stdout: stdout, stderr: stderr}
} }
// addLocal inserts a completed snapshot into the local index. // addLocal inserts a completed snapshot into the local index.
@@ -519,16 +521,16 @@ func TestListSnapshots_JSONMergedView(t *testing.T) {
// TestListSnapshots_JSONUnreachableRemote checks that a failed listing // TestListSnapshots_JSONUnreachableRemote checks that a failed listing
// does not corrupt the JSON document with warning text, and that // does not corrupt the JSON document with warning text, and that
// "unknown" is reported as null rather than as absence. // "unknown" is reported as null rather than as absence.
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONUnreachableRemote(t *testing.T) { func TestListSnapshots_JSONUnreachableRemote(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t) env := newListEnv(t)
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)) env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
env.store.listErr = errRemoteUnreachable env.store.listErr = errRemoteUnreachable
stderr := captureProcessStderr(t, func() { err := env.v.ListSnapshots(true)
require.NoError(t, env.v.ListSnapshots(true)) require.NoError(t, err)
})
// stdout must be nothing but the JSON document, so the warning has // stdout must be nothing but the JSON document, so the warning has
// to go to stderr. // to go to stderr.
@@ -540,8 +542,9 @@ func TestListSnapshots_JSONUnreachableRemote(t *testing.T) {
assert.Nil(t, rows[0].RemotePresent, assert.Nil(t, rows[0].RemotePresent,
"remote state is unknown when the destination cannot be listed") "remote state is unknown when the destination cannot be listed")
assert.Contains(t, stderr, "Could not list backup destination store") assert.Contains(t, env.stderr.String(),
assert.Contains(t, stderr, "permission denied") "could not list backup destination store")
assert.Contains(t, env.stderr.String(), "permission denied")
} }
// useNonUTCLocalZone points time.Local at a fixed non-UTC zone for the // useNonUTCLocalZone points time.Local at a fixed non-UTC zone for the
@@ -632,9 +635,10 @@ func TestListSnapshots_TimestampsAreUTCOnNonUTCHost(t *testing.T) {
// machine consumer would otherwise see no difference between "that // machine consumer would otherwise see no difference between "that
// snapshot is not on the destination" and "that snapshot could not be // snapshot is not on the destination" and "that snapshot could not be
// read". // read".
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) { func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t) env := newListEnv(t)
goodKey := env.addRemote(t, listRemoteID, goodKey := env.addRemote(t, listRemoteID,
@@ -646,18 +650,16 @@ func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) {
strings.NewReader("this is not a zstd stream")) strings.NewReader("this is not a zstd stream"))
require.NoError(t, err) require.NoError(t, err)
stderr := captureProcessStderr(t, func() { err = env.v.ListSnapshots(true)
require.NoError(t, env.v.ListSnapshots(true)) require.NoError(t, err)
})
rows := decodeListJSON(t, env.stdout.String()) rows := decodeListJSON(t, env.stdout.String())
require.Len(t, rows, 1) require.Len(t, rows, 1)
assert.Equal(t, goodKey, rows[0].RemoteKey) assert.Equal(t, goodKey, rows[0].RemoteKey)
assert.Contains(t, stderr, "could not be described", assert.Contains(t, env.stderr.String(),
"1 remote snapshot(s) could not be described",
"a row dropped from the JSON document must be announced somewhere") "a row dropped from the JSON document must be announced somewhere")
assert.Contains(t, stderr, `"unreadable":1`,
"the count of dropped rows must be reported, not just the fact")
} }
// maxRemoteOnlyRowsForTest mirrors the maxRemoteOnlyRows cap in the // maxRemoteOnlyRowsForTest mirrors the maxRemoteOnlyRows cap in the
@@ -669,9 +671,10 @@ const maxRemoteOnlyRowsForTest = 1000
// truncation of a listing whose whole purpose is disaster recovery is // truncation of a listing whose whole purpose is disaster recovery is
// the wrong failure mode: the consumer least able to notice is exactly // the wrong failure mode: the consumer least able to notice is exactly
// the one reading JSON. // the one reading JSON.
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONReportsTruncation(t *testing.T) { func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t) env := newListEnv(t)
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC) timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
@@ -681,30 +684,25 @@ func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp) env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
} }
stderr := captureProcessStderr(t, func() { err := env.v.ListSnapshots(true)
require.NoError(t, env.v.ListSnapshots(true)) require.NoError(t, err)
})
rows := decodeListJSON(t, env.stdout.String()) rows := decodeListJSON(t, env.stdout.String())
assert.Len(t, rows, maxRemoteOnlyRowsForTest) assert.Len(t, rows, maxRemoteOnlyRowsForTest)
assert.Contains(t, stderr, "Listing truncated") assert.Contains(t, env.stderr.String(), "listing truncated")
assert.Contains(t, stderr, `"omitted":1`) assert.Contains(t, env.stderr.String(), "1 further remote-only")
assert.Contains(t, stderr,
fmt.Sprintf(`"limit":%d`, maxRemoteOnlyRowsForTest))
} }
// captureProcessStdout redirects the process's own stdout to a pipe, // captureProcessStdout redirects the process's own stdout to a pipe,
// rebuilds the global logger, runs fn, and returns everything written to // rebuilds the global logger over it, runs fn, and returns everything
// the pipe. // written.
// //
// The logger is rebuilt on purpose even though it is supposed to write // internal/log builds its logger over os.Stdout at construction time and
// to stderr: that is exactly what makes this a regression guard. If the // offers no injectable sink (issue #82), so a warning logged during a
// logger ever goes back to os.Stdout, Initialize picks up the pipe and // --json listing lands on the process's real stdout, not on any writer a
// the log record shows up in the capture, breaking the JSON parse here // test can inject. Capturing the file descriptor is therefore the only
// the same way it would break `snapshot list --json | jq` in the field. // way a test can see what `snapshot list --json | jq` would see.
// Without the rebuild, a regressed logger would write to the real stdout
// the test process was started with and go unnoticed.
// //
// Not parallel-safe: os.Stdout and the logger are process-global. // Not parallel-safe: os.Stdout and the logger are process-global.
func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string { func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
@@ -716,6 +714,8 @@ func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
previous := os.Stdout previous := os.Stdout
os.Stdout = writer os.Stdout = writer
// Rebuild the logger so it writes to the pipe rather than to the
// real stdout the test process was started with.
log.Initialize(log.Config{}) log.Initialize(log.Config{})
drained := make(chan string, 1) drained := make(chan string, 1)
@@ -738,57 +738,7 @@ func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
require.NoError(t, reader.Close()) require.NoError(t, reader.Close())
// Put the logger back on the restored streams. // Put the logger back on the restored stdout.
log.Initialize(log.Config{})
return captured
}
// captureProcessStderr redirects the process's own stderr to a pipe,
// rebuilds the global logger over it, runs fn, and returns everything
// written.
//
// internal/log writes every diagnostic to os.Stderr and captures that
// file at Initialize time, so a warning logged during a listing lands on
// the process's real stderr, not on any writer a test can inject.
// Capturing the file descriptor is therefore the only way a test can see
// what the operator would see. The captured stream is a pipe rather than
// a terminal, so the records are JSON — the same form a redirected
// stderr gets in production.
//
// Not parallel-safe: os.Stderr and the logger are process-global.
func captureProcessStderr(t *testing.T, fn func()) string {
t.Helper()
reader, writer, err := os.Pipe()
require.NoError(t, err)
previous := os.Stderr
os.Stderr = writer
log.Initialize(log.Config{})
drained := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, reader)
drained <- buf.String()
}()
fn()
os.Stderr = previous
require.NoError(t, writer.Close())
captured := <-drained
require.NoError(t, reader.Close())
// Put the logger back on the restored streams.
log.Initialize(log.Config{}) log.Initialize(log.Config{})
return captured return captured
@@ -797,18 +747,13 @@ func captureProcessStderr(t *testing.T, fn func()) string {
// TestListSnapshots_JSONStdoutIsOnlyTheDocument is the regression guard // TestListSnapshots_JSONStdoutIsOnlyTheDocument is the regression guard
// for `snapshot list --json | jq` surviving a damaged destination store. // for `snapshot list --json | jq` surviving a damaged destination store.
// //
// Every stdout writer the command has — the JSON encoder and the UI // Every stdout writer the command has — the JSON encoder, the UI, and
// is pointed at one pipe here, exactly as they are pointed at one file // the global logger — is pointed at one pipe here, exactly as they are
// descriptor in production, and the logger is rebuilt over that same // pointed at one file descriptor in production. A single log line about
// pipe's process-level stdout so that a logger which regressed back to // a corrupt manifest ahead of the array is enough to break the parse,
// stdout would land in the capture. A single log line about a corrupt // and that is what this asserts cannot happen.
// manifest ahead of the array is enough to break the parse, and that is
// what this asserts cannot happen.
// //
// The two warnings are asserted on the separately captured stderr: they //nolint:paralleltest // replaces os.Stdout and the global logger
// must be emitted, just not there.
//
//nolint:paralleltest // replaces os.Stdout, os.Stderr and the logger
func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) { func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
env := newListEnv(t) env := newListEnv(t)
@@ -827,16 +772,12 @@ func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
oddKey := env.addRemoteRawTimestamp(t, oddKey := env.addRemoteRawTimestamp(t,
"testhost_odd_2026-03-04T00:00:00Z", "the day before yesterday") "testhost_odd_2026-03-04T00:00:00Z", "the day before yesterday")
var captured string captured := captureProcessStdout(t, func(stdout io.Writer) {
stderr := captureProcessStderr(t, func() {
captured = captureProcessStdout(t, func(stdout io.Writer) {
env.v.Stdout = stdout env.v.Stdout = stdout
env.v.UI = ui.NewWithColor(stdout, false) env.v.UI = ui.NewWithColor(stdout, false)
require.NoError(t, env.v.ListSnapshots(true)) require.NoError(t, env.v.ListSnapshots(true))
}) })
})
rows := decodeListJSON(t, captured) rows := decodeListJSON(t, captured)
require.Len(t, rows, 2, "the readable snapshots must both be listed") require.Len(t, rows, 2, "the readable snapshots must both be listed")
@@ -853,8 +794,8 @@ func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
// Both warnings were emitted, on the stream that cannot corrupt the // Both warnings were emitted, on the stream that cannot corrupt the
// document. // document.
stderr := env.stderr.String()
assert.Contains(t, stderr, "Could not describe remote snapshot") assert.Contains(t, stderr, "Could not describe remote snapshot")
assert.Contains(t, stderr, "Remote manifest has an unparseable timestamp") assert.Contains(t, stderr, "Remote manifest has an unparseable timestamp")
assert.Contains(t, stderr, "could not be described") assert.Contains(t, stderr, "1 remote snapshot(s) could not be described")
assert.Contains(t, stderr, `"unreadable":1`)
} }

View File

@@ -43,15 +43,7 @@ type Vaultik struct {
ctx context.Context //nolint:containedctx // ctx bound at construction by design ctx context.Context //nolint:containedctx // ctx bound at construction by design
cancel context.CancelFunc cancel context.CancelFunc
// IO. Stdout carries the output the user asked for and nothing else, // IO
// so that `--json | jq` works. Stderr completes the standard triple
// for anything a command needs to write there directly; diagnostics
// are not that — they go through internal/log, which writes to the
// process's stderr. No production code writes to Stderr today, so
// searching for its writers turns up nothing; it is kept as the
// injection point a direct stderr write would otherwise have to
// invent, and removing it would make the triple asymmetric for no
// gain.
Stdout io.Writer Stdout io.Writer
Stderr io.Writer Stderr io.Writer
Stdin io.Reader Stdin io.Reader

View File

@@ -48,52 +48,6 @@ missing() {
! command -v "$1" >/dev/null 2>&1 ! command -v "$1" >/dev/null 2>&1
} }
# Docker is a hard requirement, not a nice-to-have: script/lint runs the
# digest-pinned golangci-lint image from the Dockerfile's lint stage, and
# script/check and script/precommit both run script/lint. A bootstrap
# that prints "bootstrap complete" on a machine where `make check` cannot
# run is a false success, so this fails instead.
#
# Installing docker from here was considered and rejected: it needs root,
# a running daemon, and on macOS a GUI cask, so an attempt would itself
# fail in the common case - trading one false success for a second
# failure mode. Naming exactly what breaks is more useful.
# Prints the problem and returns 0 when docker cannot be used; returns
# 1 (and prints nothing) when it can.
docker_problem() {
if missing docker; then
echo "docker is not installed"
return 0
fi
if ! docker info >/dev/null 2>&1; then
echo "the docker daemon is not reachable"
return 0
fi
return 1
}
require_docker() {
reason="$(docker_problem)" || return 0
cat >&2 <<EOF
bootstrap: FAILED - $reason.
Docker is required to develop this repo. Without it these do not work:
script/lint runs the digest-pinned golangci-lint image declared
by the Dockerfile's lint stage, which is the single
source of truth for the linter version
script/check runs script/lint
script/precommit runs script/check, so commits are blocked by the
pre-commit hook installed by script/setup
script/cibuild builds the Dockerfile, which is what CI runs
Install docker (and start the daemon, checking DOCKER_HOST and your
group membership), then re-run script/bootstrap. golangci-lint on PATH
is deliberately not a substitute: script/lint will not use it.
EOF
exit 1
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -107,27 +61,17 @@ main() {
# golangci-lint is deliberately NOT installed: script/lint runs the # golangci-lint is deliberately NOT installed: script/lint runs the
# digest-pinned golangci-lint image from the Dockerfile's lint stage, # digest-pinned golangci-lint image from the Dockerfile's lint stage,
# so whatever a package manager happens to ship would only be a # so whatever a package manager happens to ship would only be a
# shadow of the pinned version that could drift from CI. script/lint # shadow of the pinned version that could drift from CI.
# will not use a PATH binary on a host at any version, so installing if missing docker; then
# one here would buy nothing. echo "bootstrap: docker not found; script/lint needs it to run" >&2
echo "bootstrap: the pinned linter (see the Dockerfile lint stage)" >&2
fi
# sqlite3 CLI: the test suite shells out to it (VACUUM). # sqlite3 CLI: the test suite shells out to it (VACUUM).
if missing sqlite3; then pkg_install sqlite sqlite3 sqlite sqlite; fi 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 go mod download
# Last, so that everything installable is installed before the one
# thing this script cannot install decides the outcome.
require_docker
echo "bootstrap complete" echo "bootstrap complete"
} }

View File

@@ -1,9 +1,6 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile does not run # script/cibuild: run the CI build. The Dockerfile runs script/check
# script/check; it runs `make fmt-check` and `make lint` in its lint # (via make check), so a successful build implies all checks pass.
# stage and `make test` in its builder stage. A successful build
# implies those three passed, provided they actually ran -- which is
# what the CHECK_EPOCH below is for.
# Generic: needs no adaptation. The Gitea workflow runs this on push. # Generic: needs no adaptation. The Gitea workflow runs this on push.
set -eu set -eu
@@ -11,32 +8,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# The Dockerfile's check layers are keyed on CHECK_EPOCH, so a # CHECK_EPOCH changes on every invocation, which invalidates the
# fresh value here is what forces them to re-run: without it an # Dockerfile layers that run the checks. Without it an unchanged
# unchanged tree replays them from cache, the checks never execute, # tree replays those layers from cache, the checks never execute,
# and the build still exits 0. The ARG sits immediately above the # and the build still exits 0. The ARG sits immediately above the
# check RUNs, so dependency and module layers still cache. The # check RUNs, so dependency and module layers still cache.
# Dockerfile also refuses to build at all when CHECK_EPOCH is empty, docker build --build-arg CHECK_EPOCH="$(date +%s)" .
# so a missing value fails loudly here rather than passing quietly.
#
# The value must be unique per invocation, not per second. `date +%s`
# is second-granular, so two concurrent invocations in the same
# second get identical epochs and the later one can be served from
# cache -- the original defect in miniature. `%N` alone does not fix
# it: busybox silently drops %N, exits 0, and hands back second
# granularity with no warning. `$$` is what makes this correct
# regardless, since concurrent invocations have different pids.
#
# Assign the epoch on its own line rather than inline in the
# argument. Under `set -eu` a command substitution that fails
# inside an argument does NOT abort the script: CHECK_EPOCH would
# become an empty string, an empty string is a constant, and a
# constant CHECK_EPOCH is exactly the cached-check false green this
# script exists to prevent -- so the guard would disarm itself and
# still exit 0. As a bare assignment, `set -e` catches a failing
# `date` and no build starts.
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" .
} }
main "$@" main "$@"

View File

@@ -9,16 +9,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Same CHECK_EPOCH contract as script/cibuild, for the same reason docker build -t "$("$SCRIPT_DIR/projectname")" .
# and with the same bare-assignment and `$$` requirements -- see the
# comments there. This script is not the CI gate, but a local build
# is almost always warm, so without this it would report a green the
# tree had not earned and the two entrypoints would disagree about
# whether the tree is clean. The Dockerfile now refuses to build
# without a non-empty value, so this is required, not optional.
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"

View File

@@ -1,144 +0,0 @@
#!/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 "$@"

View File

@@ -8,24 +8,12 @@
# needs editing. # needs editing.
# #
# Normally that means running the pinned image with docker. The one # Normally that means running the pinned image with docker. The one
# exception is running INSIDE that image: the Dockerfile's lint stage # exception is a golangci-lint on PATH whose version is exactly equal to
# runs `make lint`, and there is no docker daemon in there. That stage # the pin: that is the same linter, so it is run directly. This is what
# sets VAULTIK_LINT_IN_CONTAINER=1, and only when that variable is set # happens inside the lint container itself (Dockerfile runs `make lint`,
# is a golangci-lint on PATH used directly - and then only if its # and there is no docker daemon in there). A PATH binary at any other
# version is exactly the pin. Version equality alone is deliberately NOT # version is never used - that silent substitution is the bug this
# enough: it also matches a developer's locally installed copy of the # script exists to prevent.
# same version, which is a different build with a different Go
# toolchain, reached by a different code path, and it would bypass the
# digest pin this script exists to enforce. /.dockerenv was considered
# as the context signal and rejected: dockerd creates it for `docker
# run`, but it is not reliably present during a BuildKit `docker build`,
# which is exactly the case the exception exists for.
#
# The linter's output is checked before it is believed: every run is
# audited by script/lint-audit for findings that cannot belong to this
# tree, and a run refused by golangci-lint's cross-process lock is
# retried rather than reported as a verdict. See the lock-retry loop in
# main and the header of script/lint-audit.
# #
# Extra arguments are passed through to `golangci-lint run`, before # Extra arguments are passed through to `golangci-lint run`, before
# `./...` (see script/lint-fix). # `./...` (see script/lint-fix).
@@ -34,15 +22,6 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
DOCKERFILE="$ROOT/Dockerfile" DOCKERFILE="$ROOT/Dockerfile"
# golangci-lint takes a cross-process lock and refuses to start while
# another instance holds it. That refusal is not a lint result, and
# exiting non-zero on it is indistinguishable to a caller from real
# findings - so it is retried rather than reported. Bounded, because a
# lock that is never released must fail rather than hang.
LOCK_MESSAGE="parallel golangci-lint is running"
LOCK_ATTEMPTS=6
LOCK_SLEEP=15
# The image reference of the Dockerfile's lint stage, tag and digest # The image reference of the Dockerfile's lint stage, tag and digest
# included, e.g. # included, e.g.
# golangci/golangci-lint:v2.12.2-alpine@sha256:91b2... # golangci/golangci-lint:v2.12.2-alpine@sha256:91b2...
@@ -57,24 +36,8 @@ pinned_version() {
} }
# The version of the golangci-lint on PATH, if any, e.g. 2.12.2 # The version of the golangci-lint on PATH, if any, e.g. 2.12.2
#
# `version --short` prints the bare version and is the interface meant
# for this (checked against 2.10.1 and 2.12.2). The banner scrape below
# it is a fallback for a release where --short is absent or silent; the
# banner's exact wording is not a stable interface, which is why it is
# no longer the primary parse.
installed_version() { installed_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0 command -v golangci-lint >/dev/null 2>&1 || return 0
short="$(golangci-lint version --short 2>/dev/null |
tr -d '[:space:]' | sed -e 's/^v//')"
case "$short" in
*[0-9].[0-9]*.[0-9]*)
echo "$short"
return 0
;;
esac
golangci-lint version 2>/dev/null | awk ' golangci-lint version 2>/dev/null | awk '
{ {
for (i = 1; i <= NF; i++) { for (i = 1; i <= NF; i++) {
@@ -86,13 +49,6 @@ installed_version() {
}' }'
} }
# True inside the Dockerfile's lint stage, which sets this. Nothing else
# sets it: setting it by hand on a host is an explicit, visible decision
# to lint with an unpinned binary, not something reached by accident.
in_lint_container() {
[ "${VAULTIK_LINT_IN_CONTAINER:-}" = "1" ]
}
require_docker() { require_docker() {
image="$1" image="$1"
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
@@ -101,10 +57,9 @@ lint: docker is required to run the pinned linter.
pinned image: $image pinned image: $image
Install docker. Linting with any other golangci-lint is not supported: Install docker, or install golangci-lint $(pinned_version) on PATH.
it is what lets a local run pass while CI fails. An installed Linting with any other version is not supported: it is what lets a
golangci-lint on PATH is not used, whatever its version; only the lint local run pass while CI fails.
stage of the Dockerfile itself runs the linter natively.
EOF EOF
exit 1 exit 1
fi fi
@@ -115,102 +70,30 @@ run.
pinned image: $image pinned image: $image
Start the daemon (and check DOCKER_HOST / your group membership). This Start the daemon (and check DOCKER_HOST / your group membership), or
script will not fall back to a different linter version or to an install golangci-lint $(pinned_version) on PATH. This script will not
unpinned binary on PATH. fall back to a different linter version.
EOF EOF
exit 1 exit 1
fi fi
} }
# Where the per-worktree caches live. # Caches for the containerized linter. Keeping them out of the repo and
cache_home() { # persisting them between runs is what keeps the inner loop fast: a warm
# run costs about the same as a native one plus container startup.
cache_root() {
echo "${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/vaultik-lint" echo "${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/vaultik-lint"
} }
# A short, stable digest of this worktree's path. run_in_docker() {
path_digest() { image="$1"
if command -v sha256sum >/dev/null 2>&1; then shift
printf '%s' "$ROOT" | sha256sum | cut -c1-12 require_docker "$image"
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$ROOT" | shasum -a 256 | cut -c1-12
else
printf '%s' "$ROOT" | cksum | tr -cd '0-9' | cut -c1-12
fi
}
# Caches for the containerized linter, private to THIS worktree. cache="$(cache_root)"
#
# Keeping them out of the repo and persisting them between runs is what
# keeps the inner loop fast: a warm run costs about the same as a native
# one plus container startup. Keeping them keyed on the worktree path is
# what keeps them correct. One shared cache for the whole repo was the
# defect in issue #99: two worktrees of this repo have identical file
# contents, so their cache keys collide, and golangci-lint replays the
# stored results - including the file paths recorded when they were
# produced. That silently reports one worktree's findings, or one
# worktree's clean bill of health, for another.
cache_dir() {
slug="$(printf '%s' "$(basename "$ROOT")" | tr -c 'A-Za-z0-9._-' '-')"
echo "$(cache_home)/$slug-$(path_digest)"
}
# One cache per worktree means throwaway worktrees would otherwise leave
# caches behind forever. Each cache records the worktree it belongs to,
# and any cache whose worktree no longer exists is collected here, so
# growth is bounded by the number of worktrees that actually exist. The
# whole tree also sits under XDG_CACHE_HOME (~/.cache by default), so it
# is disposable by definition: `rm -rf "${XDG_CACHE_HOME:-~/.cache}/vaultik-lint"`
# costs nothing but the next run's cold cache.
prune_dead_caches() {
home="$(cache_home)"
if [ ! -d "$home" ]; then
return 0
fi
for dir in "$home"/*; do
if [ ! -f "$dir/worktree" ]; then
continue
fi
owner="$(cat "$dir/worktree")"
if [ -z "$owner" ]; then
continue
fi
if [ ! -d "$owner" ]; then
# The Go module cache inside is deliberately read-only, and
# rm(1) cannot unlink a file out of a directory it may not
# write, so the tree has to be made writable first. And
# failing to tidy up is a housekeeping problem, never a
# reason to fail a lint: without the fallback below, `set
# -e` turns a stale cache that will not delete into a lint
# error, which is a gate failing for a reason that has
# nothing to do with the code. (Observed, not theorised.)
chmod -R u+w "$dir" 2>/dev/null || true
if ! rm -rf "$dir" 2>/dev/null; then
# Restore the marker on a partial removal: an
# unmarked leftover would be skipped by every future
# run and never collected.
mkdir -p "$dir" 2>/dev/null || true
echo "$owner" >"$dir/worktree" 2>/dev/null || true
echo "lint: could not remove stale cache $dir" >&2
fi
fi
done
}
prepare_cache() {
cache="$1"
mkdir -p "$cache/go-build" "$cache/go-mod" "$cache/golangci-lint" mkdir -p "$cache/go-build" "$cache/go-mod" "$cache/golangci-lint"
echo "$ROOT" >"$cache/worktree"
}
# Run the linter, wherever it is that this script is allowed to run it. exec docker run --rm \
run_linter() {
if in_lint_container; then
golangci-lint run "$@" ./...
return $?
fi
docker run --rm \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
--env HOME=/tmp \ --env HOME=/tmp \
--env GOFLAGS=-buildvcs=false \ --env GOFLAGS=-buildvcs=false \
@@ -218,118 +101,26 @@ run_linter() {
--env GOMODCACHE=/cache/go-mod \ --env GOMODCACHE=/cache/go-mod \
--env GOLANGCI_LINT_CACHE=/cache/golangci-lint \ --env GOLANGCI_LINT_CACHE=/cache/golangci-lint \
--volume "$ROOT:/src" \ --volume "$ROOT:/src" \
--volume "$CACHE:/cache" \ --volume "$cache:/cache" \
--workdir /src \ --workdir /src \
"$IMAGE" \ "$image" \
golangci-lint run "$@" ./... golangci-lint run "$@" ./...
} }
# Run the linter, streaming its combined output while also capturing it,
# and hand back its exit status. The output has to be inspected before
# it is believed, which is why this script no longer just execs the
# linter. `tee` would swallow the status, so it is smuggled out through
# a file: there is no pipefail in POSIX sh.
run_capture() {
capture="$1"
shift
rm -f "$capture.status"
{
rc=0
# `set -e` is in force inside this subshell too, so the status
# has to be caught here: an unguarded non-zero exit (which is
# what "the linter found something" looks like) would abort the
# subshell before the status was ever written.
run_linter "$@" 2>&1 || rc=$?
echo "$rc" >"$capture.status"
} | tee "$capture"
if [ ! -s "$capture.status" ]; then
echo "lint: the linter did not report an exit status" >&2
exit 1
fi
read -r captured_status <"$capture.status"
rm -f "$capture.status"
return "$captured_status"
}
# Reject output that cannot describe this tree. See script/lint-audit
# for what that means and why: in short, a finding citing a file that is
# not here means the result being reported was produced somewhere else,
# and a PASS built out of another checkout's analysis is silent (issue
# #99). The audit therefore runs on clean output as well.
audit_output() {
capture="$1"
if ! "$ROOT/script/lint-audit" "$capture"; then
if [ -n "$CACHE" ]; then
echo " this tree's lint cache: $CACHE" >&2
fi
exit 1
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
IMAGE="$(lint_image)" image="$(lint_image)"
if [ -z "$IMAGE" ]; then if [ -z "$image" ]; then
echo "lint: no lint stage found in $DOCKERFILE" >&2 echo "lint: no lint stage found in $DOCKERFILE" >&2
exit 1 exit 1
fi fi
CACHE="" if [ "$(installed_version)" = "$(pinned_version)" ]; then
if in_lint_container; then exec golangci-lint run "$@" ./...
# No docker daemon in here, so there is no fallback: a mismatch
# is a hard error rather than a quiet substitution.
installed="$(installed_version)"
pinned="$(pinned_version)"
if [ -z "$installed" ] || [ "$installed" != "$pinned" ]; then
cat >&2 <<EOF
lint: VAULTIK_LINT_IN_CONTAINER is set, so this is expected to be
running inside the Dockerfile's pinned lint image, but the golangci-lint
on PATH does not match the pin.
pinned: $pinned ($IMAGE)
installed: ${installed:-<none>}
EOF
exit 1
fi
else
require_docker "$IMAGE"
prune_dead_caches
CACHE="$(cache_dir)"
prepare_cache "$CACHE"
fi fi
capture="$(mktemp "${TMPDIR:-/tmp}/vaultik-lint.XXXXXX")" run_in_docker "$image" "$@"
trap 'rm -f "$capture" "$capture.status"' EXIT HUP INT TERM
attempt=1
while :; do
status=0
run_capture "$capture" "$@" || status=$?
if grep -Fq "$LOCK_MESSAGE" "$capture"; then
if [ "$attempt" -lt "$LOCK_ATTEMPTS" ]; then
echo "lint: another golangci-lint holds the lock;" \
"retrying in ${LOCK_SLEEP}s" \
"(attempt $attempt of $LOCK_ATTEMPTS)" >&2
sleep "$LOCK_SLEEP"
attempt=$((attempt + 1))
continue
fi
cat >&2 <<EOF
lint: gave up after $LOCK_ATTEMPTS attempts, each blocked by another
golangci-lint holding the cross-process lock. This is NOT a lint
verdict: the tree was never analysed. Re-run when the other run has
finished.
EOF
exit 1
fi
audit_output "$capture"
exit "$status"
done
} }
main "$@" main "$@"

View File

@@ -1,113 +0,0 @@
#!/bin/sh
# script/lint-audit: audit a captured golangci-lint run for output that
# cannot describe this tree. Called by script/lint on every run; usable
# on its own against any saved lint output.
#
# script/lint-audit <capture-file>
#
# Exits 0 when every finding cites a file in this tree, 1 when any does
# not. It NEVER certifies that a lint run passed - it has no idea
# whether the run found issues, and does not look. It only rejects
# output that is impossible for this tree, which is a different and much
# weaker claim. Do not use it as a gate; use script/lint.
#
# Why this exists (issue #99): golangci-lint caches analysis results,
# and a cache shared between two checkouts of this repo can serve one
# checkout's stored findings for another, file paths included. The
# failure is symmetric and only one direction is loud - a clean tree
# failed by a dirty sibling gets investigated, while a dirty tree passed
# by a clean sibling is silent. This turns the silent direction into a
# hard error, which is why it runs on clean output too.
#
# The primary fix is that script/lint now keys its cache on the worktree
# path so the collision cannot happen. This is the backstop, because a
# backstop that only runs when we already believe things are fine is
# worth more than one more assumption.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Where script/lint bind-mounts the tree inside the pinned image. A
# containerized run that prints absolute paths (`--path-mode abs`)
# prints them under this, so they are this tree's files under another
# name. Note the consequence, and why the cache key rather than this
# check is the real fix: two containerized runs of different checkouts
# both call themselves /src, so contamination between two container
# runs is not distinguishable by path alone.
CONTAINER_ROOT="/src"
usage() {
echo "usage: $(basename "$0") <capture-file>" >&2
exit 2
}
# Every path cited by a finding that is not a file in this tree.
#
# The linter runs with the tree root as its working directory, so a
# legitimate finding cites either a relative path that resolves inside
# the tree or an absolute path under the root. A path that escapes
# (absolute and elsewhere, or with a `..` component) or that names a
# file which is not here describes something this run did not analyse.
foreign_paths() {
capture="$1"
awk -F: '$1 ~ /\.go$/ && $2 ~ /^[0-9]+$/ { print $1 }' "$capture" |
sort -u |
while IFS= read -r path; do
case "$path" in
"$ROOT"/*)
path="${path#"$ROOT"/}"
;;
"$CONTAINER_ROOT"/*)
path="${path#"$CONTAINER_ROOT"/}"
;;
/*)
printf '%s\n' "$path"
continue
;;
../* | */../*)
printf '%s\n' "$path"
continue
;;
esac
if [ ! -e "$ROOT/$path" ]; then
printf '%s\n' "$path"
fi
done
}
main() {
[ "$#" -eq 1 ] || usage
capture="$1"
if [ ! -f "$capture" ]; then
echo "lint-audit: no such capture file: $capture" >&2
exit 2
fi
foreign="$(foreign_paths "$capture")"
if [ -z "$foreign" ]; then
exit 0
fi
cat >&2 <<EOF
lint: REJECTED - the linter reported findings for files that are not in
this tree, so its output does not describe the tree that was linted.
This result is void, whichever way it went: a pass here would be a pass
earned by analysing someone else's code.
tree: $ROOT
Paths reported that are not in this tree:
EOF
printf '%s\n' "$foreign" | sed -e 's/^/ /' >&2
cat >&2 <<EOF
This is the signature of analysis replayed from a cache belonging to
another checkout (issue #99). Clear this tree's lint cache and re-run:
rm -rf "\${XDG_CACHE_HOME:-\$HOME/.cache}/vaultik-lint"
EOF
exit 1
}
main "$@"

View File

@@ -1,92 +0,0 @@
#!/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 "$@"

View File

@@ -1,18 +0,0 @@
#!/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 "$@"

View File

@@ -6,62 +6,11 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# The flags live in one function so the quiet run and the verbose rerun
# below cannot drift apart. A rerun that used different flags would
# diagnose a different program than the one that failed.
#
# -count=1 is the documented way to bypass Go's test result cache, and
# it is not optional here. Without it, a package whose inputs are
# unchanged prints `ok <pkg> (cached)`, and that line is
# indistinguishable -- to every check this repo performs -- from a
# package that actually ran. The whole suite reports its full set of
# `ok` lines in under half a second having executed nothing. That
# matters beyond the local inner loop: the Dockerfile's `RUN make test`
# is forced to re-execute by CHECK_EPOCH, but a GOCACHE baked into an
# earlier image layer survives into the re-executed step, so the step
# can re-run and still do no work. It is applied unconditionally rather
# than only in the containerised path because the pre-commit hook runs
# this same script; a gate that is honest only in CI is dishonest
# exactly where people lean on it most.
#
# -timeout is a hang backstop, not a performance budget: its job is to
# turn a deadlocked test into a stack dump instead of a wedged CI job,
# so it wants to sit far above the slowest legitimate runtime, not just
# above it. It is per test binary and covers test execution only -- the
# clock starts inside testing.M.Run, after compilation and linking, so
# build time is not charged against it. (Measured: a containerised run
# with an empty GOCACHE reports per-package durations within noise of a
# warm host run. A shell `timeout 30 go test ./...` would include
# compilation, but that is a different mechanism from this flag.)
#
# The 120s value DELIBERATELY DIVERGES from REPO_POLICIES.md:192, which
# mandates "Add a 30-second timeout", and from that file's canonical Go
# recipe at :212-214, which uses -timeout 30s. REPO_POLICIES.md is
# org-canonical and cannot be amended from this repo, so the divergence
# is recorded here instead, and issue #101 proposes amending the policy
# text upstream. Do not revert this to 30s without reading #101 first.
#
# Why it diverges: the slowest packages are internal/database and
# internal/vaultik, observed under -race at about 6.4s warm, 8.1s in a
# cold containerised run on a contended host, and 10.2s in an
# independent cold run on this same host. The worst case is not tightly
# characterised -- each fresh measurement has come in above the last --
# which is itself an argument for generous headroom. Against the 10.2s
# observation, 30s is only 2.9x: not a safety margin but a flake
# waiting for a slow day, whose failure mode is a timeout that looks
# like a real defect. 120s leaves about 12x while still bounding a hung
# package -- including the verbose rerun below -- to a few minutes. The
# cost of that choice, also recorded on #101: because of the rerun, a
# hung package pays the timeout twice.
run_tests() {
go test -race -timeout 120s -count=1 "$@" ./...
}
main() { main() {
cd "$ROOT" cd "$ROOT"
run_tests || { go test -race -timeout 30s ./... || {
echo "--- Rerunning with -v for details ---" echo "--- Rerunning with -v for details ---"
run_tests -v go test -race -timeout 30s -v ./...
exit 1 exit 1
} }
} }

View File

@@ -1,73 +0,0 @@
#!/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 "$@"