From 624c7a03d6274f652d2bbba7a6f707a77b2a3c87 Mon Sep 17 00:00:00 2001 From: bourgois Date: Thu, 16 Jul 2026 10:51:07 +0000 Subject: [PATCH] feat(provenance): machine-derived artifact names + beads/base provenance in gc version (vp-q1ho) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make artifact BASE_REF=/ builds a gc binary named from the ACTUAL build commit (gc---[-dirty]) and refuses the base branch's name when HEAD is not in the base's lineage — the gc-main-20260710-77916fc6c trap. BASE_REF must be a remote-tracking ref so the lineage claim names its remote (origin here is upstream, not the fork). Artifact builds pass -buildvcs=false and inject commit + base stamps via ldflags: verified live that Go's VCS stamping from a linked worktree nested under the repo dir embeds the MAIN checkout's HEAD/dirty state (a worktree build at eb743642c embedded 50e120757), and embeds nothing from a worktree outside it. The target then verifies the binary's self-reported commit and writes the .buildinfo.json manifest beside the artifact. gc version --long/--json now report the linked steveyegge/beads library version and the build-base stamp (or 'unstamped') — three installed gc binaries once linked three different beads libraries while all self-reporting 1.1.1. --- CHANGELOG.md | 23 +++ Makefile | 42 ++++++ cmd/artifactname/main.go | 81 ++++++++++ cmd/gc/cmd_version.go | 64 +++++++- cmd/gc/cmd_version_test.go | 61 ++++++++ docs/reference/cli.md | 4 +- internal/provenance/artifact.go | 192 ++++++++++++++++++++++++ internal/provenance/artifact_test.go | 217 +++++++++++++++++++++++++++ 8 files changed, 678 insertions(+), 6 deletions(-) create mode 100644 cmd/artifactname/main.go create mode 100644 internal/provenance/artifact.go create mode 100644 internal/provenance/artifact_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f12ba2401d..42124b0b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Provenance-correct release artifacts: `make artifact` + provenance in + `gc version` (vp-q1ho).** New `make artifact BASE_REF=/` + builds a gc binary whose filename is machine-derived from the ACTUAL build + commit (`gc---[-dirty]`) and refuses the base + branch's name as the token when HEAD is not in the base's lineage — the + `gc-main-20260710-77916fc6c` trap (filename claimed main + 77916fc6c; the + binary carried neither). BASE_REF must be a remote-tracking ref because a + lineage claim that does not name its remote is unfalsifiable (`origin` + here is the upstream, not the fork). The build passes `-buildvcs=false` + and injects commit + base-lineage stamps via ldflags: Go's own VCS + stamping is untrustworthy from linked worktrees (verified live — nested + under the repo dir it embeds the MAIN checkout's HEAD/dirty state, outside + it embeds nothing). Post-build the target verifies the binary's + self-reported commit against HEAD and writes the `.buildinfo.json` + manifest beside the artifact (`cmd/writebuildmanifest`). `gc version + --long`/`--json` now also report the linked `github.com/steveyegge/beads` + library version and the build-base stamp (`base: + Voxist/main@eb743642c+0-0`, or `unstamped`), so "what exactly is + deployed?" is answerable from the binary itself — three installed gc + binaries once linked three different beads libraries while all + self-reporting the same version string. New `cmd/artifactname` + + `internal/provenance` artifact derivation. + - **L0 pre-heal in `ensure-project-id`: auto-restore canonical project_id from `city.toml [identity_map]` when the DB confirms it but L1 was wiped (vp-cz7o.21).** `gc dolt-state ensure-project-id` now reads a new L0 layer — the diff --git a/Makefile b/Makefile index 3b622c8208..328c91c5bc 100644 --- a/Makefile +++ b/Makefile @@ -104,6 +104,48 @@ ifeq ($(shell uname),Darwin) @scripts/sign-darwin-local.sh $(BUILD_DIR)/$(BINARY) endif +## artifact: build a provenance-named gc artifact (requires BASE_REF=/, e.g. Voxist/main) +## The filename is machine-derived from the ACTUAL build commit (rev-parse +## HEAD); the base branch's name is refused as the token unless HEAD is in +## BASE_REF's lineage (the gc-main-20260710-77916fc6c trap). The binary gets +## its commit and a main.buildBase lineage stamp via ldflags (visible in +## `gc version --long`), a .buildinfo.json manifest beside it, and its +## self-reported commit is verified against HEAD after the build. A dirty +## tree fails unless ALLOW_DIRTY=1, which names the artifact -dirty instead. +## +## Builds pass -buildvcs=false: Go's own VCS stamping is untrustworthy from +## linked worktrees — nested under this repo dir it embeds the MAIN +## checkout's HEAD/dirty state (how a worktree build at eb743642c once +## embedded 50e120757), outside it embeds nothing. The later -X main.commit +## wins over the $(COMMIT) one in $(LDFLAGS) and carries the full sha plus +## an explicit -dirty suffix. +ARTIFACT_DIR ?= $(BUILD_DIR) +.PHONY: artifact +artifact: + @set -e; \ + if [ -z "$(BASE_REF)" ]; then \ + echo "ERROR: BASE_REF is required, e.g. 'make artifact BASE_REF=Voxist/main' — the lineage claim must name the remote (git remote -v)" >&2; \ + exit 2; \ + fi; \ + exports=$$(go run ./cmd/artifactname -repo . -base '$(BASE_REF)' -binary '$(BINARY)' $(if $(ALLOW_DIRTY),-allow-dirty,)) || exit $$?; \ + eval "$$exports"; \ + if [ -z "$$ARTIFACT_NAME" ] || [ -z "$$ARTIFACT_COMMIT_STAMP" ]; then \ + echo "ERROR: artifactname emitted no usable exports" >&2; \ + exit 1; \ + fi; \ + mkdir -p "$(ARTIFACT_DIR)"; \ + echo "building $(ARTIFACT_DIR)/$$ARTIFACT_NAME"; \ + go build -buildvcs=false -ldflags "$(LDFLAGS) -X main.commit=$$ARTIFACT_COMMIT_STAMP -X main.buildBase=$$ARTIFACT_BASE_STAMP" -o "$(ARTIFACT_DIR)/$$ARTIFACT_NAME" ./cmd/gc; \ + if [ "$$(uname)" = "Darwin" ]; then scripts/sign-darwin-local.sh "$(ARTIFACT_DIR)/$$ARTIFACT_NAME"; fi; \ + go run ./cmd/writebuildmanifest -binary "$(ARTIFACT_DIR)/$$ARTIFACT_NAME" -repo "$(CURDIR)"; \ + reported=$$("$(ARTIFACT_DIR)/$$ARTIFACT_NAME" version --json | sed -n 's/.*"commit":"\([^"]*\)".*/\1/p'); \ + if [ "$$reported" != "$$ARTIFACT_COMMIT_STAMP" ]; then \ + echo "ERROR: binary self-reports commit '$$reported', expected '$$ARTIFACT_COMMIT_STAMP' — refusing to trust this artifact" >&2; \ + exit 1; \ + fi; \ + "$(ARTIFACT_DIR)/$$ARTIFACT_NAME" version --long; \ + echo "OK: $(ARTIFACT_DIR)/$$ARTIFACT_NAME (self-reported commit verified == HEAD)" + ## check-self-contained: assert the built gc binary is self-contained (Linux/Nix ICU rpath). ## Only enforced when the Nix/Flox ICU block above fired (_NIX_ICU_DEV set): ## on those hosts a binary without an ICU RUNPATH loads interactively (the diff --git a/cmd/artifactname/main.go b/cmd/artifactname/main.go new file mode 100644 index 0000000000..9f57c3f320 --- /dev/null +++ b/cmd/artifactname/main.go @@ -0,0 +1,81 @@ +// Command artifactname derives the provenance-correct name and base-lineage +// stamp for a gc release artifact. `make artifact` invokes it before +// building so the artifact filename is machine-derived from the ACTUAL +// build commit (git rev-parse HEAD) and the base-branch token is only used +// when HEAD really is in the base's lineage — never from a base/merge ref a +// human happened to have in mind. +// +// The base must be a remote-tracking ref (e.g. Voxist/main): in this repo +// `origin` is the upstream, so an unqualified "main" claim is exactly the +// wrong-remote trap this tool exists to refuse. +// +// Usage: +// +// artifactname -base / [-repo dir] [-binary gc] [-allow-dirty] [-format eval|name] +// +// -format eval (default) prints POSIX-shell assignments for eval in a +// Makefile recipe: +// +// ARTIFACT_NAME='gc-main-20260716-eb743642c' +// ARTIFACT_HEAD_SHA='eb743642c...' +// ARTIFACT_COMMIT_STAMP='eb743642c...' (gains -dirty when the tree is dirty) +// ARTIFACT_BASE_STAMP='Voxist/main@eb743642c+0-0' +// +// -format name prints just the artifact filename. +// +// All facts come from `git -C ` queries, never from the Go +// toolchain's buildvcs stamping — which, from a linked worktree nested +// under the repo directory, records the MAIN checkout's HEAD and dirty +// state instead of the worktree's (and records nothing from a worktree +// outside it). Artifact builds therefore pass -buildvcs=false and inject +// ARTIFACT_COMMIT_STAMP via -X main.commit. +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/gastownhall/gascity/internal/provenance" +) + +func main() { + repo := flag.String("repo", ".", "path of the git repository being built") + base := flag.String("base", "", "remote-tracking base ref the lineage claim is made against, e.g. Voxist/main (required)") + binary := flag.String("binary", "gc", "binary name prefix for the artifact") + allowDirty := flag.Bool("allow-dirty", false, "permit a dirty working tree; the artifact name gains a -dirty suffix instead of failing") + format := flag.String("format", "eval", "output format: eval (shell assignments) or name (filename only)") + flag.Parse() + + if err := run(*repo, *base, *binary, *allowDirty, *format, time.Now().UTC(), os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "artifactname: %v\n", err) //nolint:errcheck // best-effort stderr + os.Exit(1) + } +} + +func run(repo, base, binary string, allowDirty bool, format string, now time.Time, stdout *os.File) error { + if base == "" { + return fmt.Errorf("-base is required (e.g. -base Voxist/main); the lineage claim must name the remote") + } + a, err := provenance.DeriveArtifact(repo, base) + if err != nil { + return err + } + if a.Dirty && !allowDirty { + return fmt.Errorf("working tree of %q is dirty: a binary built now would not correspond to any commit; commit first, or pass -allow-dirty to get an explicit -dirty name", repo) + } + name := a.Name(binary, now) + switch format { + case "name": + fmt.Fprintf(stdout, "%s\n", name) //nolint:errcheck // best-effort stdout + case "eval": + fmt.Fprintf(stdout, "ARTIFACT_NAME=%s\n", provenance.ShellSingleQuote(name)) //nolint:errcheck // best-effort stdout + fmt.Fprintf(stdout, "ARTIFACT_HEAD_SHA=%s\n", provenance.ShellSingleQuote(a.HeadSHA)) //nolint:errcheck // best-effort stdout + fmt.Fprintf(stdout, "ARTIFACT_COMMIT_STAMP=%s\n", provenance.ShellSingleQuote(a.CommitStamp())) //nolint:errcheck // best-effort stdout + fmt.Fprintf(stdout, "ARTIFACT_BASE_STAMP=%s\n", provenance.ShellSingleQuote(a.BaseStamp())) //nolint:errcheck // best-effort stdout + default: + return fmt.Errorf("unknown -format %q (want eval or name)", format) + } + return nil +} diff --git a/cmd/gc/cmd_version.go b/cmd/gc/cmd_version.go index c58be7fb19..95376d0f43 100644 --- a/cmd/gc/cmd_version.go +++ b/cmd/gc/cmd_version.go @@ -10,12 +10,22 @@ import ( "github.com/spf13/cobra" ) +// beadsModulePath is the linked beads library module; three deployed gc +// binaries once linked three different versions of it while all reporting +// the same gc version string, so it is first-class version output now. +const beadsModulePath = "github.com/steveyegge/beads" + // Build metadata — injected via ldflags at build time. // Falls back to VCS info embedded by the Go toolchain (go install, go build). var ( - version = "dev" - commit = "unknown" - date = "unknown" + version = "dev" + commit = "unknown" + date = "unknown" + // buildBase is the fork-base lineage stamp (e.g. + // "Voxist/main@eb743642c+0-0") injected by `make artifact`; empty for + // builds that never proved their lineage. + buildBase = "" + beadsVersion = "unknown" goPseudoVersionSuffixRes = []*regexp.Regexp{ regexp.MustCompile(`^(.*)\.0\.\d{14}-[0-9a-f]{12,}$`), regexp.MustCompile(`^(.*)-0\.\d{14}-[0-9a-f]{12,}$`), @@ -26,6 +36,31 @@ var ( func init() { info, ok := debug.ReadBuildInfo() version, commit, date = resolveBuildMetadata(version, commit, date, ok, info) + beadsVersion = resolveBeadsVersion(ok, info) +} + +// resolveBeadsVersion reports the effective linked beads library version +// from the embedded module info, honoring replace directives (a replaced +// module is what the binary actually runs; a local-path replace has no +// version, so the path itself is the most honest answer). +func resolveBeadsVersion(ok bool, info *debug.BuildInfo) string { + if !ok || info == nil { + return "unknown" + } + for _, dep := range info.Deps { + if dep == nil || dep.Path != beadsModulePath { + continue + } + mod := dep + if dep.Replace != nil { + mod = dep.Replace + } + if mod.Version != "" { + return mod.Version + } + return mod.Path + } + return "unknown" } func resolveBuildMetadata( @@ -92,20 +127,28 @@ func newVersionCmd(stdout, stderr io.Writer) *cobra.Command { Short: "Print gc version", Long: `Print the gc version string. -Use --long to include git commit and build date metadata.`, +Use --long to include git commit, build date, linked beads library, and +build-base lineage metadata (base is "unstamped" for builds not produced +via 'make artifact').`, Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { + base := buildBase + if base == "" { + base = "unstamped" + } if jsonOut { return writeCLIJSONLineOrErr(stdout, stderr, "gc version", versionJSONResult{ SchemaVersion: "1", Version: version, Commit: commit, Date: date, + BeadsVersion: beadsVersion, + BuildBase: base, Long: longOutput, }) } if longOutput { - fmt.Fprintf(stdout, "%s (commit: %s, built: %s)\n", version, commit, date) //nolint:errcheck // best-effort stdout + fmt.Fprintf(stdout, "%s\n", formatLongVersion(version, commit, date, beadsVersion, buildBase)) //nolint:errcheck // best-effort stdout return nil } fmt.Fprintf(stdout, "%s\n", version) //nolint:errcheck // best-effort stdout @@ -117,10 +160,21 @@ Use --long to include git commit and build date metadata.`, return cmd } +// formatLongVersion renders the --long output. An empty base renders as +// "unstamped" — provenance silence must be visible, not blank. +func formatLongVersion(version, commit, date, beads, base string) string { + if base == "" { + base = "unstamped" + } + return fmt.Sprintf("%s (commit: %s, built: %s, beads: %s, base: %s)", version, commit, date, beads, base) +} + type versionJSONResult struct { SchemaVersion string `json:"schema_version"` Version string `json:"version"` Commit string `json:"commit"` Date string `json:"date"` + BeadsVersion string `json:"beads_version"` + BuildBase string `json:"build_base"` Long bool `json:"long"` } diff --git a/cmd/gc/cmd_version_test.go b/cmd/gc/cmd_version_test.go index 1d30ec6633..82440b35e1 100644 --- a/cmd/gc/cmd_version_test.go +++ b/cmd/gc/cmd_version_test.go @@ -42,6 +42,67 @@ func TestResolveBuildMetadataUsesModuleVersion(t *testing.T) { } } +func TestResolveBeadsVersion(t *testing.T) { + beads := "github.com/steveyegge/beads" + tests := []struct { + name string + ok bool + info *debug.BuildInfo + want string + }{ + {name: "no build info", ok: false, info: nil, want: "unknown"}, + {name: "dep absent", ok: true, info: &debug.BuildInfo{}, want: "unknown"}, + { + name: "dep present", + ok: true, + info: &debug.BuildInfo{Deps: []*debug.Module{{Path: beads, Version: "v1.1.0"}}}, + want: "v1.1.0", + }, + { + name: "replace wins", + ok: true, + info: &debug.BuildInfo{Deps: []*debug.Module{{ + Path: beads, + Version: "v1.1.0", + Replace: &debug.Module{Path: beads, Version: "v1.1.1-0.20260704062855-e97839a2e1c0"}, + }}}, + want: "v1.1.1-0.20260704062855-e97839a2e1c0", + }, + { + name: "local dir replace has no version", + ok: true, + info: &debug.BuildInfo{Deps: []*debug.Module{{ + Path: beads, + Version: "v1.1.0", + Replace: &debug.Module{Path: "../beads"}, + }}}, + want: "../beads", + }, + } + for _, tt := range tests { + if got := resolveBeadsVersion(tt.ok, tt.info); got != tt.want { + t.Errorf("%s: resolveBeadsVersion = %q, want %q", tt.name, got, tt.want) + } + } +} + +func TestFormatLongVersion(t *testing.T) { + // Unstamped builds (plain go build / make build) must say so explicitly: + // silence here is how three binaries claiming "1.1.1" hid three + // different beads libraries. + got := formatLongVersion("1.1.1", "50e120757-dirty", "2026-07-07T17:48:08Z", "v1.1.0", "") + want := "1.1.1 (commit: 50e120757-dirty, built: 2026-07-07T17:48:08Z, beads: v1.1.0, base: unstamped)" + if got != want { + t.Errorf("formatLongVersion unstamped = %q, want %q", got, want) + } + + got = formatLongVersion("1.1.1", "eb743642c", "2026-07-16T10:00:00Z", "v1.1.0", "Voxist/main@eb743642c+0-0") + want = "1.1.1 (commit: eb743642c, built: 2026-07-16T10:00:00Z, beads: v1.1.0, base: Voxist/main@eb743642c+0-0)" + if got != want { + t.Errorf("formatLongVersion stamped = %q, want %q", got, want) + } +} + func TestResolveBuildMetadataUsesVCSSettings(t *testing.T) { info := &debug.BuildInfo{ Settings: []debug.BuildSetting{ diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 06ce972e4e..aa5dbac740 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4652,7 +4652,9 @@ gc unregister [path|name] [flags] Print the gc version string. -Use --long to include git commit and build date metadata. +Use --long to include git commit, build date, linked beads library, and +build-base lineage metadata (base is "unstamped" for builds not produced +via 'make artifact'). ``` gc version [flags] diff --git a/internal/provenance/artifact.go b/internal/provenance/artifact.go new file mode 100644 index 0000000000..c4beb10468 --- /dev/null +++ b/internal/provenance/artifact.go @@ -0,0 +1,192 @@ +package provenance + +import ( + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// Artifact describes the provenance-relevant state of a repository at build +// time, derived entirely from read-only git queries. It exists so release +// artifact names and version stamps are machine-derived from the ACTUAL +// build commit — never from a base/merge ref a human happened to have in +// mind (the gc-main-20260710-77916fc6c incident: filename said main + +// 77916fc6c, binary embedded an unmerged side-branch commit). +type Artifact struct { + // HeadSHA is the full commit hash of HEAD — the commit the build will + // embed as vcs.revision. + HeadSHA string + // ShortSHA is the abbreviated (>= 9 chars) form of HeadSHA used in + // artifact names. + ShortSHA string + // Token is the lineage segment of the artifact name: the base branch + // name when HEAD is an ancestor of BaseRef, otherwise the sanitized + // current branch name (never the base branch name — that is refused). + Token string + // Dirty reports whether the working tree has uncommitted or untracked + // changes, matching what the Go toolchain will embed as vcs.modified. + Dirty bool + // BaseRef is the remote-tracking ref the lineage claim is made against, + // exactly as supplied (e.g. "Voxist/main"). + BaseRef string + // BaseSHA is the abbreviated commit BaseRef resolved to at derivation + // time, so the stamp stays falsifiable after the ref moves. + BaseSHA string + // Ahead counts commits on HEAD that are not on BaseRef. + Ahead int + // Behind counts commits on BaseRef that are not on HEAD. + Behind int +} + +var tokenSanitizeRe = regexp.MustCompile(`[^A-Za-z0-9._]+`) + +// DeriveArtifact inspects the git repository at repoPath and derives the +// provenance facts an artifact name and version stamp are built from. +// +// baseRef must resolve to a remote-tracking ref (refs/remotes/...): a +// lineage claim that does not name its remote is unfalsifiable — this repo's +// `origin` is the upstream, not the fork, so a bare branch name invites the +// exact wrong-remote comparison this package exists to prevent. +// +// The base branch's name is only ever used as the Token when HEAD is an +// ancestor of baseRef. When it is not, and the current branch shares the +// base branch's name (a local `main` diverged from the fork's main), the +// derivation fails outright rather than mint a misleading name. +func DeriveArtifact(repoPath, baseRef string) (Artifact, error) { + fullRef, err := gitOut(repoPath, "rev-parse", "--symbolic-full-name", "--verify", "--quiet", baseRef) + if err != nil || fullRef == "" { + return Artifact{}, fmt.Errorf("base ref %q does not resolve in %q: pass / for a fetched remote (see `git remote -v`)", baseRef, repoPath) + } + rest, isRemote := strings.CutPrefix(fullRef, "refs/remotes/") + if !isRemote { + return Artifact{}, fmt.Errorf("base ref %q resolves to %s, not a remote-tracking ref: the lineage claim must name the remote (e.g. /main), because local and upstream branches of the same name diverge silently", baseRef, fullRef) + } + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 || parts[1] == "" { + return Artifact{}, fmt.Errorf("base ref %q (%s) has no branch component", baseRef, fullRef) + } + baseToken := sanitizeToken(parts[1]) + + headSHA, err := gitOut(repoPath, "rev-parse", "HEAD") + if err != nil { + return Artifact{}, fmt.Errorf("resolving HEAD of %q: %w", repoPath, err) + } + shortSHA, err := gitOut(repoPath, "rev-parse", "--short=9", "HEAD") + if err != nil { + return Artifact{}, fmt.Errorf("abbreviating HEAD of %q: %w", repoPath, err) + } + baseSHA, err := gitOut(repoPath, "rev-parse", "--short=9", baseRef) + if err != nil { + return Artifact{}, fmt.Errorf("resolving base %q: %w", baseRef, err) + } + + status, err := gitOut(repoPath, "status", "--porcelain") + if err != nil { + return Artifact{}, fmt.Errorf("checking working tree of %q: %w", repoPath, err) + } + dirty := status != "" + + counts, err := gitOut(repoPath, "rev-list", "--left-right", "--count", baseRef+"...HEAD") + if err != nil { + return Artifact{}, fmt.Errorf("counting divergence from %q: %w", baseRef, err) + } + fields := strings.Fields(counts) + if len(fields) != 2 { + return Artifact{}, fmt.Errorf("unexpected rev-list --count output %q", counts) + } + behind, err := strconv.Atoi(fields[0]) + if err != nil { + return Artifact{}, fmt.Errorf("parsing behind count %q: %w", fields[0], err) + } + ahead, err := strconv.Atoi(fields[1]) + if err != nil { + return Artifact{}, fmt.Errorf("parsing ahead count %q: %w", fields[1], err) + } + + token := baseToken + if ahead > 0 { + // HEAD is not an ancestor of the base: the artifact must carry the + // branch it was actually built from, and must never borrow the base + // branch's name. + branch, _ := gitOut(repoPath, "symbolic-ref", "--short", "--quiet", "HEAD") + token = sanitizeToken(branch) + if token == "" { + token = "detached" + } + if token == baseToken { + return Artifact{}, fmt.Errorf( + "HEAD (%s, branch %q) is not an ancestor of %s@%s (+%d/-%d): refusing to name the artifact %q — this is the wrong-remote trap; rebase onto %s or build from a differently-named branch", + shortSHA, branch, baseRef, baseSHA, ahead, behind, baseToken, baseRef) + } + } + + return Artifact{ + HeadSHA: headSHA, + ShortSHA: shortSHA, + Token: token, + Dirty: dirty, + BaseRef: baseRef, + BaseSHA: baseSHA, + Ahead: ahead, + Behind: behind, + }, nil +} + +// Name renders the canonical artifact filename: ---[-dirty]. The sha is HEAD's — the commit the binary +// embeds — by construction, and a dirty build is visible in the name so it +// can never masquerade as a clean one. +func (a Artifact) Name(binary string, date time.Time) string { + name := fmt.Sprintf("%s-%s-%s-%s", binary, a.Token, date.UTC().Format("20060102"), a.ShortSHA) + if a.Dirty { + name += "-dirty" + } + return name +} + +// CommitStamp renders the commit identity to inject via ldflags +// (-X main.commit): the full HEAD sha, with an explicit -dirty suffix when +// the tree is dirty. Artifact builds inject this and disable the Go +// toolchain's own VCS stamping (-buildvcs=false) because that stamping is +// untrustworthy from linked worktrees: nested under the repo dir it records +// the MAIN checkout's HEAD and dirty state (verified live — a worktree +// build at eb743642c embedded the main checkout's 50e120757), and outside +// the repo dir it records nothing. +func (a Artifact) CommitStamp() string { + stamp := a.HeadSHA + if a.Dirty { + stamp += "-dirty" + } + return stamp +} + +// BaseStamp renders the build's relationship to its base lineage at build +// time — e.g. "Voxist/main@eb743642c+0-0" — for embedding via ldflags so +// `gc version` can answer "how far from the fork's main was this build?" +// without rev-list archeology. +func (a Artifact) BaseStamp() string { + return fmt.Sprintf("%s@%s+%d-%d", a.BaseRef, a.BaseSHA, a.Ahead, a.Behind) +} + +// ShellSingleQuote wraps s in single quotes for safe eval in POSIX shells, +// escaping embedded single quotes. Used by `go run ./cmd/artifactname +// -format eval`, whose output a Makefile recipe evals. +func ShellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func sanitizeToken(s string) string { + return strings.Trim(tokenSanitizeRe.ReplaceAllString(s, "-"), "-") +} + +func gitOut(repoPath string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/provenance/artifact_test.go b/internal/provenance/artifact_test.go new file mode 100644 index 0000000000..62275b63e4 --- /dev/null +++ b/internal/provenance/artifact_test.go @@ -0,0 +1,217 @@ +package provenance + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// initArtifactTestRepo creates a repo whose HEAD is published as the +// remote-tracking ref voxist/main (no network involved), mirroring the +// fork-remote layout the artifact rules exist for. +func initArtifactTestRepo(t *testing.T) string { + t.Helper() + dir := initProvenanceTestRepo(t) + provenanceTestGit(t, dir, "branch", "-M", "work") + publishBase(t, dir, "HEAD") + return dir +} + +// publishBase points refs/remotes/voxist/main at rev, standing in for a +// fetched fork remote. +func publishBase(t *testing.T, dir, rev string) { + t.Helper() + sha := provenanceTestGitOut(t, dir, "rev-parse", rev) + provenanceTestGit(t, dir, "update-ref", "refs/remotes/voxist/main", sha) +} + +func artifactTestCommit(t *testing.T, dir, msg string) { + t.Helper() + provenanceTestGit(t, dir, "commit", "--allow-empty", "-m", msg) +} + +func TestDeriveArtifactInBaseLineage(t *testing.T) { + dir := initArtifactTestRepo(t) + head := provenanceTestGitOut(t, dir, "rev-parse", "HEAD") + + a, err := DeriveArtifact(dir, "voxist/main") + if err != nil { + t.Fatalf("DeriveArtifact: %v", err) + } + if a.Token != "main" { + t.Errorf("Token = %q, want %q", a.Token, "main") + } + if a.HeadSHA != head { + t.Errorf("HeadSHA = %q, want %q", a.HeadSHA, head) + } + if len(a.ShortSHA) < 9 || !strings.HasPrefix(head, a.ShortSHA) { + t.Errorf("ShortSHA = %q, want >=9-char prefix of %q", a.ShortSHA, head) + } + if a.Ahead != 0 || a.Behind != 0 { + t.Errorf("Ahead/Behind = %d/%d, want 0/0", a.Ahead, a.Behind) + } + if a.Dirty { + t.Error("Dirty = true on a clean tree") + } + if a.BaseRef != "voxist/main" { + t.Errorf("BaseRef = %q, want %q", a.BaseRef, "voxist/main") + } + if !strings.HasPrefix(head, a.BaseSHA) { + t.Errorf("BaseSHA = %q, want prefix of %q", a.BaseSHA, head) + } +} + +func TestDeriveArtifactBehindBaseStillMainToken(t *testing.T) { + // Building an OLD main commit is honest "main" lineage; the staleness + // must land in Behind, not silently vanish. + dir := initArtifactTestRepo(t) + old := provenanceTestGitOut(t, dir, "rev-parse", "HEAD") + artifactTestCommit(t, dir, "advance base") + publishBase(t, dir, "HEAD") + provenanceTestGit(t, dir, "checkout", "--detach", old) + + a, err := DeriveArtifact(dir, "voxist/main") + if err != nil { + t.Fatalf("DeriveArtifact: %v", err) + } + if a.Token != "main" { + t.Errorf("Token = %q, want %q", a.Token, "main") + } + if a.Ahead != 0 || a.Behind != 1 { + t.Errorf("Ahead/Behind = %d/%d, want 0/1", a.Ahead, a.Behind) + } +} + +func TestDeriveArtifactSideBranchGetsBranchToken(t *testing.T) { + dir := initArtifactTestRepo(t) + provenanceTestGit(t, dir, "checkout", "-b", "fix/order-dispatch_v2") + artifactTestCommit(t, dir, "side work") + + a, err := DeriveArtifact(dir, "voxist/main") + if err != nil { + t.Fatalf("DeriveArtifact: %v", err) + } + if a.Token != "fix-order-dispatch_v2" { + t.Errorf("Token = %q, want %q", a.Token, "fix-order-dispatch_v2") + } + if a.Ahead != 1 || a.Behind != 0 { + t.Errorf("Ahead/Behind = %d/%d, want 1/0", a.Ahead, a.Behind) + } +} + +func TestDeriveArtifactRefusesMisleadingBaseName(t *testing.T) { + // A local branch NAMED main that is not in the fork main's lineage is + // exactly the origin-vs-fork trap; the name must be refused, not derived. + dir := initArtifactTestRepo(t) + provenanceTestGit(t, dir, "checkout", "-b", "main") + artifactTestCommit(t, dir, "diverged local main") + + _, err := DeriveArtifact(dir, "voxist/main") + if err == nil { + t.Fatal("DeriveArtifact on diverged branch named main: want error, got nil") + } + if !strings.Contains(err.Error(), "not an ancestor") { + t.Errorf("error %q should explain the lineage refusal", err) + } +} + +func TestDeriveArtifactDetachedNotAncestor(t *testing.T) { + dir := initArtifactTestRepo(t) + provenanceTestGit(t, dir, "checkout", "--detach") + artifactTestCommit(t, dir, "detached work") + + a, err := DeriveArtifact(dir, "voxist/main") + if err != nil { + t.Fatalf("DeriveArtifact: %v", err) + } + if a.Token != "detached" { + t.Errorf("Token = %q, want %q", a.Token, "detached") + } +} + +func TestDeriveArtifactDirty(t *testing.T) { + dir := initArtifactTestRepo(t) + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("changed\n"), 0o600); err != nil { + t.Fatalf("dirty tracked file: %v", err) + } + a, err := DeriveArtifact(dir, "voxist/main") + if err != nil { + t.Fatalf("DeriveArtifact: %v", err) + } + if !a.Dirty { + t.Error("Dirty = false with modified tracked file") + } +} + +func TestDeriveArtifactUntrackedCountsAsDirty(t *testing.T) { + // Go's own vcs.modified counts untracked files; the name must agree + // with what the binary will embed. + dir := initArtifactTestRepo(t) + if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("x\n"), 0o600); err != nil { + t.Fatalf("untracked file: %v", err) + } + a, err := DeriveArtifact(dir, "voxist/main") + if err != nil { + t.Fatalf("DeriveArtifact: %v", err) + } + if !a.Dirty { + t.Error("Dirty = false with untracked file present") + } +} + +func TestDeriveArtifactRequiresRemoteTrackingBase(t *testing.T) { + dir := initArtifactTestRepo(t) + // "work" exists as a local branch; a lineage claim against it names no + // remote and must be rejected. + if _, err := DeriveArtifact(dir, "work"); err == nil { + t.Fatal("DeriveArtifact with local-branch base: want error, got nil") + } + if _, err := DeriveArtifact(dir, "nosuch/main"); err == nil { + t.Fatal("DeriveArtifact with unknown base: want error, got nil") + } +} + +func TestArtifactName(t *testing.T) { + a := Artifact{Token: "main", ShortSHA: "77916fc6c"} + date := time.Date(2026, 7, 10, 23, 59, 0, 0, time.UTC) + if got, want := a.Name("gc", date), "gc-main-20260710-77916fc6c"; got != want { + t.Errorf("Name = %q, want %q", got, want) + } + a.Dirty = true + if got, want := a.Name("gc", date), "gc-main-20260710-77916fc6c-dirty"; got != want { + t.Errorf("Name (dirty) = %q, want %q", got, want) + } +} + +func TestArtifactCommitStamp(t *testing.T) { + a := Artifact{HeadSHA: "eb743642c6b4935e07dc864a96e7003195dc123a"} + if got, want := a.CommitStamp(), "eb743642c6b4935e07dc864a96e7003195dc123a"; got != want { + t.Errorf("CommitStamp = %q, want %q", got, want) + } + a.Dirty = true + if got, want := a.CommitStamp(), "eb743642c6b4935e07dc864a96e7003195dc123a-dirty"; got != want { + t.Errorf("CommitStamp (dirty) = %q, want %q", got, want) + } +} + +func TestArtifactBaseStamp(t *testing.T) { + a := Artifact{BaseRef: "Voxist/main", BaseSHA: "eb743642c", Ahead: 1, Behind: 340} + if got, want := a.BaseStamp(), "Voxist/main@eb743642c+1-340"; got != want { + t.Errorf("BaseStamp = %q, want %q", got, want) + } +} + +func TestShellSingleQuote(t *testing.T) { + tests := []struct{ in, want string }{ + {in: "plain", want: "'plain'"}, + {in: "with space", want: "'with space'"}, + {in: "don't", want: `'don'\''t'`}, + } + for _, tt := range tests { + if got := ShellSingleQuote(tt.in); got != tt.want { + t.Errorf("ShellSingleQuote(%q) = %s, want %s", tt.in, got, tt.want) + } + } +}