Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<remote>/<branch>`
builds a gc binary whose filename is machine-derived from the ACTUAL build
commit (`gc-<token>-<UTC date>-<sha>[-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
Expand Down
42 changes: 42 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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=<remote>/<branch>, 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
Expand Down
81 changes: 81 additions & 0 deletions cmd/artifactname/main.go
Original file line number Diff line number Diff line change
@@ -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 <remote>/<branch> [-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 <repo>` 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
}
64 changes: 59 additions & 5 deletions cmd/gc/cmd_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,}$`),
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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"`
}
61 changes: 61 additions & 0 deletions cmd/gc/cmd_version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading