← 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
M .goreleaser.yamlM AGENTS.mdA docs/brainstorms/2026-03-28-cli-manifest-requirements.mdA docs/plans/2026-03-28-002-feat-cli-manifest-plan.mdM internal/cli/release_test.goM internal/cli/root.goA internal/pipeline/climanifest.goA internal/pipeline/climanifest_test.goM internal/pipeline/publish.goA internal/version/version.goA internal/version/version_test.goM release-please-config.json
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 →