feat(pack): --create-tag and --push to materialize release tags (#1489)#1497
feat(pack): --create-tag and --push to materialize release tags (#1489)#1497danielmeppiel wants to merge 5 commits into
Conversation
Collapse the bump/tag/push handshake into the pack command. After the existing --check-versions gate passes on a clean tree, --create-tag materialises the release tag(s) derived from marketplace.versioning (one v<version> for single_version, one <name>-v<version> per package for per_package), and --push pushes them to 'origin' via explicit refspecs (refs/tags/<name>:refs/tags/<name>) -- never 'git push --tags'. Refusals are first-class JSON contract codes (dirty_tree, tag_exists, version_mismatch, no_remote, push_without_tag, no_check_versions, no_marketplace, git_failure) exposed at tag_creation.refusal_code in the --json envelope and exit with code 1. Existing gate exits (3, 4) are unchanged. --dry-run previews everything without touching git. Closes #1489 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds release-tag materialization to apm pack, letting marketplace producers create and optionally push version-derived git tags after the existing version gate succeeds.
Changes:
- Adds
GitTaggerandrun_git()helpers for planning, preflighting, creating, and pushing release tags. - Wires
--create-tagand--pushintoapm pack, including JSON envelope fields and exit-code handling. - Adds unit/integration coverage plus documentation, guide, changelog, and lint-threshold updates.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/release/git_tagger.py |
Implements tag planning, preflight checks, creation, and explicit-refspec push logic. |
src/apm_cli/release/__init__.py |
Re-exports release helper APIs. |
src/apm_cli/utils/git_subprocess.py |
Adds shared sanitized git subprocess wrapper. |
src/apm_cli/commands/pack.py |
Adds pack flags, tagging orchestration, JSON payloads, and exit handling. |
pyproject.toml |
Raises pylint argument threshold for the expanded pack_cmd. |
tests/unit/release/test_git_tagger.py |
Adds unit coverage for tagger lifecycle behavior. |
tests/unit/release/conftest.py |
Adds hermetic git repository fixtures for release tests. |
tests/unit/release/__init__.py |
Adds release test package marker. |
tests/unit/commands/test_pack_tagging.py |
Adds unit coverage for apm pack tagging flag wiring and JSON behavior. |
tests/unit/commands/conftest.py |
Adds command-test git helper fixture. |
tests/integration/release/test_pack_tagging_e2e.py |
Adds hermetic end-to-end pack/tag/push tests. |
tests/integration/release/__init__.py |
Adds integration release test package marker. |
docs/src/content/docs/producer/releasing-from-any-ci.md |
Documents one-shot tagging workflow. |
packages/apm-guide/.apm/skills/apm-usage/commands.md |
Updates apm usage guide command reference. |
CHANGELOG.md |
Adds Unreleased entry for the new pack tagging flags. |
Copilot's findings
- Files reviewed: 13/15 changed files
- Comments generated: 3
Blocking fixes: - JSON envelope: source error code from tag_push_payload when push refuses (creation succeeded), instead of always reading tag_creation_payload (whose refusal_code is None on success). Regression-trapped by a new unit test. - Docs/CHANGELOG/PR_BODY: replace fabricated 'single_version' strategy name with the real strategies (lockstep, tag_pattern, per_package) per src/apm_cli/marketplace/yml_schema.py. - docs/reference/cli/pack.md: add --create-tag/--push rows and expand exit-code table with rows 1 (refusal codes), 3 (--check-versions), 4 (--check-clean). Recommended fixes: - git_tagger.create: pass '--' before plan.name to git tag (option injection hardening if tag_pattern ever yields a name starting with '-'). - git_tagger.push: refuse remote names starting with '-' (defensive, pack.py hardcodes 'origin'). - git_tagger._existing_remote_tags: elevate ls-remote failure log from info -> warning; rewording clarifies fail-open behavior. - pack._run_tagging: render TaggingRefusal.hint in create/push catch blocks (previously only preflight rendered it). - docs/producer/publish-to-a-marketplace.md: replace 'git tag X && git push --tags' anti-pattern with the new one-shot flow. - docs/producer/versioning-strategies.md, producer/index.md: forward-link to the one-shot tagging flow. - docs/producer/releasing-from-any-ci.md: tighten wording, document explicit-refspec push invariant, drop the dismissive 'intended for local one-shot releases' line. - CHANGELOG: trim Unreleased entry. Test coverage: - Add no_marketplace refusal test (bundle-only project + --create-tag). - Add regression test asserting tag_push refusal_code propagates into envelope.errors[].code (the blocking JSON bug). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| python-architect | 1 | 1 | 0 | Module boundaries are clean; JSON envelope sourcing bug now fixed and regression-trapped. |
| doc-writer | 2 | 3 | 1 | single_version fabrication, missing reference/cli/pack.md update, and lingering git push --tags anti-pattern in publish guide all addressed. |
| devx-ux-expert | 1 | 2 | 1 | CLI reference parity restored; one-shot flow now cross-linked from versioning-strategies and producer/index. |
| cli-logging-expert | 0 | 1 | 1 | Refusal hint now renders in create/push catch blocks (was preflight-only). |
| supply-chain-security | 0 | 2 | 1 | -- separator on git tag; ls-remote fail-open log elevated to warning. |
| oss-growth-hacker | 1 | 2 | 0 | Terminology and dismissive disclaimer wording addressed; CHANGELOG entry trimmed. |
| test-coverage-expert | 0 | 1 | 0 | Added no_marketplace refusal test on bundle-only project; new regression test pins envelope-sourcing fix. |
| apm-primitives-architect | 0 | 0 | 0 | Not applicable to this surface. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [python-architect] Promote or inline
_is_local_package-- currently imported as a cross-module private (from apm_cli.commands.pack import _is_local_packageinsiderelease/git_tagger.pycallers). Either expose it asapm_cli.marketplace.utils.is_local_packageor inline the two-line predicate at the call site. Low urgency; do as a separate clean-up PR. - [supply-chain-security] Surface
remote_check_skipped: trueas an explicit field intag_creation_payloadwhen ls-remote fails open. Today the warning log communicates it; CI matrices that key off the JSON envelope cannot see it. Small, additive. - [doc-writer] Add a short troubleshooting matrix to
producer/releasing-from-any-ci.mdmapping each refusal code to "what changed in the repo since the last clean release" -- e.g.tag_exists-> "previous release pipeline died after tag was pushed but before remote artifacts published". The codes are stable enough to document statically. - [devx-ux-expert] When
dirty_treerefusal fires, include agit status -scapture in the refusal message (already happens), but also suggest the exactgit stash --include-untrackedre-run command inrefusal.hint. Single-line UX win. - [test-coverage-expert] Consider a third integration test asserting the idempotent re-run behavior: after a successful
--create-tag --push, a second invocation must refuse withtag_exists(not silently re-tag). The plumbing exists; the cross-flow assertion does not.
Architecture
classDiagram
class GitTagger {
+plan_tags(strategy, marketplace_version, packages, tag_pattern) List~TagPlan~
+preflight(plans, remote) None
+create(plans) List~str~
+push(tag_names, remote) List~str~
-_existing_remote_tags(remote, candidates)
}
class TagPlan {
+name: str
+annotation: str
+target_sha: str
}
class TaggingRefusal {
+code: str
+message: str
+hint: str
}
class PackCommand {
+_run_tagging(...) tuple
}
PackCommand --> GitTagger : owns
GitTagger --> TagPlan : produces
GitTagger ..> TaggingRefusal : raises
sequenceDiagram
participant U as User
participant P as apm pack
participant V as version_gate
participant D as drift_gate
participant T as GitTagger
participant G as git
U->>P: --check-versions --create-tag --push
P->>V: run
V-->>P: ok
P->>D: run (if --check-clean)
D-->>P: ok
P->>T: plan_tags + preflight (origin)
T->>G: ls-remote --tags origin (auth-delegated)
G-->>T: tag list (or fail-open warn)
T-->>P: TagPlans
P->>T: create()
T->>G: git tag -a -m MSG -- NAME
G-->>T: ok
P->>T: push(["NAME"], remote="origin")
T->>G: git push origin refs/tags/NAME:refs/tags/NAME
G-->>T: ok
T-->>P: pushed
P-->>U: exit 0, JSON envelope with tag_creation + tag_push
Recommendation
ship_with_followups. The fold-in commit (a06d6a59) addresses every blocking-severity finding the panel raised and the high-signal recommended ones. Land this once CI is green; the remaining follow-ups (promote _is_local_package, surface remote_check_skipped JSON, idempotent-re-run integration test, troubleshooting matrix) are clean separate-PR work that should not gate the user-visible feature.
Full per-persona findings
python-architect
- [blocking] JSON envelope sources error code from
tag_creation_payloadeven when the refusal originated intag_push_payload, soenvelope.errors[].codebecomesnullon push-only failures. Fixed ina06d6a59(src/apm_cli/commands/pack.py~L506) by derivingrefusal_source = tag_push_payload if tag_push_payload.get('refusal_code') else tag_creation_payload; regression-trapped bytests/unit/commands/test_pack_tagging.py::test_pack_json_envelope_sources_error_from_tag_push_when_push_refuses. - [recommended]
_is_local_packageis imported as a cross-module private. Promote toapm_cli.marketplace.utilsor inline. Tracked as follow-up Why do we need a GitHub token? #1.
doc-writer
- [blocking]
single_versionstrategy name does not exist. Real strategies insrc/apm_cli/marketplace/yml_schema.py:232are{lockstep, tag_pattern, per_package}. Fixed ina06d6a59acrossCHANGELOG.md,docs/src/content/docs/producer/releasing-from-any-ci.md:70, andPR_BODY.md. - [blocking]
docs/src/content/docs/reference/cli/pack.mdwas not updated with--create-tag/--pushrows, and the exit-code table did not call out tag refusal codes 1, 3, 4. Fixed ina06d6a59. - [recommended]
docs/src/content/docs/producer/publish-to-a-marketplace.md:35still taught thegit tag && git push --tagsanti-pattern that this PR is designed to replace. Fixed ina06d6a59with a forward-pointer to the one-shot flow. - [recommended] Cross-link from
versioning-strategies.mdandproducer/index.md. Fixed ina06d6a59. - [recommended] CHANGELOG entry was ~200 words in one dense paragraph. Fixed in
a06d6a59(trimmed and reflowed). - [nit] Troubleshooting matrix mapping refusal codes to recovery actions. Tracked as follow-up Will there be MCP coverage? #3.
devx-ux-expert
- [blocking]
reference/cli/pack.mdparity gap (same surface as doc-writer's blocking item). Fixed ina06d6a59. - [recommended] Strategy-name terminology mismatch (
single_versionvslockstep). Fixed. - [recommended] Refusal hint should include exact re-run command when relevant (e.g.
git stashfor dirty tree). Tracked as follow-up Add ARM64 Linux support to CI/CD pipeline #4. - [nit] "Intended for local one-shot releases" disclaimer wording felt apologetic; reframed in
a06d6a59.
cli-logging-expert
- [recommended]
TaggingRefusal.hintwas rendered only in the preflight catch block inpack.py::_run_tagging; the create and push catch blocks dropped it. Fixed ina06d6a59(pack.py~L648, ~L671). - [nit] Consider tagging the warning when ls-remote fails so log scrapers can key off it. Partially addressed by elevating the level to
warningwith explicit "fail-open" wording.
supply-chain-security
- [recommended]
git tag -a -m MSG NAMEcould parseNAMEas an option if a futuretag_patternyielded a name starting with-. Fixed ina06d6a59by inserting--separator (git_tagger.py:266). - [recommended] Elevate ls-remote failure log from
infotowarning; consider exposingremote_check_skippedin JSON. Log elevation fixed ina06d6a59; JSON exposure tracked as follow-up Integrate copilot runtime #2. - [nit]
git pushremote name could also start with-. Defensive guard added ina06d6a59on top ofpack.pyhardcodingorigin.
oss-growth-hacker
- [blocking] Strategy-name terminology mismatch propagates to public-facing announce surfaces (CHANGELOG, PR body). Fixed in
a06d6a59. - [recommended] CHANGELOG entry density hurts the release-notes scan. Trimmed in
a06d6a59. - [recommended] Drop the dismissive "intended for local one-shot releases" line -- it discourages exactly the CI users this flow serves. Fixed.
test-coverage-expert
- [recommended] No test exercised the
no_marketplacerefusal throughapm pack --create-tagon a bundle-only project. Added ina06d6a59(tests/unit/commands/test_pack_tagging.py::test_pack_create_tag_refuses_no_marketplace_on_bundle_only_project). - (Implicit on the JSON envelope fix) New test
test_pack_json_envelope_sources_error_from_tag_push_when_push_refusesis the regression-trap for the python-architect blocking finding.
apm-primitives-architect
No findings. PR does not touch .apm/, .github/, skills, agents, or workflow primitives.
auth-expert -- inactive
Git operations delegate to git's own credential resolution; APM AuthResolver and token_manager are unchanged. The auth-protocol boundary lint (scripts/lint-auth-signals.sh) is clean.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Completion confirmation: ready to mergeVerified branch Panel follow-throughAll blocking findings from the apm-review-panel (#issuecomment-4549935384) addressed in fold-in commit
Verification evidenceCI: 13 SUCCESS (Lint, both Build & Test shards, Coverage Combine, APM Self-Check, PR Binary Smoke, CodeQL+Analyze x2, NOTICE Drift, gate, build, license/cla). Lint contract (all silent / exit 0):
Targeted tests: File-length and structure
Recommendation: ready to merge. Follow-ups #1-#5 from the panel comment remain as separate clean-up PRs and do not gate this feature. Completion subagent (batch-bug-shepherd) |
Adds 5 e2e tests against real git fixtures (working repo + local bare
remote) to defend the user-visible promises of
'apm pack --check-versions --create-tag --push' that the existing
suite did not exercise end-to-end:
- B: 'strategy: tag_pattern' + 'build.tagPattern' renders the
configured template (one tag per package, both local and remote).
- F: '--check-versions' failure exits 3 AND leaves NO tag on disk
(closes the silent-side-effect gap the unit-tier test did not check).
- G: idempotent re-run refuses cleanly with 'tag_exists' (exit 1) and
produces no duplicate tag.
- E: push refuses fail-closed when the tag already exists on origin
(via 'ls-remote' preflight) -- proven by staging the remote tag
from a sibling clone before the run.
- J: '--json' envelope keeps 'tag_creation' / 'tag_push' shape stable
across success AND refusal in one back-to-back invocation.
Each new test is mutation-break verified: deleting the production
guard it defends causes the assertion to fail. Mutation log:
- B: 'pattern = "{name}-v{version}"' in plan_tags -> assertion fails
- F: drop 'return None, None, False' on gate fail -> v5.0.0 leaks
- G: 'if False and existing_local' -> refusal_code becomes git_failure
- E: 'if False and existing_remote' -> exit 0 with full success
- J: drop 'refusal_code: None' from success payload -> KeyError
No production code changed; tests-only delta.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
E2E test-coverage addendum (test-coverage panelist)Folded 5 mutation-break-gated e2e tests into
Fixture strategy: real git fixtures throughout (working repo + local bare Each test was mutation-break verified: deleting the production guard it defends caused the test to fail. Restored before push; production code unchanged (tests-only delta, +353 LOC). CI green: lint, ruff format, R0801, auth-signal, all test shards, binary smoke. No drift, no Status: ready-to-merge from the test-coverage angle. |
…push # Conflicts: # CHANGELOG.md
Closing: YAGNI on this surfaceAfter review, closing this PR (and #1489 as not-planned). The engineering is solid — drift gates, refusal codes, dry-run, JSON output, e2e tests all worked as designed. What changed is the strategic read of whether the surface belongs in APM at all. The real gap is ~30 seconds of
|
feat(pack):
--create-tagand--pushto materialize release tags (#1489)TL;DR
apm pack --check-versions --create-tag --pushcollapses the bump → tag → push handshake into one gated command. After the existing version gate passes on a clean tree, the CLI materialises the tag(s) derived frommarketplace.versioningand pushes them tooriginvia explicit refspecs — nevergit push --tags. Refusals are first-class JSON contract codes (dirty_tree,tag_exists,version_mismatch,no_remote,push_without_tag,no_check_versions,no_marketplace,git_failure) surfacing attag_creation.refusal_codeand exiting1; the existing gate codes3and4are unchanged.Closes #1489.
Problem (WHY)
The current release dance is five mechanical steps the CLI already has the information to perform:
marketplace.yml, runsapm pack --check-versions --check-clean, eyeballs the report, then manually typesgit tag vX.Y.Z && git push origin vX.Y.Z. Steps 4–5 are error-prone: tag-name typos, forgotten push, or pushing the wrong commit when localHEADhas drifted fromorigin/main. Issue feat(pack): --create-tag and --push flags to create and push release tag after --check-versions #1489 lists this as the motivating friction.--check-versionsfrommarketplace.versioning.strategyand per-packagetag_pattern. The two derivations can disagree silently.git push --tagsis the lazy shortcut that bulk-pushes every local tag, including stale or wrong-namespace tags. Producers reach for it because the safe form (explicit refspec per tag) is verbose to type by hand.Important
The fix has to preserve the producer's release ergonomics that already work:
--dry-runpreviews,--jsonmachine-readable envelope, exit codes3(version gate) and4(drift gate). Any new refusal must NOT collide with those.This is the kind of glue work PROSE describes as "Grounding outputs in deterministic tool execution transforms probabilistic generation into verifiable action." — the tagger is a deterministic primitive driven by the same gate that already proves the version is releasable.
Approach (WHAT)
src/apm_cli/release/package, called frompack.pyafter the release-gate blockpack.pyunder the 2450-line cap; future tagging callers can re-useGitTaggerwithout going through Click.marketplace.versioning(single_version/per_package/tag_pattern)git push origin refs/tags/<name>:refs/tags/<name>per tag--tagswas rejected; see Trade-offs. Test-locked invariant.tag_creation.refusal_codein the--jsonenvelope, exit13,4); machine-readable for CI scripts.git pushalready uses for the resolved remotescripts/lint-auth-signals.shenforces.ls-remote --tagsfor tag-exists preflight carries the# auth-delegated:annotation Rule B requires.Implementation (HOW)
src/apm_cli/release/git_tagger.py(new, 390 lines)GitTaggerwithplan_tags→preflight→create→pushlifecycle;TaggingRefusalwithcode/message/hint; stable refusal-code constants.push()builds explicitrefs/tags/<n>:refs/tags/<n>refspecs — never--tags.src/apm_cli/utils/git_subprocess.py(new, 73 lines)run_git()helper layeringexternal_process_env()+git_subprocess_env()+GIT_TERMINAL_PROMPT=0. Single source of subprocess env setup; avoids R0801 withref_resolver.src/apm_cli/commands/pack.py(+217 LOC, total 974 / cap 2450)gate_configloader to also fire when tagging flags are set; added_run_tagging()helper that owns guard logic + refusal payload construction; extended JSON envelope withtag_creation/tag_pushkeys (always present,nullwhen unused).pyproject.tomlpylint.max-args18 → 19 forpack_cmd(now 19 Click params); the existing threshold-just-above-max convention is preserved.GitTagger, 14 unit onpackwiring, 2 e2e against a real bare remote. Total 38 new tests; all green.docs/src/content/docs/producer/releasing-from-any-ci.md; one entry inUnreleasedofCHANGELOG.md; one row update inpackages/apm-guide/.apm/skills/apm-usage/commands.md.Diagrams
Decision tree for the tagging block, anchored to the existing gate precedence.
flowchart TD A[apm pack invocation] --> B{tagging flags set?} B -->|no| Z[skip block, exit 0] B -->|yes| C{--check-versions set?} C -->|no| R1[refuse no_check_versions, exit 1] C -->|yes| D{version gate passed?} D -->|no| E[exit 3, tagging skipped] D -->|yes| F{drift gate passed?} F -->|no| G[exit 4, tagging skipped] F -->|yes| H[GitTagger.preflight] H -->|refuse| R2[refuse with stable code, exit 1] H -->|ok| I[create local tags] I --> J{--push?} J -->|no| Z J -->|yes| K[push explicit refspecs to origin] K --> ZEnd-to-end happy-path lifecycle on a single-version marketplace, showing the test-locked invariant (no
--tags).sequenceDiagram autonumber participant U as Producer participant P as apm pack participant T as GitTagger participant G as git participant R as origin bare remote U->>P: --check-versions --create-tag --push P->>P: version gate (already shipped) P->>P: drift gate (already shipped) P->>T: plan_tags(strategy, version, packages) T-->>P: TagPlan v1.0.0 P->>T: preflight(plans, remote=origin) T->>G: status --porcelain T->>G: tag -l v1.0.0 T->>G: ls-remote --tags origin P->>T: create(plans) T->>G: tag -a v1.0.0 -m Release v1.0.0 P->>T: push(created, remote=origin) T->>G: push origin refs/tags/v1.0.0 G->>R: explicit refspec only R-->>U: tag visible on remoteTrade-offs
git push --tags. Rejected--tagsbecause it pushes every local tag (including drift-namespace or stale tags) — the exact silent-action class that motivated this issue. Cost: onepushinvocation per tag inper_packagemode. Locked with a unit test that spies onrun_gitand asserts no--tagsflag is ever passed.git tag … && git push …) because the producer would still have to copy-paste it and we lose--dry-runparity. The in-process tagger gets--jsonand dry-run for free.tag_refusedumbrella. Chose stable per-cause codes so CI scripts can branch (if .refusal_code == "tag_exists" then …); the cost is a larger surface contract to keep stable.1for refusals vs new code5/6. Kept1because the existing exit-code contract documents1as "build/runtime error"; tag refusal is a runtime error the producer must act on. Thetag_creation.refusal_codein JSON disambiguates without breaking back-compat.Benefits
apm pack --check-versions --create-tag --pushinstead ofapm pack --check-versions && git tag … && git push origin ….marketplace.versioningdata the version gate already validated; producer can no longer ship av.1.0.0typo.--tagsis never used; only the just-created tags are pushed. Dirty trees, existing tags, and version mismatches refuse with actionable hints.tag_creation.refusal_codeis part of the stable--jsonenvelope; pipelines can branch on the cause without parsing prose.--dry-runparity. Producers can preview exactly which tags would be created and pushed without touching git or the remote.Validation
Lint contract from
.apm/instructions/linting.instructions.md— all silent / exit 0:Unit + integration totals
Scenario Evidence
--pushnever falls back togit push --tagstests/unit/release/test_git_tagger.py::test_push_invokes_git_push_with_explicit_tag_refs_not_minus_minus_tags3and never tagstests/unit/commands/test_pack_tagging.py::TestRefusalSemantics::test_pack_release_gates_still_exit_3_and_4_when_their_checks_fail1, not3/4tests/unit/commands/test_pack_tagging.py::TestRefusalSemantics::test_pack_refusal_on_dirty_tree_exits_1_not_3_or_4single_versiontests/integration/release/test_pack_tagging_e2e.py::test_pack_check_versions_create_tag_push_end_to_endper_packagestrategy creates one tag per packagetests/unit/commands/test_pack_tagging.py::TestHappyPath::test_pack_create_tag_and_push_happy_path_per_packagetag_creation/tag_pushkeystests/unit/commands/test_pack_tagging.py::TestJsonEnvelope::test_pack_json_envelope_keys_always_presentjqconsumers never see missing keys.How to test
git clonea fresh APM project with amarketplace:block (or scaffold viaapm init --marketplace).apm pack --check-versions --create-tag --dry-run— confirm it printsWould create tag: vX.Y.Zand no tag actually appears ingit tag --list.apm pack --check-versions --create-tag— confirmgit tag --listnow shows the tag andgit show <tag>points atHEAD.originremote, runapm pack --check-versions --create-tag --pushon a fresh version — confirmgit ls-remote --tags originshows the new tag.--create-tag— confirm exit code1andtag_creation.refusal_code == "dirty_tree"in the--jsonoutput.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com