[object Object]

← back to Cli Printing Press

feat(cli): add .printing-press.json manifest to published CLIs (#41)

6821a433a5a5f6a18f35d676058b76ae3a132c2a · 2026-03-29 00:24:57 -0700 · Trevin Chow

* refactor(cli): extract version to internal/version package

* feat(cli): add CLIManifest struct, writer, and helpers

* feat(cli): wire CLI manifest into publish pipeline

* fix(cli): normalize spec_url/spec_path in manifest and remove brittle version test

- Local file paths in SpecURL are moved to spec_path only
- URLs duplicated in both SpecURL and SpecPath emit only in spec_url
- Version test asserts valid semver format instead of hardcoded value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(cli): tighten semver assertion in version test

* docs(cli): add brainstorm and plan for CLI manifest feature

* fix(cli): apply gofmt to CLIManifest struct alignment

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6821a433a5a5f6a18f35d676058b76ae3a132c2a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Mar 29 00:24:57 2026 -0700

    feat(cli): add .printing-press.json manifest to published CLIs (#41)
    
    * refactor(cli): extract version to internal/version package
    
    * feat(cli): add CLIManifest struct, writer, and helpers
    
    * feat(cli): wire CLI manifest into publish pipeline
    
    * fix(cli): normalize spec_url/spec_path in manifest and remove brittle version test
    
    - Local file paths in SpecURL are moved to spec_path only
    - URLs duplicated in both SpecURL and SpecPath emit only in spec_url
    - Version test asserts valid semver format instead of hardcoded value
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * test(cli): tighten semver assertion in version test
    
    * docs(cli): add brainstorm and plan for CLI manifest feature
    
    * fix(cli): apply gofmt to CLIManifest struct alignment
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 .goreleaser.yaml                                   |   2 +-
 AGENTS.md                                          |   2 +-
 .../2026-03-28-cli-manifest-requirements.md        |  66 +++++
 .../plans/2026-03-28-002-feat-cli-manifest-plan.md | 234 +++++++++++++++
 internal/cli/release_test.go                       |  24 +-
 internal/cli/root.go                               |  28 +-
 internal/pipeline/climanifest.go                   |  74 +++++
 internal/pipeline/climanifest_test.go              | 326 +++++++++++++++++++++
 internal/pipeline/publish.go                       |  61 ++++
 internal/version/version.go                        |  33 +++
 internal/version/version_test.go                   |  17 ++
 release-please-config.json                         |   2 +-
 12 files changed, 830 insertions(+), 39 deletions(-)

diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index f558fed0..a98fa176 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -18,7 +18,7 @@ builds:
     flags:
       - -trimpath
     ldflags:
-      - -s -w -X github.com/mvanhorn/cli-printing-press/internal/cli.version={{.Version}}
+      - -s -w -X github.com/mvanhorn/cli-printing-press/internal/version.Version={{.Version}}
     mod_timestamp: "{{ .CommitTimestamp }}"
 
 archives:
diff --git a/AGENTS.md b/AGENTS.md
index 43c71a26..53f8bc11 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,7 +52,7 @@ Every commit and PR title must include one of these scopes. The `PR Title` actio
 **Never manually edit version numbers.** Three files carry the version and release-please keeps them in sync:
 - `.claude-plugin/plugin.json` → `version`
 - `.claude-plugin/marketplace.json` → `plugins[0].version`
-- `internal/cli/root.go` → `var version` (annotated with `x-release-please-version`)
+- `internal/version/version.go` → `var Version` (annotated with `x-release-please-version`)
 
 `TestVersionConsistencyAcrossFiles` in `internal/cli/release_test.go` will fail if versions drift.
 
diff --git a/docs/brainstorms/2026-03-28-cli-manifest-requirements.md b/docs/brainstorms/2026-03-28-cli-manifest-requirements.md
new file mode 100644
index 00000000..6e6cf513
--- /dev/null
+++ b/docs/brainstorms/2026-03-28-cli-manifest-requirements.md
@@ -0,0 +1,66 @@
+---
+date: 2026-03-28
+topic: cli-manifest
+---
+
+# CLI Library Manifest
+
+## Problem Frame
+
+Generated CLIs in `~/printing-press/library/<cli-name>/` carry no provenance metadata. The only way to know when a CLI was created is to inspect filesystem timestamps, which are unreliable across copies, moves, and time. Metadata exists in `manuscripts/` and `.runstate/`, but those are separate directories tied to the generating machine — if the CLI folder is shared or examined in isolation, that context is lost.
+
+A manifest file in each generated CLI directory would make it self-describing: when it was built, from what spec, by what version of printing-press, and how to trace back to the full generation record.
+
+## Requirements
+
+**Manifest Content**
+
+- R1. Each generated CLI directory contains a `.printing-press.json` manifest file at its root
+- R2. Manifest includes generation timestamp (`generated_at`, RFC 3339)
+- R3. Manifest includes the printing-press version that produced it (`printing_press_version`)
+- R4. Manifest includes the API name used during generation (`api_name`)
+- R5. Manifest includes the spec source (`spec_url` and/or `spec_path` — whichever was used)
+- R6. Manifest includes the spec format (`spec_format`: openapi3, internal, etc.)
+- R7. Manifest includes the derived CLI name (`cli_name`)
+- R8. Manifest includes the run ID linking back to the manuscript archive (`run_id`)
+- R9. Manifest includes the catalog entry slug if the spec came from the catalog (`catalog_entry`, optional)
+- R10. Manifest includes a SHA-256 checksum of the spec file used (`spec_checksum`)
+
+**Schema Versioning**
+
+- R11. Manifest includes a schema version field (`schema_version: 1`) so the format can evolve without breaking consumers
+
+**Write Timing**
+
+- R12. The manifest is written during the publish phase, when the CLI is copied to its library directory — not during intermediate generation steps
+
+## Success Criteria
+
+- Looking at any CLI folder in `~/printing-press/library/` immediately answers: when was this built, from what spec, and by what version of printing-press
+- The manifest is present and correct for all newly generated CLIs
+- The `run_id` in the manifest can be used to locate the corresponding manuscript archive
+
+## Scope Boundaries
+
+- No CLI to read/query manifests (could be added later, not part of this)
+- No regeneration workflow triggered from the manifest (the data supports it, but the feature is out of scope)
+- No migration to backfill manifests for previously generated CLIs
+- Manifest is write-once at publish time; no update-on-regeneration semantics yet
+
+## Key Decisions
+
+- **Tier 2 scope**: Provenance + regeneration link, but not full portability (no vision features, generation config, or source repo in the manifest). Keeps it simple with room to extend via `schema_version`.
+- **Dot-file naming (`.printing-press.json`)**: Signals "tooling metadata" without cluttering the project, but remains easily discoverable.
+- **Spec checksum included**: Enables future "is this CLI up to date?" checks without requiring the full spec to be stored in the manifest.
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R5][Technical] Should `spec_path` be stored as an absolute path or relative? Absolute is more informative but machine-specific. Consider storing both or just the URL when available.
+- [Affects R12][Technical] Where exactly in the publish pipeline (`internal/pipeline/publish.go`) should the manifest write hook into? Needs codebase inspection during planning.
+- [Affects R10][Technical] Should the checksum be computed from the raw spec file bytes or the parsed/normalized spec? Raw bytes is simpler and more verifiable.
+
+## Next Steps
+
+-> `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-28-002-feat-cli-manifest-plan.md b/docs/plans/2026-03-28-002-feat-cli-manifest-plan.md
new file mode 100644
index 00000000..1cf32514
--- /dev/null
+++ b/docs/plans/2026-03-28-002-feat-cli-manifest-plan.md
@@ -0,0 +1,234 @@
+---
+title: "feat: Add .printing-press.json manifest to generated CLIs"
+type: feat
+status: completed
+date: 2026-03-28
+origin: docs/brainstorms/2026-03-28-cli-manifest-requirements.md
+---
+
+# feat: Add .printing-press.json manifest to generated CLIs
+
+## Overview
+
+Add a `.printing-press.json` manifest file to each generated CLI directory in `~/printing-press/library/`. The manifest captures provenance metadata — when the CLI was built, from what spec, by which version of printing-press — so the folder is self-describing even in isolation.
+
+## Problem Frame
+
+Generated CLIs carry no provenance metadata. The only way to determine when a CLI was created is to inspect filesystem timestamps, which are unreliable across copies, moves, and time. Metadata exists in `manuscripts/` and `.runstate/`, but those are machine-specific and separate from the published CLI. (see origin: docs/brainstorms/2026-03-28-cli-manifest-requirements.md)
+
+## Requirements Trace
+
+**Manifest Structure**
+- R1. `.printing-press.json` manifest at root of each generated CLI directory
+- R11. `schema_version: 1`
+
+**Required Fields**
+- R2. `generated_at` (RFC 3339 timestamp)
+- R3. `printing_press_version`
+- R4. `api_name`
+- R5. `spec_url` and/or `spec_path`
+- R6. `spec_format` (openapi3, internal, graphql)
+- R7. `cli_name`
+- R8. `run_id`
+- R9. `catalog_entry` (optional, when spec came from catalog)
+- R10. `spec_checksum` (SHA-256)
+
+**Lifecycle**
+- R12. Written during the publish phase, not intermediate generation steps
+
+## Scope Boundaries
+
+- No CLI command to read/query manifests
+- No regeneration workflow triggered from the manifest
+- No backfill for previously generated CLIs
+- Write-once at publish time; no update-on-regeneration semantics
+- Standalone `generate` command does not write a manifest (it has no publish phase)
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/publish.go` — `PublishWorkingCLI` is the publish entry point. After `CopyDir(workingDir, finalDir)`, it sets `state.PublishedDir`, calls `state.Save()`, then `WriteRunManifest(state)`. The new `WriteCLIManifest` call should go after `CopyDir` and before `state.Save()`.
+- `internal/pipeline/publish.go` — `RunManifest` struct and `BuildRunManifest` show the existing pattern for metadata structs. `WriteCLIManifest` should follow the same `json.MarshalIndent("", "  ")` + `os.WriteFile(path, data, 0o644)` pattern.
+- `internal/pipeline/state.go` — `PipelineState` carries `SpecURL`, `SpecPath`, `RunID`, `APIName`. Does not carry `SpecFormat` or `CatalogEntry`.
+- `internal/pipeline/fullrun.go` — `copySpecToOutput` writes the spec to `<workingDir>/spec.json`. This file is available at publish time for checksum computation and format detection.
+- `internal/cli/root.go:30` — `var version = "0.4.0"` is unexported. Cannot be imported by `internal/pipeline`.
+- `internal/catalog/catalog.go` — `LookupFS` can resolve an API name to a catalog `Entry` with `SpecFormat`.
+- `internal/naming/naming.go` — `CLI(apiName)` derives the CLI name.
+- `internal/openapi/openapi.go` — `IsOpenAPI(data)` detects OpenAPI specs.
+- Test patterns: table-driven with `testify/assert`, `t.TempDir()`, `setPressTestEnv` helper in `state_test.go`.
+
+### Institutional Learnings
+
+- Published CLIs in `library/` should be treated as immutable (see `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`).
+- Path logic is centralized in `internal/pipeline/paths.go` and naming in `internal/naming/`.
+
+## Key Technical Decisions
+
+- **Separate struct from RunManifest**: `CLIManifest` is a new struct, not an extension of `RunManifest`. RunManifest contains machine-specific fields (`GitRoot`, `WorkingDir`, `Scope`) that don't belong in a portable CLI manifest. Different purpose, different struct.
+- **Compute everything at publish time**: Rather than adding fields to `PipelineState` (which would require a state version bump), compute `spec_format`, `catalog_entry`, and `spec_checksum` from available data at publish time. PipelineState already carries `APIName`, `SpecURL`, `SpecPath`, `RunID`.
+- **Version via shared package**: Extract the version var to `internal/version/version.go` so both `internal/cli` and `internal/pipeline` can import it. Cleanest solution without parameter threading.
+- **spec_path stored as-is (absolute)**: The `spec_url` is the portable identifier; `spec_path` is supplementary context for the generating machine. No path rewriting.
+- **Spec checksum from raw file bytes**: Hash the spec file available in the working directory at publish time. Simple, verifiable with `shasum`, deterministic per run.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **spec_path absolute or relative?** → Store as-is (absolute). URL is the portable identifier. (see origin deferred question affecting R5)
+- **Where in publish pipeline?** → After `CopyDir` in `PublishWorkingCLI`, before `state.Save()`. (see origin deferred question affecting R12)
+- **Checksum from raw or parsed spec?** → Raw bytes of the spec file in the working directory. (see origin deferred question affecting R10)
+- **How to detect spec format at publish time?** → Read `spec.json` from working dir, use `openapi.IsOpenAPI(data)` and `graphql.IsGraphQLSDL(data)` to detect format. Fallback to `"internal"`.
+
+- **Does `copySpecToOutput` write raw bytes or re-marshal?** → It calls `ensureJSON()` (fullrun.go:274) which re-marshals YAML to JSON via `yaml.Unmarshal` + `json.Marshal`. The `spec_checksum` is a fingerprint of the re-marshaled JSON in `spec.json`, not the original source file. Still valid for change detection but will not match `shasum` of YAML source files.
+
+### Deferred to Implementation
+
+(None remaining — all resolved during planning or review.)
+
+## Implementation Units
+
+- [x] **Unit 1: Extract version to shared package**
+
+**Goal:** Make the printing-press version importable from any internal package.
+
+**Requirements:** R3
+
+**Dependencies:** None
+
+**Files:**
+- Create: `internal/version/version.go` — with `x-release-please-version` annotation on the `var Version` line
+- Modify: `internal/cli/root.go` — import `internal/version` and reference `version.Version`; remove `x-release-please-version` annotation
+- Modify: `internal/cli/release_test.go` — update `TestVersionConsistencyAcrossFiles` (line 81 uses `version` directly, must change to `version.Version`), update `TestGoreleaserLdflagsTargetMatchesVersionVar` (asserts `internal/cli.version`, must change to `internal/version.version`), update `TestReleasePleaseAnnotationExists` (reads `root.go`, must change to `internal/version/version.go`)
+- Modify: `.goreleaser.yaml` — update ldflags from `internal/cli.version` to `internal/version.version`
+- Modify: `AGENTS.md` — update versioning section to list `internal/version/version.go` instead of `internal/cli/root.go`
+- Test: `internal/version/version_test.go`
+
+**Approach:**
+- Move the `var version` declaration (with `x-release-please-version` annotation) and `debug.ReadBuildInfo()` init logic from `internal/cli/root.go` to a new `internal/version` package.
+- Export as `version.Version` (the var) and `version.Get()` (function returning the version string, for callers that prefer a function call).
+- `internal/cli/root.go` imports `version.Version` to set cobra's `Version` field — same runtime behavior, different import path.
+- Update `.goreleaser.yaml` ldflags to target `internal/version.version` instead of `internal/cli.version`.
+- Update `AGENTS.md` versioning section to reference the new file location.
+
+**Patterns to follow:**
+- The existing `init()` in `root.go` that reads `debug.ReadBuildInfo()` — replicate this logic in `internal/version/version.go`
+
+**Test scenarios:**
+- Happy path: `version.Version` returns the hardcoded version string when no build info is available
+- Happy path: `version.Get()` returns the same value as `version.Version`
+- Edge case: `TestVersionConsistencyAcrossFiles` still passes after the move (reads `internal/version/version.go` instead of `internal/cli/root.go`)
+- Edge case: `TestGoreleaserLdflagsTargetMatchesVersionVar` passes with updated ldflags path (`internal/version.version`)
+
+**Verification:**
+- `go build ./...` succeeds
+- `go test ./internal/version/ ./internal/cli/` passes
+- The binary `--version` output is unchanged
+
+---
+
+- [x] **Unit 2: CLIManifest struct, writer, and helpers**
+
+**Goal:** Define the manifest data model and a standalone function to write `.printing-press.json` to a directory.
+
+**Requirements:** R1, R2, R3, R4, R5, R6, R7, R8, R9, R10, R11
+
+**Dependencies:** Unit 1 (for `version.Version`)
+
+**Files:**
+- Create: `internal/pipeline/climanifest.go`
+- Test: `internal/pipeline/climanifest_test.go`
+
+**Approach:**
+- Define `CLIManifest` struct with JSON tags matching the brainstorm field names: `schema_version`, `generated_at`, `printing_press_version`, `api_name`, `cli_name`, `spec_url`, `spec_path`, `spec_format`, `spec_checksum`, `run_id`, `catalog_entry`.
+- `WriteCLIManifest(dir string, m CLIManifest) error` — marshals to indented JSON and writes to `filepath.Join(dir, ".printing-press.json")`. Follow the `WriteRunManifest` pattern: `json.MarshalIndent` with `""` prefix and `"  "` indent, `0o644` perms, `fmt.Errorf` wrapping.
+- `specChecksum(path string) (string, error)` — reads file bytes, returns `"sha256:<hex>"`. Returns empty string (not error) if file doesn't exist.
+- `detectSpecFormat(data []byte) string` — uses `openapi.IsOpenAPI` and `graphql.IsGraphQLSDL`, falls back to `"internal"`.
+- `CLIManifestFilename` constant = `".printing-press.json"`.
+
+**Patterns to follow:**
+- `WriteRunManifest` / `WriteArchivedManifest` in `internal/pipeline/publish.go` for the JSON write pattern
+- `openapi.IsOpenAPI` for format detection
+
+**Test scenarios:**
+- Happy path: `WriteCLIManifest` creates `.printing-press.json` with all fields correctly serialized as indented JSON
+- Happy path: `specChecksum` returns correct `sha256:<hex>` for a known input
+- Happy path: `detectSpecFormat` returns `"openapi3"` for an OpenAPI spec, `"graphql"` for a GraphQL SDL, `"internal"` for an internal spec
+- Edge case: `WriteCLIManifest` with optional fields (`catalog_entry`, `spec_path`) omitted — JSON uses `omitempty` correctly
+- Edge case: `specChecksum` returns empty string for nonexistent file
+- Edge case: `WriteCLIManifest` target directory doesn't exist — returns error
+- Happy path: `WriteCLIManifest` always sets `schema_version` to 1
+
+**Verification:**
+- `go test ./internal/pipeline/ -run CLIManifest` passes
+- Written JSON is valid and parseable
+- Field names match the brainstorm spec exactly
+
+---
+
+- [x] **Unit 3: Wire manifest into PublishWorkingCLI**
+
+**Goal:** Build and write the CLI manifest as part of the publish flow so every published CLI gets a `.printing-press.json`.
+
+**Requirements:** R12, all R1-R11 (end-to-end)
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Modify: `internal/pipeline/publish.go` — add manifest build + write call inside `PublishWorkingCLI`
+- Test: `internal/pipeline/climanifest_test.go` (add integration-style test)
+
+**Approach:**
+- In `PublishWorkingCLI`, after `CopyDir(workingDir, finalDir)` and setting `state.PublishedDir`, build a `CLIManifest`:
+  - `schema_version`: 1
+  - `generated_at`: `time.Now().UTC()`
+  - `printing_press_version`: `version.Version`
+  - `api_name`: `state.APIName`
+  - `cli_name`: `naming.CLI(state.APIName)`
+  - `spec_url`: `state.SpecURL`
+  - `spec_path`: `state.SpecPath`
+  - `spec_format`: detect from spec file in `state.EffectiveWorkingDir()` (note: `spec.json` only exists when `specFlag` is `--spec` per fullrun.go:267; for `--docs` runs, no spec.json exists and these fields will be empty)
+  - `spec_checksum`: compute from spec file in `state.EffectiveWorkingDir()` (same caveat)
+  - `run_id`: `state.RunID`
+  - `catalog_entry`: look up `state.APIName` in `catalog.LookupFS` (empty string if not found)
+- Call `WriteCLIManifest(finalDir, manifest)`
+- If the manifest write fails, return the error (don't silently ignore — a missing manifest is a bug, not a recoverable condition)
+
+**Patterns to follow:**
+- The existing `WriteRunManifest(state)` call in `PublishWorkingCLI` — similar positioning, similar error handling
+
+**Test scenarios:**
+- Happy path: After `PublishWorkingCLI`, `finalDir/.printing-press.json` exists and contains correct `api_name`, `run_id`, `schema_version`, `generated_at`, `printing_press_version`, and `cli_name`
+- Happy path: `spec_checksum` in manifest matches independently computed checksum of the spec file
+- Happy path: When API name matches a catalog entry, `catalog_entry` is populated
+- Edge case: When API name doesn't match a catalog entry, `catalog_entry` is omitted from JSON
+- Edge case: When spec file is missing from working dir, `spec_checksum` and `spec_format` are empty but manifest is still written
+- Integration: Published CLI directory is fully usable (existing publish behavior unchanged) with the addition of `.printing-press.json`
+
+**Verification:**
+- `go test ./internal/pipeline/` passes
+- Running `MakeBestCLI` (via `FULL_RUN=1` integration test) produces a CLI directory containing `.printing-press.json`
+
+## System-Wide Impact
+
+- **Interaction graph:** Only `PublishWorkingCLI` is modified. No callbacks, middleware, or observers affected. The `generate` command (standalone) is explicitly out of scope — it does not publish to library/.
+- **Error propagation:** Manifest write failure fails the publish operation. This is intentional — a publish without a manifest is incomplete.
+- **State lifecycle risks:** None. No changes to `PipelineState` schema. The manifest is written to the output directory, not to runstate.
+- **API surface parity:** The manifest is a new file in the output directory. No existing consumers read `.printing-press.json`, so no breakage risk.
+- **Unchanged invariants:** `RunManifest` (in runstate and manuscripts) is untouched. The version string embedded in generated `root.go` via templates is untouched. The NOTICE file is untouched.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| `copySpecToOutput` may re-marshal the spec (checksum differs from original file) | Document which bytes are checksummed. The checksum is still a valid fingerprint for change detection. |
+| `catalog.LookupFS` may not find entries for all API names | `catalog_entry` is optional (`omitempty`). Missing is fine. |
+| Moving version to shared package could break build info injection via ldflags | Verify goreleaser ldflags path is updated to target `internal/version.version` |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-28-cli-manifest-requirements.md](docs/brainstorms/2026-03-28-cli-manifest-requirements.md)
+- Related code: `internal/pipeline/publish.go` (PublishWorkingCLI, RunManifest pattern)
+- Related code: `internal/pipeline/fullrun.go` (MakeBestCLI, copySpecToOutput)
+- Related code: `internal/cli/root.go` (version var, generate command)
+- Institutional learning: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`
diff --git a/internal/cli/release_test.go b/internal/cli/release_test.go
index b360c107..03f31165 100644
--- a/internal/cli/release_test.go
+++ b/internal/cli/release_test.go
@@ -6,6 +6,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/mvanhorn/cli-printing-press/internal/version"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"gopkg.in/yaml.v3"
@@ -13,13 +14,12 @@ import (
 
 func TestGoreleaserLdflagsTargetMatchesVersionVar(t *testing.T) {
 	// The goreleaser config injects the version via ldflags into
-	// internal/cli.version. If the variable is renamed or moved,
+	// internal/version.Version. If the variable is renamed or moved,
 	// goreleaser silently injects into nothing and the binary
 	// reports the hardcoded fallback. This test catches that drift.
 
-	// 1. Verify the version variable exists and is settable in this package.
-	//    (If this test compiles, the variable exists. We just confirm it's a string.)
-	assert.IsType(t, "", version)
+	// 1. Verify the version variable exists and is settable.
+	assert.IsType(t, "", version.Version)
 
 	// 2. Verify the goreleaser config references the correct ldflags path.
 	data, err := os.ReadFile("../../.goreleaser.yaml")
@@ -35,19 +35,19 @@ func TestGoreleaserLdflagsTargetMatchesVersionVar(t *testing.T) {
 
 	ldflags := strings.Join(config.Builds[0].Ldflags, " ")
 	assert.Contains(t, ldflags,
-		"github.com/mvanhorn/cli-printing-press/internal/cli.version",
-		"goreleaser ldflags must target internal/cli.version")
+		"github.com/mvanhorn/cli-printing-press/internal/version.Version",
+		"goreleaser ldflags must target internal/version.Version")
 }
 
 func TestReleasePleaseAnnotationExists(t *testing.T) {
 	// release-please uses the x-release-please-version annotation
-	// to find and bump the hardcoded version in root.go. If the
-	// annotation is removed, release-please silently stops updating it.
-	data, err := os.ReadFile("root.go")
+	// to find and bump the hardcoded version. If the annotation is
+	// removed, release-please silently stops updating it.
+	data, err := os.ReadFile("../version/version.go")
 	require.NoError(t, err)
 
 	assert.Contains(t, string(data), "x-release-please-version",
-		"root.go must have x-release-please-version annotation for automated version bumps")
+		"version.go must have x-release-please-version annotation for automated version bumps")
 }
 
 func TestVersionConsistencyAcrossFiles(t *testing.T) {
@@ -78,6 +78,6 @@ func TestVersionConsistencyAcrossFiles(t *testing.T) {
 	// All three should match
 	assert.Equal(t, plugin.Version, market.Plugins[0].Version,
 		"plugin.json and marketplace.json versions must match")
-	assert.Equal(t, plugin.Version, version,
-		"plugin.json and root.go hardcoded version must match")
+	assert.Equal(t, plugin.Version, version.Version,
+		"plugin.json and version.go hardcoded version must match")
 }
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 7ceb9d10..aded16a1 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -10,7 +10,6 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
-	"runtime/debug"
 	"strings"
 	"time"
 
@@ -23,37 +22,18 @@ import (
 	"github.com/mvanhorn/cli-printing-press/internal/openapi"
 	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/mvanhorn/cli-printing-press/internal/version"
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v3"
 )
 
-var version = "0.4.0" // x-release-please-version
-
-func init() {
-	info, ok := debug.ReadBuildInfo()
-	if !ok {
-		return
-	}
-	v := info.Main.Version
-	// Only use the build info version when it's a real tagged release.
-	// Skip empty, "(devel)", and pseudo-versions like "v0.0.0-20260328...".
-	if v == "" || v == "(devel)" {
-		return
-	}
-	trimmed := strings.TrimPrefix(v, "v")
-	if strings.HasPrefix(trimmed, "0.0.0-") {
-		return
-	}
-	version = trimmed
-}
-
 func Execute() error {
 	rootCmd := &cobra.Command{
 		Use:           "printing-press",
 		Short:         "Describe your API. Get a production CLI.",
 		SilenceUsage:  true,
 		SilenceErrors: true,
-		Version:       version,
+		Version:       version.Version,
 	}
 	rootCmd.SetVersionTemplate("printing-press {{.Version}}\n")
 
@@ -472,11 +452,11 @@ func newVersionCmd() *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if asJSON {
 				return json.NewEncoder(os.Stdout).Encode(map[string]string{
-					"version": version,
+					"version": version.Version,
 					"go":      runtime.Version(),
 				})
 			}
-			fmt.Printf("printing-press %s\n", version)
+			fmt.Printf("printing-press %s\n", version.Version)
 			return nil
 		},
 	}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
new file mode 100644
index 00000000..d54ff308
--- /dev/null
+++ b/internal/pipeline/climanifest.go
@@ -0,0 +1,74 @@
+package pipeline
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/mvanhorn/cli-printing-press/internal/openapi"
+)
+
+// CLIManifestFilename is the name of the manifest file written to each
+// published CLI directory.
+const CLIManifestFilename = ".printing-press.json"
+
+// CLIManifest captures provenance metadata for a generated CLI.
+// It is written to the root of each published CLI directory so the
+// folder is self-describing even in isolation.
+type CLIManifest struct {
+	SchemaVersion        int       `json:"schema_version"`
+	GeneratedAt          time.Time `json:"generated_at"`
+	PrintingPressVersion string    `json:"printing_press_version"`
+	APIName              string    `json:"api_name"`
+	CLIName              string    `json:"cli_name"`
+	SpecURL              string    `json:"spec_url,omitempty"`
+	SpecPath             string    `json:"spec_path,omitempty"`
+	SpecFormat           string    `json:"spec_format,omitempty"`
+	SpecChecksum         string    `json:"spec_checksum,omitempty"`
+	RunID                string    `json:"run_id,omitempty"`
+	CatalogEntry         string    `json:"catalog_entry,omitempty"`
+}
+
+// WriteCLIManifest marshals m as indented JSON and writes it to
+// dir/.printing-press.json.
+func WriteCLIManifest(dir string, m CLIManifest) error {
+	data, err := json.MarshalIndent(m, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshaling CLI manifest: %w", err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644); err != nil {
+		return fmt.Errorf("writing CLI manifest: %w", err)
+	}
+	return nil
+}
+
+// specChecksum computes a SHA-256 checksum of the file at path.
+// Returns "sha256:<hex>" on success, or an empty string if the file
+// does not exist.
+func specChecksum(path string) (string, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return "", nil
+		}
+		return "", fmt.Errorf("reading spec for checksum: %w", err)
+	}
+	h := sha256.Sum256(data)
+	return "sha256:" + hex.EncodeToString(h[:]), nil
+}
+
+// detectSpecFormat examines the raw spec bytes and returns a format
+// string: "openapi3", "graphql", or "internal".
+func detectSpecFormat(data []byte) string {
+	if openapi.IsOpenAPI(data) {
+		return "openapi3"
+	}
+	if openapi.IsGraphQLSDL(data) {
+		return "graphql"
+	}
+	return "internal"
+}
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
new file mode 100644
index 00000000..222df2b2
--- /dev/null
+++ b/internal/pipeline/climanifest_test.go
@@ -0,0 +1,326 @@
+package pipeline
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/mvanhorn/cli-printing-press/internal/version"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWriteCLIManifest(t *testing.T) {
+	dir := t.TempDir()
+
+	m := CLIManifest{
+		SchemaVersion:        1,
+		GeneratedAt:          time.Date(2026, 3, 28, 15, 4, 5, 0, time.UTC),
+		PrintingPressVersion: "0.4.0",
+		APIName:              "notion",
+		CLIName:              "notion-pp-cli",
+		SpecURL:              "https://example.com/spec.json",
+		SpecPath:             "/tmp/spec.json",
+		SpecFormat:           "openapi3",
+		SpecChecksum:         "sha256:abc123",
+		RunID:                "20260328T150405Z-abcd1234",
+		CatalogEntry:         "notion",
+	}
+
+	err := WriteCLIManifest(dir, m)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Equal(t, 1, got.SchemaVersion)
+	assert.Equal(t, "notion", got.APIName)
+	assert.Equal(t, "notion-pp-cli", got.CLIName)
+	assert.Equal(t, "0.4.0", got.PrintingPressVersion)
+	assert.Equal(t, "https://example.com/spec.json", got.SpecURL)
+	assert.Equal(t, "/tmp/spec.json", got.SpecPath)
+	assert.Equal(t, "openapi3", got.SpecFormat)
+	assert.Equal(t, "sha256:abc123", got.SpecChecksum)
+	assert.Equal(t, "20260328T150405Z-abcd1234", got.RunID)
+	assert.Equal(t, "notion", got.CatalogEntry)
+	assert.Equal(t, m.GeneratedAt, got.GeneratedAt)
+}
+
+func TestWriteCLIManifestSchemaVersionAlwaysOne(t *testing.T) {
+	dir := t.TempDir()
+	m := CLIManifest{SchemaVersion: 1, APIName: "test"}
+
+	err := WriteCLIManifest(dir, m)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+	assert.Equal(t, 1, got.SchemaVersion)
+}
+
+func TestWriteCLIManifestOmitsEmptyOptionalFields(t *testing.T) {
+	dir := t.TempDir()
+
+	m := CLIManifest{
+		SchemaVersion:        1,
+		GeneratedAt:          time.Now().UTC(),
+		PrintingPressVersion: "0.4.0",
+		APIName:              "test",
+		CLIName:              "test-pp-cli",
+		SpecURL:              "https://example.com/spec.json",
+		// SpecPath, CatalogEntry intentionally omitted
+	}
+
+	err := WriteCLIManifest(dir, m)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	// Verify optional fields are not present in JSON
+	var raw map[string]interface{}
+	require.NoError(t, json.Unmarshal(data, &raw))
+
+	_, hasCatalog := raw["catalog_entry"]
+	assert.False(t, hasCatalog, "catalog_entry should be omitted when empty")
+
+	_, hasSpecPath := raw["spec_path"]
+	assert.False(t, hasSpecPath, "spec_path should be omitted when empty")
+}
+
+func TestWriteCLIManifestNonexistentDir(t *testing.T) {
+	err := WriteCLIManifest("/nonexistent/path", CLIManifest{})
+	assert.Error(t, err)
+}
+
+func TestSpecChecksum(t *testing.T) {
+	dir := t.TempDir()
+	content := []byte(`{"openapi": "3.0.0"}`)
+	specPath := filepath.Join(dir, "spec.json")
+	require.NoError(t, os.WriteFile(specPath, content, 0o644))
+
+	checksum, err := specChecksum(specPath)
+	require.NoError(t, err)
+
+	h := sha256.Sum256(content)
+	expected := "sha256:" + hex.EncodeToString(h[:])
+	assert.Equal(t, expected, checksum)
+}
+
+func TestSpecChecksumNonexistentFile(t *testing.T) {
+	checksum, err := specChecksum("/nonexistent/file.json")
+	require.NoError(t, err)
+	assert.Empty(t, checksum)
+}
+
+func TestPublishWorkingCLIWritesManifest(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	// Create a working directory with a minimal CLI structure and spec
+	workingDir := filepath.Join(home, "working", "test-pp-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(
+		filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"),
+		0o644,
+	))
+
+	specContent := []byte(`{"openapi": "3.0.0", "info": {"title": "Test"}}`)
+	require.NoError(t, os.WriteFile(
+		filepath.Join(workingDir, "spec.json"),
+		specContent,
+		0o644,
+	))
+
+	// Create a PipelineState pointing to the working directory.
+	// SpecURL is a real URL, SpecPath is a different local path —
+	// both should appear in the manifest.
+	state := NewState("test-api", workingDir)
+	state.SpecURL = "https://example.com/spec.json"
+	state.SpecPath = "/tmp/test-spec.json"
+
+	// Ensure state directory exists so Save() works
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	// Publish to a new directory
+	publishDir := filepath.Join(home, "library", "test-pp-cli")
+	finalDir, err := PublishWorkingCLI(state, publishDir)
+	require.NoError(t, err)
+	assert.Equal(t, publishDir, finalDir)
+
+	// Verify .printing-press.json exists in published directory
+	manifestPath := filepath.Join(finalDir, CLIManifestFilename)
+	data, err := os.ReadFile(manifestPath)
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Equal(t, 1, got.SchemaVersion)
+	assert.Equal(t, "test-api", got.APIName)
+	assert.Equal(t, "test-api-pp-cli", got.CLIName)
+	assert.Equal(t, version.Version, got.PrintingPressVersion)
+	assert.Equal(t, "https://example.com/spec.json", got.SpecURL)
+	assert.Equal(t, "/tmp/test-spec.json", got.SpecPath)
+	assert.Equal(t, "openapi3", got.SpecFormat)
+	assert.NotEmpty(t, got.RunID)
+	assert.False(t, got.GeneratedAt.IsZero())
+
+	// Verify checksum matches independently computed value
+	h := sha256.Sum256(specContent)
+	expectedChecksum := "sha256:" + hex.EncodeToString(h[:])
+	assert.Equal(t, expectedChecksum, got.SpecChecksum)
+}
+
+func TestPublishManifestNormalizesLocalPathInSpecURL(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	workingDir := filepath.Join(home, "working", "local-spec-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"), 0o644))
+
+	// Simulate the fullrun --spec /path/to/spec.json behavior:
+	// SpecURL = local path, SpecPath = same local path
+	state := NewState("local-test", workingDir)
+	state.SpecURL = "/tmp/my-spec.yaml"
+	state.SpecPath = "/tmp/my-spec.yaml"
+
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	publishDir := filepath.Join(home, "library", "local-spec-pp-cli")
+	finalDir, err := PublishWorkingCLI(state, publishDir)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(finalDir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	// Local path should be in spec_path, NOT in spec_url
+	assert.Empty(t, got.SpecURL, "local file path should not appear in spec_url")
+	assert.Equal(t, "/tmp/my-spec.yaml", got.SpecPath)
+}
+
+func TestPublishManifestNormalizesURLDuplicatedInBothFields(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	workingDir := filepath.Join(home, "working", "dup-url-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"), 0o644))
+
+	// Simulate the fullrun --spec https://... behavior:
+	// SpecURL = URL, SpecPath = same URL (duplicated)
+	state := NewState("dup-url", workingDir)
+	state.SpecURL = "https://example.com/spec.json"
+	state.SpecPath = "https://example.com/spec.json"
+
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	publishDir := filepath.Join(home, "library", "dup-url-pp-cli")
+	finalDir, err := PublishWorkingCLI(state, publishDir)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(finalDir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	// URL should be in spec_url only, not duplicated into spec_path
+	assert.Equal(t, "https://example.com/spec.json", got.SpecURL)
+	assert.Empty(t, got.SpecPath, "URL should not be duplicated in spec_path")
+}
+
+func TestPublishWorkingCLIManifestWithoutSpec(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	// Working directory without spec.json
+	workingDir := filepath.Join(home, "working", "no-spec-pp-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(
+		filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"),
+		0o644,
+	))
+
+	state := NewState("no-spec", workingDir)
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	publishDir := filepath.Join(home, "library", "no-spec-pp-cli")
+	finalDir, err := PublishWorkingCLI(state, publishDir)
+	require.NoError(t, err)
+
+	// Manifest should still be written with empty spec fields
+	data, err := os.ReadFile(filepath.Join(finalDir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Equal(t, 1, got.SchemaVersion)
+	assert.Equal(t, "no-spec", got.APIName)
+	assert.Empty(t, got.SpecChecksum)
+	assert.Empty(t, got.SpecFormat)
+}
+
+func TestDetectSpecFormat(t *testing.T) {
+	tests := []struct {
+		name     string
+		data     []byte
+		expected string
+	}{
+		{
+			name:     "openapi json",
+			data:     []byte(`{"openapi": "3.0.0", "info": {}}`),
+			expected: "openapi3",
+		},
+		{
+			name:     "openapi yaml",
+			data:     []byte("openapi: 3.0.0\ninfo:\n  title: Test"),
+			expected: "openapi3",
+		},
+		{
+			name:     "swagger",
+			data:     []byte(`{"swagger": "2.0"}`),
+			expected: "openapi3",
+		},
+		{
+			name:     "graphql",
+			data:     []byte("type Query {\n  hello: String\n}"),
+			expected: "graphql",
+		},
+		{
+			name:     "internal spec",
+			data:     []byte("name: test\nbase_url: https://api.example.com"),
+			expected: "internal",
+		},
+		{
+			name:     "empty",
+			data:     []byte{},
+			expected: "internal",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, detectSpecFormat(tt.data))
+		})
+	}
+}
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 637cb767..35f6a0bc 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -6,7 +6,13 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
+
+	"github.com/mvanhorn/cli-printing-press/catalog"
+	catalogpkg "github.com/mvanhorn/cli-printing-press/internal/catalog"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
+	"github.com/mvanhorn/cli-printing-press/internal/version"
 )
 
 type RunManifest struct {
@@ -98,6 +104,11 @@ func PublishWorkingCLI(state *PipelineState, targetDir string) (string, error) {
 	}
 
 	state.PublishedDir = finalDir
+
+	if err := writeCLIManifestForPublish(state, finalDir); err != nil {
+		return "", err
+	}
+
 	if err := state.Save(); err != nil {
 		return "", err
 	}
@@ -149,6 +160,56 @@ func ArchiveRunArtifacts(state *PipelineState) (string, error) {
 	return archiveDir, nil
 }
 
+func writeCLIManifestForPublish(state *PipelineState, dir string) error {
+	// Normalize spec_url vs spec_path. The fullrun pipeline sets
+	// state.SpecURL to the raw --spec argument (URL or file path)
+	// and state.SpecPath = SpecURL for --spec runs. We need to put
+	// URLs in spec_url and file paths in spec_path, not both.
+	specURL, specPath := state.SpecURL, state.SpecPath
+	isURL := strings.HasPrefix(specURL, "http://") || strings.HasPrefix(specURL, "https://")
+	if !isURL && specURL != "" {
+		// Raw --spec argument was a file path, not a URL.
+		specPath = specURL
+		specURL = ""
+	}
+	if isURL {
+		// Don't duplicate a URL into spec_path.
+		if specPath == specURL {
+			specPath = ""
+		}
+	}
+
+	m := CLIManifest{
+		SchemaVersion:        1,
+		GeneratedAt:          time.Now().UTC(),
+		PrintingPressVersion: version.Version,
+		APIName:              state.APIName,
+		CLIName:              naming.CLI(state.APIName),
+		SpecURL:              specURL,
+		SpecPath:             specPath,
+		RunID:                state.RunID,
+	}
+
+	// Detect spec format and compute checksum from the spec file in the
+	// working directory. spec.json only exists when specFlag is --spec;
+	// for --docs runs it won't be present and these fields stay empty.
+	specFile := filepath.Join(state.EffectiveWorkingDir(), "spec.json")
+	if data, err := os.ReadFile(specFile); err == nil {
+		m.SpecFormat = detectSpecFormat(data)
+		checksum, err := specChecksum(specFile)
+		if err == nil {
+			m.SpecChecksum = checksum
+		}
+	}
+
+	// Look up catalog entry by API name; empty string if not found.
+	if entry, err := catalogpkg.LookupFS(catalog.FS, state.APIName); err == nil {
+		m.CatalogEntry = entry.Name
+	}
+
+	return WriteCLIManifest(dir, m)
+}
+
 func CopyDir(src, dst string) error {
 	info, err := os.Stat(src)
 	if err != nil {
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 00000000..903b57ed
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,33 @@
+package version
+
+import (
+	"runtime/debug"
+	"strings"
+)
+
+// Version is the current printing-press version. It is set at build time
+// via ldflags for tagged releases, or falls back to the hardcoded value.
+var Version = "0.4.0" // x-release-please-version
+
+func init() {
+	info, ok := debug.ReadBuildInfo()
+	if !ok {
+		return
+	}
+	v := info.Main.Version
+	// Only use the build info version when it's a real tagged release.
+	// Skip empty, "(devel)", and pseudo-versions like "v0.0.0-20260328...".
+	if v == "" || v == "(devel)" {
+		return
+	}
+	trimmed := strings.TrimPrefix(v, "v")
+	if strings.HasPrefix(trimmed, "0.0.0-") {
+		return
+	}
+	Version = trimmed
+}
+
+// Get returns the current version string.
+func Get() string {
+	return Version
+}
diff --git a/internal/version/version_test.go b/internal/version/version_test.go
new file mode 100644
index 00000000..4e19bb21
--- /dev/null
+++ b/internal/version/version_test.go
@@ -0,0 +1,17 @@
+package version
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestVersionIsValidSemver(t *testing.T) {
+	assert.NotEmpty(t, Version)
+	assert.Regexp(t, regexp.MustCompile(`^\d+\.\d+\.\d+$`), Version)
+}
+
+func TestGetReturnsVersion(t *testing.T) {
+	assert.Equal(t, Version, Get())
+}
diff --git a/release-please-config.json b/release-please-config.json
index 40447b3a..af15b7cc 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -17,7 +17,7 @@
         },
         {
           "type": "generic",
-          "path": "internal/cli/root.go"
+          "path": "internal/version/version.go"
         }
       ]
     }

← c1219dec docs(cli): use brew for lefthook install instructions (#42)  ·  back to Cli Printing Press  ·  feat(skills): integrate sniff into printing-press skill work 334a52af →