check / check (push) Successful in 22m37s
Linting and testing become the lint and test phases of the Dockerfile, and the build stage depends on both. Dockerfile.lint, CHECK_EPOCH and the tests that checked them are removed. Every docker build in script/ passes --no-cache, and script/cibuild runs script/bootstrap first, which now fetches apt package lists so a fresh CI runner can install Go. The image takes its version from the VERSION build arg or git describe, and still stamps the commit and its date from .git. The golangci-lint v2.14.0 findings are fixed in the code. The rules in CLAUDE.md move into AGENTS.md. IsDevVersion now counts "unknown", the version script/docker stamps outside a git checkout. Model: opus-5-5
188 lines
5.8 KiB
Go
188 lines
5.8 KiB
Go
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.
|
|
func readMakefile(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return readRepoFile(t, "Makefile")
|
|
}
|
|
|
|
// readRepoFile reads a file by its path relative to the repository
|
|
// root.
|
|
func readRepoFile(t *testing.T, name string) string {
|
|
t.Helper()
|
|
|
|
//nolint:gosec // G304: the path is a constant relative to this repo
|
|
contents, err := os.ReadFile(filepath.Join(repoRoot(t), name))
|
|
require.NoError(t, err)
|
|
|
|
return string(contents)
|
|
}
|
|
|
|
// repoRoot returns the repository root. The test binary runs with its
|
|
// package directory as the working directory, so the root is found by
|
|
// walking up until the module file appears.
|
|
func repoRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
|
|
for {
|
|
_, err = os.Stat(filepath.Join(dir, "go.mod"))
|
|
if err == nil {
|
|
return dir
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
require.NotEqual(t, dir, parent,
|
|
"walked to the filesystem root without finding a go.mod")
|
|
|
|
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
|
|
}
|