Context
A template may rely on features (template functions, conditional-file syntax, computed values, hook contracts, …) that only exist in certain specs CLI versions. Today there is no way for a template author to declare a required CLI version, so users on an older build silently get broken renders or confusing parse errors.
This change introduces a reserved project.yaml key that lets template authors declare a version constraint for the specs CLI. When a user runs the template, the CLI checks its own version against the declared constraint and refuses to proceed (with a clear error) if it does not match.
The reserved key is __specs__version. Unlike a "minimal version" field, this accepts a full semver constraint string so template authors can express both lower and upper bounds.
Behaviour
project.yaml may declare a constraint:
Accepted values are any valid Masterminds/semver constraint string, e.g.:
| Value |
Meaning |
0.1.0 |
exactly 0.1.0 |
^0.1.0 |
>= 0.1.0, < 0.2.0 |
^1.1.1 |
>= 1.1.1, < 2.0.0 |
~0.1.0 |
>= 0.1.0, < 0.2.0 |
>= 0.1.0 |
>= 0.1.0 |
>= 0.1.0, < 2.0 |
explicit range |
Validation flow when loading a template:
- If the key is absent → no check, proceed.
- If present but not a string, or fails to parse as a
semver.NewConstraint → return ErrInvalidSpecsVersion (refuse to use the template).
- Parse the running CLI's
cmd.Version with semver.NewVersion.
- If the CLI version is
"dev" (the unset default) or otherwise unparseable, skip the check and emit a debug log (logger.Debug). Rationale: developers building from source must not be locked out, and this matches how latestSemverTag already tolerates unparseable versions.
- If the parsed CLI version does not satisfy the constraint → return
fmt.Errorf("%w: template requires specs %s, but this binary is %s", specs.ErrSpecsVersionUnsatisfied, constraintRaw, cliVersion). Wrapping with %w is mandatory — the project-wide convention introduced on branch 50-consistent-error-wrapping is that all sentinel errors must be wrapped so callers (and output.JSONWriter) can recover them via errors.Is and KindOf.
- The key is consumed — it must be deleted from the user-context map so it does not leak into template rendering as
.SpecsVersion or similar.
The check fires inside template.Get(), so it covers both specs use and specs template use. specs template save deliberately does not run the check — a user may want to save a newer template on an older CLI for later use or for sharing.
Files to modify
-
pkg/specs/configuration.go — add a ProjectSpecsVersionKey = "__specs__version" constant alongside ProjectDelimitersKey, and document it the same way.
-
pkg/specs/errors.go — add two sentinels alongside the existing ones:
ErrInvalidSpecsVersion — __specs__version is present but the wrong type or not a parseable semver constraint string.
ErrSpecsVersionUnsatisfied — running CLI version does not satisfy the constraint.
Both must be added as cases in KindOf() with stable kind strings invalid_specs_version and specs_version_unsatisfied (snake_case, matching the existing entries). These kind strings flow into JSON output as the error_kind field via output.JSONWriter.WriteErr, so script-friendly clients can detect them.
Every call site that returns one of these sentinels must wrap with %w (fmt.Errorf("%w: ...", specs.ErrSpecsVersionUnsatisfied, ...)), per the convention established in branch 50-consistent-error-wrapping (e.g. ErrInvalidComputedDef, ErrCyclicDependency, ErrProjectFileMissing all follow this pattern).
-
pkg/specs/errors_test.go — extend the existing tests added in branch 50-consistent-error-wrapping:
TestKindOf_KnownSentinels — add bare and wrapped cases for both new sentinels.
TestErrorsIs_AllSentinelsWrapCorrectly — append both new sentinels to the slice.
-
pkg/template/context.go:
- Add
ExtractSpecsVersion(templateRoot string) (constraint *semver.Constraints, raw string, err error) mirroring the ExtractProjectDelimiters shape. Returns (nil, "", nil) when the key is absent.
- In
LoadUserContext, add delete(raw, specs.ProjectSpecsVersionKey) next to the existing delete(raw, specs.ProjectDelimitersKey) line so it never reaches the user context.
-
pkg/template/template.go — in Get(), after ExtractProjectDelimiters and before LoadUserContext, call a new private checkSpecsVersion(templateRoot, logger) helper that:
- Calls
ExtractSpecsVersion.
- Compares against the CLI version passed in via
Config.Version (see "wiring" below).
- Returns the wrapped
ErrSpecsVersionUnsatisfied on mismatch.
-
Wiring the running version into template.Get() — pkg/template must not import pkg/cmd (would be a layering inversion). Two options:
- Add a
Version string field to template.Config and have the cmd layer pass cmd.Version in. Empty/"dev" skips the check. Preferred — same shape as the existing Delims and SafeMode config fields.
- Move the
Version variable from pkg/cmd into a new pkg/specs/version package and import that from pkg/template. More invasive; do this only if a second caller ends up needing it.
-
pkg/cmd/use.go and pkg/cmd/template_use.go — populate Config.Version with cmd.Version before calling template.Get.
-
Tests:
pkg/template/context_test.go — add cases for: missing key (ok), wrong type (error), invalid constraint string (error), valid constraint extracted, key stripped from user context.
pkg/template/template_test.go — add cases for: Get() succeeds when CLI version satisfies constraint, fails with ErrSpecsVersionUnsatisfied when it does not, skips the check when Config.Version is empty or "dev", fails with ErrInvalidSpecsVersion when the value is malformed.
pkg/cmd/use_test.go / template_use_test.go — one end-to-end case asserting the error surfaces from a real CLI invocation.
-
Docs (mandatory per AGENTS.md):
docs/content/docs/project-yaml.md — document the new key with a table of accepted constraint shapes.
docs/content/docs/architecture/template-engine.md — note the version gate as a pre-flight check during template load.
docs/content/docs/architecture/overview.md — add two new rows to the "Error Handling (pkg/specs/errors.go)" table for the two new sentinels and their kind strings; if it lists reserved __ keys, add __specs__version there too.
docs/content/docs/template-structure.md — add to the reserved-keys section if one exists alongside __delimiters.
-
README.md — update the project.yaml / reserved-keys section to mention __specs__version.
Critical files (existing patterns to mirror)
pkg/template/context.go:44-63 — ExtractProjectDelimiters is the exact shape to copy.
pkg/specs/configuration.go:26-33 — pattern for documenting the new reserved key constant.
pkg/util/git/git.go:354-375 — example of using semver.NewVersion; we will use semver.NewConstraint plus Constraints.Check(*semver.Version) here.
pkg/cmd/version.go:7-9 — Version variable, set via ldflags, defaults to "dev".
Out of scope (deliberate non-goals)
- Validation at
template save time. Saving a template that this CLI cannot currently use is a legitimate workflow.
- A per-invocation override flag (e.g.
--ignore-version-check). Can be added later if users actually ask for it; otherwise it just invites foot-guns.
Verification
go test ./pkg/template/... ./pkg/specs/... ./pkg/cmd/... — unit + cmd-level tests pass.
- Build with an injected version:
go build -ldflags "-X github.com/specsnl/specs-cli/pkg/cmd.Version=0.1.0" -o /tmp/specs ./cmd/specs.
- Create a tiny template with
__specs__version: ^0.2.0 in its project.yaml and run /tmp/specs template use ... — confirm it fails with the expected message.
- Repeat with
__specs__version: ^0.1.0 and confirm it succeeds.
- Run the same dev binary (
Version="dev") and confirm the check is skipped (with a debug log when --debug is on).
task lint and task test (per .github/instructions/executing-commands.md) before opening a PR.
Context
A template may rely on features (template functions, conditional-file syntax, computed values, hook contracts, …) that only exist in certain
specsCLI versions. Today there is no way for a template author to declare a required CLI version, so users on an older build silently get broken renders or confusing parse errors.This change introduces a reserved
project.yamlkey that lets template authors declare a version constraint for the specs CLI. When a user runs the template, the CLI checks its own version against the declared constraint and refuses to proceed (with a clear error) if it does not match.The reserved key is
__specs__version. Unlike a "minimal version" field, this accepts a full semver constraint string so template authors can express both lower and upper bounds.Behaviour
project.yamlmay declare a constraint:Accepted values are any valid Masterminds/semver constraint string, e.g.:
0.1.00.1.0^0.1.0>= 0.1.0, < 0.2.0^1.1.1>= 1.1.1, < 2.0.0~0.1.0>= 0.1.0, < 0.2.0>= 0.1.0>= 0.1.0>= 0.1.0, < 2.0Validation flow when loading a template:
semver.NewConstraint→ returnErrInvalidSpecsVersion(refuse to use the template).cmd.Versionwithsemver.NewVersion."dev"(the unset default) or otherwise unparseable, skip the check and emit a debug log (logger.Debug). Rationale: developers building from source must not be locked out, and this matches howlatestSemverTagalready tolerates unparseable versions.fmt.Errorf("%w: template requires specs %s, but this binary is %s", specs.ErrSpecsVersionUnsatisfied, constraintRaw, cliVersion). Wrapping with%wis mandatory — the project-wide convention introduced on branch50-consistent-error-wrappingis that all sentinel errors must be wrapped so callers (andoutput.JSONWriter) can recover them viaerrors.IsandKindOf..SpecsVersionor similar.The check fires inside
template.Get(), so it covers bothspecs useandspecs template use.specs template savedeliberately does not run the check — a user may want to save a newer template on an older CLI for later use or for sharing.Files to modify
pkg/specs/configuration.go— add aProjectSpecsVersionKey = "__specs__version"constant alongsideProjectDelimitersKey, and document it the same way.pkg/specs/errors.go— add two sentinels alongside the existing ones:ErrInvalidSpecsVersion—__specs__versionis present but the wrong type or not a parseable semver constraint string.ErrSpecsVersionUnsatisfied— running CLI version does not satisfy the constraint.Both must be added as cases in
KindOf()with stable kind stringsinvalid_specs_versionandspecs_version_unsatisfied(snake_case, matching the existing entries). These kind strings flow into JSON output as theerror_kindfield viaoutput.JSONWriter.WriteErr, so script-friendly clients can detect them.Every call site that returns one of these sentinels must wrap with
%w(fmt.Errorf("%w: ...", specs.ErrSpecsVersionUnsatisfied, ...)), per the convention established in branch50-consistent-error-wrapping(e.g.ErrInvalidComputedDef,ErrCyclicDependency,ErrProjectFileMissingall follow this pattern).pkg/specs/errors_test.go— extend the existing tests added in branch50-consistent-error-wrapping:TestKindOf_KnownSentinels— add bare and wrapped cases for both new sentinels.TestErrorsIs_AllSentinelsWrapCorrectly— append both new sentinels to the slice.pkg/template/context.go:ExtractSpecsVersion(templateRoot string) (constraint *semver.Constraints, raw string, err error)mirroring theExtractProjectDelimitersshape. Returns(nil, "", nil)when the key is absent.LoadUserContext, adddelete(raw, specs.ProjectSpecsVersionKey)next to the existingdelete(raw, specs.ProjectDelimitersKey)line so it never reaches the user context.pkg/template/template.go— inGet(), afterExtractProjectDelimitersand beforeLoadUserContext, call a new privatecheckSpecsVersion(templateRoot, logger)helper that:ExtractSpecsVersion.Config.Version(see "wiring" below).ErrSpecsVersionUnsatisfiedon mismatch.Wiring the running version into
template.Get()—pkg/templatemust not importpkg/cmd(would be a layering inversion). Two options:Version stringfield totemplate.Configand have the cmd layer passcmd.Versionin. Empty/"dev"skips the check. Preferred — same shape as the existingDelimsandSafeModeconfig fields.Versionvariable frompkg/cmdinto a newpkg/specs/versionpackage and import that frompkg/template. More invasive; do this only if a second caller ends up needing it.pkg/cmd/use.goandpkg/cmd/template_use.go— populateConfig.Versionwithcmd.Versionbefore callingtemplate.Get.Tests:
pkg/template/context_test.go— add cases for: missing key (ok), wrong type (error), invalid constraint string (error), valid constraint extracted, key stripped from user context.pkg/template/template_test.go— add cases for:Get()succeeds when CLI version satisfies constraint, fails withErrSpecsVersionUnsatisfiedwhen it does not, skips the check whenConfig.Versionis empty or"dev", fails withErrInvalidSpecsVersionwhen the value is malformed.pkg/cmd/use_test.go/template_use_test.go— one end-to-end case asserting the error surfaces from a real CLI invocation.Docs (mandatory per AGENTS.md):
docs/content/docs/project-yaml.md— document the new key with a table of accepted constraint shapes.docs/content/docs/architecture/template-engine.md— note the version gate as a pre-flight check during template load.docs/content/docs/architecture/overview.md— add two new rows to the "Error Handling (pkg/specs/errors.go)" table for the two new sentinels and their kind strings; if it lists reserved__keys, add__specs__versionthere too.docs/content/docs/template-structure.md— add to the reserved-keys section if one exists alongside__delimiters.README.md— update the project.yaml / reserved-keys section to mention__specs__version.Critical files (existing patterns to mirror)
pkg/template/context.go:44-63—ExtractProjectDelimitersis the exact shape to copy.pkg/specs/configuration.go:26-33— pattern for documenting the new reserved key constant.pkg/util/git/git.go:354-375— example of usingsemver.NewVersion; we will usesemver.NewConstraintplusConstraints.Check(*semver.Version)here.pkg/cmd/version.go:7-9—Versionvariable, set via ldflags, defaults to"dev".Out of scope (deliberate non-goals)
template savetime. Saving a template that this CLI cannot currently use is a legitimate workflow.--ignore-version-check). Can be added later if users actually ask for it; otherwise it just invites foot-guns.Verification
go test ./pkg/template/... ./pkg/specs/... ./pkg/cmd/...— unit + cmd-level tests pass.go build -ldflags "-X github.com/specsnl/specs-cli/pkg/cmd.Version=0.1.0" -o /tmp/specs ./cmd/specs.__specs__version: ^0.2.0in itsproject.yamland run/tmp/specs template use ...— confirm it fails with the expected message.__specs__version: ^0.1.0and confirm it succeeds.Version="dev") and confirm the check is skipped (with a debug log when--debugis on).task lintandtask test(per.github/instructions/executing-commands.md) before opening a PR.