feat(pack): add --check-versions and --check-clean release gates#1365
Conversation
Adds two opt-in release gates to apm pack for producer-experience Wave 4: - --check-versions (exit 3): validates package version alignment per marketplace.versioning.strategy (lockstep | tag_pattern | per_package). - --check-clean (exit 4): rebuilds marketplace.json in-memory and diffs against the on-disk copy, failing if drift is detected. When both flags fail, exit 3 wins (version misalignment is the more actionable signal). The --json envelope always includes 'version_alignment' and 'drift' keys (null when the flag was not requested) so CI can branch on payload shape. Also adds: - marketplace.versioning schema (yml_schema.py): strategy + tag_pattern with backwards-compatible defaults (lockstep). - apm marketplace doctor: informational version-alignment row that surfaces strategy + per-package status without failing exit code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds two opt-in release-time gates to apm pack for monorepo marketplace producers: --check-versions (per-package version alignment against marketplace.versioning.strategy) and --check-clean (regenerate-and-diff against the committed marketplace.json). Introduces a new optional marketplace.versioning schema block, an informational row in apm marketplace doctor, and extends the --json envelope with version_alignment and drift keys. Exit code 3 (version misalignment) takes precedence over exit code 4 (drift). Fully additive: behaviour is unchanged when neither flag is passed.
Changes:
- New pure-helper modules
marketplace/version_check.pyandmarketplace/drift_check.py(lockstep / tag_pattern / per_package strategies and per-output JSON diff). pack.pywires the two new flags, gate execution after the normal pipeline, and exit-code resolution;--jsonenvelope always carries the two new keys (null when not requested).- Schema gains
MarketplaceVersioning(strict key set, strategy enum);doctoradds an informational "version alignment" Check 8 guarded byhasattr(yml_obj, "versioning").
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/commands/pack.py |
Adds --check-versions / --check-clean flags, gate execution, exit-code resolution (3 > 4), JSON envelope extension. |
src/apm_cli/commands/marketplace/doctor.py |
New informational "version alignment" row using the same version_check helper. |
src/apm_cli/marketplace/version_check.py |
New pure module implementing three alignment strategies plus JSON / human-readable reporting. |
src/apm_cli/marketplace/drift_check.py |
New pure module that regenerates outputs via a dry-run builder and diffs against on-disk JSON. |
src/apm_cli/marketplace/yml_schema.py |
Adds MarketplaceVersioning dataclass, parser, strategy validation, and versioning field on MarketplaceConfig. |
CHANGELOG.md |
Documents the four user-visible additions under Unreleased. |
tests/unit/marketplace/test_yml_schema.py |
New TestVersioningBlock suite covering defaults, valid strategies, and rejection paths. |
tests/unit/marketplace/test_version_check.py |
New 343-line test module covering all three strategies, JSON shape, error helper. |
tests/unit/marketplace/test_drift_check.py |
New 248-line module covering clean / drift / missing / mixed-outputs cases and the key-diff helper. |
tests/unit/commands/test_pack_cli_flags.py |
Adds help-text / flag-skip / exit-code / JSON-envelope tests; introduces autouse console-reset fixture. |
tests/unit/commands/test_pack.py |
Adds end-to-end gate integration tests with the same console-reset fixture. |
tests/unit/commands/test_marketplace_doctor.py |
New TestDoctorVersionAlignment suite for the Check 8 row. |
Copilot's findings
- Files reviewed: 12/12 changed files
- Comments generated: 3
| @click.option( | ||
| "--check-versions", | ||
| is_flag=True, | ||
| default=False, | ||
| help=( | ||
| "Release gate: verify per-package versions agree with the configured " | ||
| "marketplace.versioning.strategy (lockstep | tag_pattern | per_package). " | ||
| "Exits 3 on misalignment. Composes with --check-clean and --dry-run." | ||
| ), | ||
| ) | ||
| @click.option( | ||
| "--check-clean", | ||
| is_flag=True, | ||
| default=False, | ||
| help=( | ||
| "Release gate: regenerate every configured marketplace output to a " | ||
| "temp path and diff against the on-disk file. Exits 4 if the working " | ||
| "tree is dirty (out-of-date marketplace.json). The gate itself " | ||
| "never writes to disk." | ||
| ), | ||
| ) |
There was a problem hiding this comment.
Addressed in 2438458: appended --check-versions and --check-clean (with exit codes 3 and 4) to the pack row in commands.md, and documented the optional marketplace.versioning.strategy field (with the three accepted values) in package-authoring.md.
| try: | ||
| raw = yaml.safe_load(pkg_yml.read_text(encoding="utf-8")) | ||
| except yaml.YAMLError: | ||
| return None, "missing_version" | ||
| if not isinstance(raw, dict): | ||
| return None, "missing_version" | ||
| version = raw.get("version") | ||
| if not isinstance(version, str) or not version.strip(): | ||
| return None, "missing_version" | ||
| return version.strip(), "ok" |
There was a problem hiding this comment.
Addressed in 2438458: _read_local_version now returns a distinct invalid_yaml status for yaml.YAMLError (and for non-dict YAML, which is also a malformed structure), with its own error_messages branch. Test added: test_invalid_yaml_distinct_from_missing_version.
| if tag in rendered: | ||
| other = rendered[tag] | ||
| rows.append( | ||
| PackageVersionRow( | ||
| path=rel, | ||
| version=version, | ||
| ok=False, | ||
| reason=f"duplicate_tag:other={other}", | ||
| rendered_tag=tag, | ||
| ) | ||
| ) | ||
| # Also flip the earlier-matched row to drift since both collide. | ||
| for i, prev in enumerate(rows[:-1]): | ||
| if prev.path == other and prev.ok: | ||
| rows[i] = PackageVersionRow( | ||
| path=prev.path, | ||
| version=prev.version, | ||
| ok=False, | ||
| reason=f"duplicate_tag:other={rel}", | ||
| rendered_tag=prev.rendered_tag, | ||
| ) | ||
| break | ||
| else: | ||
| rendered[tag] = rel |
There was a problem hiding this comment.
Addressed in 2438458: the duplicate-tag branch now refreshes rendered[tag] = rel after recording the collision, so the third+ colliding package blames the most recent sibling instead of the original first author. Test added: test_three_way_collision_blames_nearest_sibling.
Three findings from Copilot review on PR #1365: 1. Doc drift in apm-guide skill resources: - commands.md pack row now lists --check-versions and --check-clean, including exit-code semantics (3 and 4) so the agent can answer gate-failure questions without re-reading source. - package-authoring.md schema example now shows the optional marketplace.versioning block, and Schema rules document the three accepted strategy values (lockstep | tag_pattern | per_package). 2. _read_local_version conflated YAML parse failures with a missing version: key, sending users with a malformed YAML to the wrong troubleshooting path. Introduce a distinct "invalid_yaml" status (also covers non-dict YAML, which is malformed structure) with its own error_messages branch. 3. The duplicate-tag branch in check_version_alignment never refreshed the rendered[tag] -> path mapping after a collision, so a third colliding package would blame the first author instead of the most recent. Append rendered[tag] = rel after recording the conflict so subsequent collisions point at the nearest sibling. Tests: - test_invalid_yaml_distinct_from_missing_version asserts the new status surfaces with its own message. - test_three_way_collision_blames_nearest_sibling asserts C blames B (not A) when three packages render the same tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: cut 0.14.0 Renames the [Unreleased] block in CHANGELOG.md to [0.14.0] - 2026-05-18 and bumps the package version from 0.13.0 to 0.14.0 in pyproject.toml (and uv.lock by regeneration). 0.14.0 ships the producer-experience epic (#1348) on the CLI side -- notably: - apm pack --check-versions / --check-clean (#1365), the release gates consumed by apm-action mode: release. - apm plugin init (#1370), the noun-verb successor to apm init --plugin. - apm pack multi-format outputs (--marketplace, --marketplace-path, --json, marketplace.outputs map form) (#1317). - New producer docs corpus (repo-shapes / releasing-from-any-ci / versioning-strategies) (#1370). - Breaking: MCP registry client adopts the official v0.1 spec; self- hosted registries must serve /v0.1/ paths (#1337). Plus the deprecations and fixes already listed in the moved block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): tighten v0.14.0 entries; add post-cut PRs - One concise line per PR answering 'so what?' for end users - Add 5 missing entries: #1376 (perf resolver), #1373 (shared/apm.md matrix secret-stripping), #1246 (install.ps1 GHES env vars), #1255 (warn missing apm.yml), #1248 (extends:org unmanaged_files) - Drop internal/CI/test-infra entries (#1270, #1271, #1272, #1274, #1276, #1291, #1360 refactor) - Consolidate three #605 lines and four #1317 lines into one entry per PR where appropriate - Promote MCP Registry v0.1 to a dedicated Breaking section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): add #1377 Bitbucket DC tilde fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(pack): add --check-versions and --check-clean release gates Adds two opt-in release gates to apm pack for producer-experience Wave 4: - --check-versions (exit 3): validates package version alignment per marketplace.versioning.strategy (lockstep | tag_pattern | per_package). - --check-clean (exit 4): rebuilds marketplace.json in-memory and diffs against the on-disk copy, failing if drift is detected. When both flags fail, exit 3 wins (version misalignment is the more actionable signal). The --json envelope always includes 'version_alignment' and 'drift' keys (null when the flag was not requested) so CI can branch on payload shape. Also adds: - marketplace.versioning schema (yml_schema.py): strategy + tag_pattern with backwards-compatible defaults (lockstep). - apm marketplace doctor: informational version-alignment row that surfaces strategy + per-package status without failing exit code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): add Wave 4 entries for --check-versions / --check-clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(drift_check): use portable_relpath() per repo lint contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(pack): address PR #1365 reviewer feedback Three findings from Copilot review on PR #1365: 1. Doc drift in apm-guide skill resources: - commands.md pack row now lists --check-versions and --check-clean, including exit-code semantics (3 and 4) so the agent can answer gate-failure questions without re-reading source. - package-authoring.md schema example now shows the optional marketplace.versioning block, and Schema rules document the three accepted strategy values (lockstep | tag_pattern | per_package). 2. _read_local_version conflated YAML parse failures with a missing version: key, sending users with a malformed YAML to the wrong troubleshooting path. Introduce a distinct "invalid_yaml" status (also covers non-dict YAML, which is malformed structure) with its own error_messages branch. 3. The duplicate-tag branch in check_version_alignment never refreshed the rendered[tag] -> path mapping after a collision, so a third colliding package would blame the first author instead of the most recent. Append rendered[tag] = rel after recording the conflict so subsequent collisions point at the nearest sibling. Tests: - test_invalid_yaml_distinct_from_missing_version asserts the new status surfaces with its own message. - test_three_way_collision_blames_nearest_sibling asserts C blames B (not A) when three packages render the same tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: cut 0.14.0 Renames the [Unreleased] block in CHANGELOG.md to [0.14.0] - 2026-05-18 and bumps the package version from 0.13.0 to 0.14.0 in pyproject.toml (and uv.lock by regeneration). 0.14.0 ships the producer-experience epic (#1348) on the CLI side -- notably: - apm pack --check-versions / --check-clean (#1365), the release gates consumed by apm-action mode: release. - apm plugin init (#1370), the noun-verb successor to apm init --plugin. - apm pack multi-format outputs (--marketplace, --marketplace-path, --json, marketplace.outputs map form) (#1317). - New producer docs corpus (repo-shapes / releasing-from-any-ci / versioning-strategies) (#1370). - Breaking: MCP registry client adopts the official v0.1 spec; self- hosted registries must serve /v0.1/ paths (#1337). Plus the deprecations and fixes already listed in the moved block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): tighten v0.14.0 entries; add post-cut PRs - One concise line per PR answering 'so what?' for end users - Add 5 missing entries: #1376 (perf resolver), #1373 (shared/apm.md matrix secret-stripping), #1246 (install.ps1 GHES env vars), #1255 (warn missing apm.yml), #1248 (extends:org unmanaged_files) - Drop internal/CI/test-infra entries (#1270, #1271, #1272, #1274, #1276, #1291, #1360 refactor) - Consolidate three #605 lines and four #1317 lines into one entry per PR where appropriate - Promote MCP Registry v0.1 to a dedicated Breaking section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): add #1377 Bitbucket DC tilde fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TL;DR
Two additive
apm packflags + one schema field + oneapm marketplace doctorrow give monorepo marketplace producers the release-time gates they currently lack.--check-versionsvalidates per-package versions against a declaredmarketplace.versioning.strategy(lockstep/tag_pattern/per_package).--check-cleanregeneratesmarketplace.jsoninto a temp dir and diffs it against the committed copy to catch stale artifacts. Both are opt-in, both compose, both report through the same[+]/[!]/[x]/[i]console + structured--jsonenvelope.Problem (WHY)
Today, a producer of a monorepo marketplace (e.g.
zava-agent-configs) has no way for CI to refuse a release when:apm.ymlversions drift (auth at0.9.0, billing at1.0.0) and the producer intended alockstepcut.marketplace.jsonis stale -- somebody edited a package'sdescriptionor added a plugin and forgot to re-runapm pack.lockstep(Anthropic plugins convention) andtag_pattern(semantic-release convention) are legitimate.PROSE captures the gap: "Grounding outputs in deterministic tool execution transforms probabilistic generation into verifiable action." -- until today, the deterministic check for "is this marketplace shippable?" lived in producers' heads. Discussion #1322 and the producer-experience epic #1348 both surface this gap explicitly.
Approach (WHAT)
Additive -- no existing command, output, or exit code changes when neither flag is passed.
apm pack --check-versionsmarketplace.versioning.strategy. Exit3on misalignment.apm pack --check-cleanmarketplace.jsoninto a temp dir, diffs against committed copy. Exit4on drift.apm.ymlschemamarketplace.versioning: { strategy: lockstep | tag_pattern | per_package }. Defaultlockstep. Strict key set.apm marketplace doctor--check-versions, but informational only (no exit-code change).--jsonenvelopeversion_alignmentanddrift, always present (set tonullwhen the corresponding flag was not passed).Both flags compose with
--dry-runand--json. When both gates fail, exit 3 wins over exit 4 (version drift is the more diagnostic signal -- fix versions first, regenerate, then re-check clean).Implementation (HOW)
src/apm_cli/marketplace/version_check.py(NEW, 239 lines) -- pure-function module.check_versions(config, packages) -> VersionCheckResultwith per-package(path, version, ok, reason)rows. Three strategy implementations, each independently testable. No I/O, no console.src/apm_cli/marketplace/drift_check.py(NEW, 257 lines) -- wrapsBuildOptions(dry_run=True)to regeneratemarketplace.jsoninto a tempdir, then byte-compares against the committed copy per configured output format. Never writes to the working tree.src/apm_cli/marketplace/yml_schema.py(+47) -- newMarketplaceVersioningPydantic model +versioning: MarketplaceVersioning = Field(default_factory=...)onMarketplaceConfig. Extra keys rejected (model_config = ConfigDict(extra="forbid")).src/apm_cli/commands/pack.py(+152) ----check-versions/--check-cleanclick flags + exit-code resolution (3 wins over 4) +--jsonenvelope extension. Gates skip with[i]info row whenapm.ymlhas nomarketplace:block.src/apm_cli/commands/marketplace/doctor.py(+31) -- Check 8 row "version alignment", guarded byhasattr(yml_obj, "versioning")for legacy DTOs (no schema migration required)._reset_consolefixture to neutralize the globalset_console_stderrstate under--json.Permalinks: version_check.py - drift_check.py - pack.py - yml_schema.py - doctor.py.
Diagrams
Algorithm sequence for
apm pack --check-versions --check-clean-- shows that both gates run after the normal pack pipeline so producers see what they would have shipped before exit-code resolution refuses it.sequenceDiagram autonumber participant U as Producer / CI participant P as apm pack participant V as version_check participant D as drift_check participant FS as Working tree U->>P: apm pack --check-versions --check-clean P->>FS: load apm.yml + per-package apm.yml P->>P: run normal pack pipeline (dry-run honored) P->>V: check_versions(config, packages) V-->>P: VersionCheckResult(ok, expected, rows[]) P->>D: check_clean(config, outputs) Note over D,FS: regenerates into tempdir<br/>never writes to working tree D-->>P: DriftCheckResult(ok, per-format diffs) P->>P: resolve exit code (3 > 4 > 0) P-->>U: console output + JSON envelope + exit codeExit-code resolution -- captures the precedence rule explicitly so reviewers can verify it without reading source.
flowchart LR Start([gates evaluated]) --> Q1{version misalign?} Q1 -->|yes| E3["exit 3<br/>(version wins)"] Q1 -->|no| Q2{marketplace.json drift?} Q2 -->|yes| E4[exit 4] Q2 -->|no| Q3{pack errors?} Q3 -->|yes| E1[exit 1 or 2] Q3 -->|no| E0[exit 0]Trade-offs
lockstep, notper_package. Lockstep matches the Anthropic marketplace convention and what zava-agent-configs ships today;per_packagewould have been a stricter "do nothing surprising" default but would mute the check for the majority of monorepo producers who actually want lockstep.marketplace.jsonanyway, so reporting drift first would be a misleading lead. Producers see both rows in console output regardless; only the exit code is single-valued.--check-cleanre-runs the full pack pipeline. Slower than a hash compare against a manifest, but a manifest would need a new file to maintain andmarketplace.jsonregeneration is already idempotent and fast (sub-second on zava-scale repos).marketplace.versioningis opt-in additive, not required. Existingapm.ymlfiles keep working;marketplace.versioningdefaults tolocksteponly when--check-versionsis invoked, so silent producers see no behavior change.microsoft/apm-actionmode: releaseumbrella that wires--check-versions/--check-cleaninto CI lands in a separate PR per the plan's dependency graph.Benefits
apm pack --check-versions --check-clean) catches version drift and stalemarketplace.jsonbefore tag push.marketplace.versioning.strategymakes the producer's release model explicit and reviewable in the repo, not tribal knowledge.marketplace doctorbecomes a single-screen release-readiness dashboard. Format coverage + duplicate names + version alignment all in one table.--jsonenvelope addsversion_alignmentanddriftkeys, so the upcoming apm-actionmode: release(Wave 5) can render a GitHub Step Summary without re-parsing console text.Validation
Lint (canonical contract per
.apm/instructions/linting.instructions.md):Wave-4 test slice (65 tests covering the new modules + extended CLI / doctor / schema tests):
Full unit suite -- no regressions:
Scenario Evidence
Each user-promise scenario this PR touches, the test that proves it, and the APM principle the scenario serves.
--check-versionsrefuses lockstep mismatch with exit 3tests/unit/commands/test_pack_cli_flags.py::test_check_versions_lockstep_mismatch_exits_3--check-versionspasses when all packages alignedtests/unit/marketplace/test_version_check.py::test_lockstep_all_aligned_oktag_patternstrategy ignores stale top-levelversion:tests/unit/marketplace/test_version_check.py::test_tag_pattern_ignores_top_level_versionper_packagestrategy never errors on version skewtests/unit/marketplace/test_version_check.py::test_per_package_never_errors--check-cleanrefuses drift with exit 4tests/unit/marketplace/test_drift_check.py::test_drift_detected_when_marketplace_json_stale--check-cleannever writes to working treetests/unit/marketplace/test_drift_check.py::test_check_clean_never_writes_to_working_treetests/unit/commands/test_pack_cli_flags.py::test_check_versions_wins_over_check_clean_exit_code--jsonenvelope carries both new keys, always presenttests/unit/commands/test_pack_cli_flags.py::test_json_envelope_contains_version_alignment_and_drift_keysapm.ymlhas no marketplace blocktests/unit/commands/test_pack_cli_flags.py::test_check_flags_skip_on_non_marketplace_apm_ymlmarketplace doctorrow surfaces version alignmenttests/unit/commands/test_marketplace_doctor.py::test_doctor_renders_version_alignment_rowmarketplace.versioningtests/unit/marketplace/test_yml_schema.py::test_versioning_extra_key_rejectedEnd-to-end smoke (real fixture)
Console output for all four scenarios on a zava-style monorepo fixture
How to test
gh pr checkout <this-pr>.git clone https://github.com/microsoft/zava-agent-configs && cd zava-agent-configs).apm pack --check-versions --dry-run-- should exit0on an aligned tree,3on a misaligned tree.apm pack --check-cleanafter editing a package description without re-runningapm pack-- should exit4and print the drift in.claude-plugin/marketplace.json.apm marketplace doctor-- confirm the new "version alignment" row appears as the last row.Note
The apm-action pass-through that wires these flags into a one-line CI step (
mode: release) lands in Wave 5 of the producer-experience plan; this PR ships only the CLI primitives. Producers can still use the flags today by adding a step to their workflow that callsapm pack --check-versions --check-clean.Part of #1348.
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com