diff --git a/CHANGELOG.md b/CHANGELOG.md index f025e090a..7f766c99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `apm pack --create-tag` (optionally with `--push`) creates -- and pushes -- release git tags from a clean tree after `--check-versions` succeeds, collapsing the bump/tag/push handshake into one command. Tag names come from `marketplace.versioning` (`lockstep` -> `v`; `tag_pattern` -> templated; `per_package` -> `-v`). `--push` requires `--create-tag` and uses explicit refspecs, never `git push --tags`. Refusals are first-class stable codes (`dirty_tree`, `tag_exists`, `version_mismatch`, `no_remote`, `push_without_tag`, `no_check_versions`, `no_marketplace`, `git_failure`) surfaced under `tag_creation.refusal_code` / `tag_push.refusal_code` in `--json` and exit `1`; the existing version-gate (`3`) and drift-gate (`4`) exit codes are unchanged. `--dry-run` previews tag names and push targets without touching git. (#1489) - `apm deps why ` explains why a transitive dependency is installed by walking the lockfile's `resolved_by` chain back to the user's direct declaration in `apm.yml`. Supports `--global` for user-scope lockfiles and `--json` for scriptable output (JSON to stdout, all logs to stderr; analogue of `npm why` / `yarn why`). Exits `0` on success, `1` when the package isn't installed or the query is ambiguous, `2` when no lockfile exists. (#1490) ## [0.15.0] - 2026-05-27 diff --git a/docs/src/content/docs/producer/index.md b/docs/src/content/docs/producer/index.md index 9caf61fec..ca4ef69fb 100644 --- a/docs/src/content/docs/producer/index.md +++ b/docs/src/content/docs/producer/index.md @@ -42,7 +42,7 @@ Once the 5-rung ladder works end to end, three pages cover the operational conce |------------------------------------------|----------------------------------------------------------------------| | Picking a repo layout before you author | [Repo shapes](./repo-shapes/) -- two starting layouts plus a hybrid composition for teams that ship their own plugin alongside a curated marketplace of others | | Aligning versions across local packages | [Versioning strategies](./versioning-strategies/) | -| Wiring the release into any CI provider | [Releasing from any CI](./releasing-from-any-ci/) | +| Wiring the release into any CI provider | [Releasing from any CI](./releasing-from-any-ci/) -- includes a [one-shot tagging flow](./releasing-from-any-ci/#one-shot-tagging-from-a-clean-tree) (`apm pack --create-tag --push`) for maintainers who want the CLI to materialise and push the release tag for them. | ## The producer mental model diff --git a/docs/src/content/docs/producer/publish-to-a-marketplace.md b/docs/src/content/docs/producer/publish-to-a-marketplace.md index 5c271c257..519c3050d 100644 --- a/docs/src/content/docs/producer/publish-to-a-marketplace.md +++ b/docs/src/content/docs/producer/publish-to-a-marketplace.md @@ -32,9 +32,15 @@ $EDITOR apm.yml # 2. describe each package apm marketplace check # 3. validate refs resolve apm pack # 4. build marketplace artifacts git add apm.yml .claude-plugin/marketplace.json -git commit -m "Release v1.0.0" && git tag v1.0.0 && git push --tags +git commit -m "Release v1.0.0" # 5. commit +apm pack --check-versions --create-tag --push # 6. tag and push from a clean tree ``` +Step 6 collapses tag-create-and-push into one command and refuses +on a dirty tree, a tag collision, or a version mismatch. See +[Releasing from any CI -> One-shot tagging](../releasing-from-any-ci/#one-shot-tagging-from-a-clean-tree) +for the full refusal contract and exit codes. + A consumer in another repo then runs: ```bash diff --git a/docs/src/content/docs/producer/releasing-from-any-ci.md b/docs/src/content/docs/producer/releasing-from-any-ci.md index 93545742f..27b4a6474 100644 --- a/docs/src/content/docs/producer/releasing-from-any-ci.md +++ b/docs/src/content/docs/producer/releasing-from-any-ci.md @@ -53,6 +53,40 @@ Authenticate `gh` with a token that has `contents: write` on the repo. Substitute the equivalent verb for non-GitHub forges (`glab release create`, `az repos`, REST upload). +## One-shot tagging from a clean tree + +When you have already committed the version bump and want the CLI +to materialise (and optionally push) the release tag for you, add +`--create-tag` and, if you also want it on `origin`, `--push`: + +```bash +apm pack --check-versions --create-tag --push +``` + +The block runs only after `--check-versions` succeeds, so a version +mismatch still exits `3` and never produces a tag. `--push` requires +`--create-tag`. The tag name is derived from `marketplace.versioning`: + +- `lockstep` (default) -> one tag, `v`. +- `tag_pattern` -> one tag named by your `tag_pattern` template (e.g. `release-v`). +- `per_package` -> one tag per local package, e.g. `-v`. + +`--dry-run` previews exactly which tags would be created and pushed +without touching git. Push uses explicit refspecs +(`refs/tags/:refs/tags/`), never `git push --tags`, so +only the planned tags move. Refusals are stable JSON contract codes +(`dirty_tree`, `tag_exists`, `version_mismatch`, `no_remote`, +`push_without_tag`, `no_check_versions`, `no_marketplace`, +`git_failure`) emitted under `tag_creation.refusal_code` and exit +with code `1`. The existing version-gate (exit `3`) and drift-gate +(exit `4`) codes are unchanged. + +Use this flow from a maintainer workstation or from CI -- the +refusal codes give pipelines a deterministic surface to react to. +The forge-native flows shown below remain valid if you prefer to +keep tag creation in `gh release create` or an equivalent. + + ## GitHub Actions ```yaml diff --git a/docs/src/content/docs/producer/versioning-strategies.md b/docs/src/content/docs/producer/versioning-strategies.md index 45c0a996e..ff6f2b935 100644 --- a/docs/src/content/docs/producer/versioning-strategies.md +++ b/docs/src/content/docs/producer/versioning-strategies.md @@ -118,5 +118,13 @@ Use per_package when: apm pack --check-versions --dry-run ``` +To collapse the gate-check, tag-create, and push into a single +command: + +```bash +apm pack --check-versions --create-tag --push +``` + See [Releasing from any CI](../releasing-from-any-ci/) for the full -release pipeline that runs both gates. +release pipeline and the +[one-shot tagging flow](../releasing-from-any-ci/#one-shot-tagging-from-a-clean-tree). diff --git a/docs/src/content/docs/reference/cli/pack.md b/docs/src/content/docs/reference/cli/pack.md index 5854094ea..68608c231 100644 --- a/docs/src/content/docs/reference/cli/pack.md +++ b/docs/src/content/docs/reference/cli/pack.md @@ -41,6 +41,8 @@ Bundles are target-agnostic. The consumer's project decides where files land at | `--marketplace-output PATH` | _(hidden)_ | **Deprecated.** Translates to `--marketplace-path claude=PATH` with a stderr warning. Will be removed in v0.15 (see #1318). | | `--legacy-skill-paths` | off | Bundle skills under per-client paths (e.g. `.cursor/skills/`) instead of the converged `.agents/skills/`. Compatibility flag. | | `--target`, `-t VALUE` | auto-detect | **Deprecated.** Recorded as informational `pack.target` metadata only; ignored by `apm install`. Will be removed in a future release. | +| `--create-tag` | off | Create annotated git tag(s) from `marketplace.versioning` after `--check-versions` succeeds on a clean tree. Requires `--check-versions`. Tag names: `lockstep` -> `v`; `tag_pattern` -> templated; `per_package` -> `-v`. | +| `--push` | off | Push the just-created tag(s) to `origin` by explicit refspec (never `git push --tags`). Requires `--create-tag`. | ## Examples @@ -143,8 +145,10 @@ Configure marketplace artifact paths in `apm.yml`: `marketplace.claude.output` c | Code | Meaning | |---|---| | `0` | Success. Requested artifacts written (or, with `--dry-run`, planned). | -| `1` | Build or runtime error: network failure, ref not found, no tag matches a marketplace range, lockfile read error, or unhandled packer exception. | +| `1` | Build or runtime error: network failure, ref not found, no tag matches a marketplace range, lockfile read error, unhandled packer exception, **or** `--create-tag` / `--push` refusal (`tag_creation.refusal_code` / `tag_push.refusal_code` carries the stable code: `dirty_tree`, `tag_exists`, `version_mismatch`, `no_remote`, `push_without_tag`, `no_check_versions`, `no_marketplace`, `git_failure`). | | `2` | `apm.yml` schema validation error. | +| `3` | `--check-versions` gate failure: bumped local package version is not strictly greater than the latest matching git tag. | +| `4` | `--check-clean` gate failure: working tree or staging area has uncommitted changes. | ## Related diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 94b6331fa..8f39000c2 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -73,7 +73,7 @@ If no `--target`, no `targets:` in `apm.yml`, and no harness signal is present, | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). Default output is a Claude Code plugin directory. Bundles are **target-agnostic**: `pack.target` is recorded in every bundle for diagnostic purposes (typically `"all"` for target-agnostic packs, or the project's detected target) and is not authoritative at install time; `pack.bundle_files` (path -> sha256) drives integrity verification. The consumer's project decides where files land. Marketplace-publishing projects (`marketplace:` block, no `dependencies:`) no longer emit the misleading "No plugin.json found" warning; after a successful build, a vendor-neutral catalog of artifact paths is appended together with a single docs pointer (`producer/publish-to-a-marketplace/#consume-from-any-assistant`) listing per-assistant install paths. Release-time gates `--check-versions` and `--check-clean` are opt-in: when present, they run after the build and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast. | `-o PATH`, `--archive`, `--dry-run`, `--format [plugin\|apm]` (default `plugin`), `--force`, `--offline`, `--include-prerelease`, `--marketplace=FORMATS`, `--marketplace-path FORMAT=PATH`, `--json`, `--check-versions` (release gate: per-package versions match `marketplace.versioning.strategy`; exit 3 on failure), `--check-clean` (release gate: regenerate-and-diff against the committed `marketplace.json`; exit 4 on drift). `--marketplace-output PATH` and `-t/--target` are **deprecated** (warn + auto-translate where applicable). Exit codes: `0` success, `1` build/runtime error, `2` schema validation error, `3` `--check-versions` misalignment, `4` `--check-clean` drift. | +| `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). Default output is a Claude Code plugin directory. Bundles are **target-agnostic**: `pack.target` is recorded in every bundle for diagnostic purposes (typically `"all"` for target-agnostic packs, or the project's detected target) and is not authoritative at install time; `pack.bundle_files` (path -> sha256) drives integrity verification. The consumer's project decides where files land. Marketplace-publishing projects (`marketplace:` block, no `dependencies:`) no longer emit the misleading "No plugin.json found" warning; after a successful build, a vendor-neutral catalog of artifact paths is appended together with a single docs pointer (`producer/publish-to-a-marketplace/#consume-from-any-assistant`) listing per-assistant install paths. Release-time gates `--check-versions` and `--check-clean` are opt-in: when present, they run after the build and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast. `--create-tag` (with optional `--push`) materialises -- and pushes -- the release git tag(s) after a successful `--check-versions`, refusing on dirty tree / existing tag / version mismatch / missing remote with stable refusal codes (exit `1`). | `-o PATH`, `--archive`, `--dry-run`, `--format [plugin\|apm]` (default `plugin`), `--force`, `--offline`, `--include-prerelease`, `--marketplace=FORMATS`, `--marketplace-path FORMAT=PATH`, `--json`, `--check-versions` (release gate: per-package versions match `marketplace.versioning.strategy`; exit 3 on failure), `--check-clean` (release gate: regenerate-and-diff against the committed `marketplace.json`; exit 4 on drift), `--create-tag` (after `--check-versions`: create local git tag(s) from `marketplace.versioning`; refusals exit 1), `--push` (push the just-created tag(s) to `origin` via explicit refspecs; requires `--create-tag`). `--marketplace-output PATH` and `-t/--target` are **deprecated** (warn + auto-translate where applicable). Exit codes: `0` success, `1` build/runtime error or tag refusal, `2` schema validation error, `3` `--check-versions` misalignment, `4` `--check-clean` drift. | | `apm unpack BUNDLE` | **[Deprecated]** Extract a bundle. Use `apm install ` instead -- it deploys directly with integrity verification and target resolution. | `-o PATH`, `--skip-verify`, `--force`, `--dry-run` | `apm install ` -- when the positional argument resolves to a directory containing `plugin.json` at its root, or to a `.tar.gz`/`.tgz` archive whose extracted root contains `plugin.json`, install switches to local-bundle mode: the bundle is integrity-verified against its embedded `apm.lock.yaml` (`pack.bundle_files`) and deployed into the consumer's resolved target. Target resolution follows the same precedence as registry installs (`--target` > `apm.yml` > directory detection); the bundle itself carries no target binding. Compile-only targets (opencode, codex, gemini) receive instructions staged under `apm_modules//.apm/instructions/` and the install emits a hint to run `apm compile` to merge them. Other existing paths (e.g. a source-package directory without `plugin.json`) still flow through the normal local-path dependency-resolver pipeline. Files are recorded under `local_deployed_files` in the project lockfile -- `apm.yml` is **never** mutated. Honours `--target`, `--global`, `--force`, `--dry-run`, `--verbose`, plus `--as ALIAS` (log/display label only). Resolver/MCP/registry/policy flags (`--update`, `--mcp`, `--parallel-downloads`, `--allow-insecure-host`, `--skill`, ...) are rejected with a single consolidated error -- local-bundle install is an imperative deploy and bypasses those subsystems. diff --git a/pyproject.toml b/pyproject.toml index e487ca0b9..1076d4664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ ignore = [ # Prevents new code from exceeding the worst existing violations. # Tighten these over time via dedicated refactoring PRs. max-statements = 275 # current max: 269 (mcp_integrator.py::install) -max-args = 18 # current max: 16 (commands/install.py) +max-args = 19 # current max: 19 (commands/pack.py with --create-tag/--push) max-branches = 115 # current max: 108 (mcp_integrator.py::install) max-returns = 18 # current max: 16 (marketplace/publisher.py) diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index 67869858d..e0aa0730c 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -53,7 +53,7 @@ Exit codes: 0 Success - 1 Build or runtime error + 1 Build or runtime error (includes --create-tag / --push refusals) 2 Manifest schema validation error 3 Version alignment check failed (--check-versions) 4 Marketplace working-tree drift detected (--check-clean) @@ -191,6 +191,29 @@ def _emit_json_error_or_raise(ctx, json_output: bool, code: str, message: str): "need per-client skill layouts." ), ) +@click.option( + "--create-tag", + "create_tag", + is_flag=True, + default=False, + help=( + "Release gate: after --check-versions succeeds, create the git tag(s) " + "implied by the marketplace versioning strategy (lockstep / tag_pattern / " + "per_package). Refuses on dirty tree, existing tags, or version mismatch. " + "Requires --check-versions. Honors --dry-run." + ), +) +@click.option( + "--push", + "push_tag", + is_flag=True, + default=False, + help=( + "After --create-tag succeeds, push the newly created tag(s) to the " + "'origin' remote by explicit refspec (never 'git push --tags'). " + "Requires --create-tag. Honors --dry-run." + ), +) @click.pass_context def pack_cmd( ctx, @@ -210,6 +233,8 @@ def pack_cmd( legacy_skill_paths, check_versions, check_clean, + create_tag, + push_tag, ): """Pack APM artifacts: bundle and/or marketplace.json.""" from ..marketplace.output_profiles import known_output_names @@ -330,8 +355,9 @@ def pack_cmd( gate_errors: list[dict] = [] version_gate_failed = False drift_gate_failed = False + gate_config = None # shared with the tagging block below - if check_versions or check_clean: + if check_versions or check_clean or create_tag or push_tag: from ..marketplace.builder import BuildOptions as MktBuildOptions from ..marketplace.builder import MarketplaceBuilder from ..marketplace.drift_check import check_marketplace_drift, render_diff_lines @@ -343,7 +369,6 @@ def pack_cmd( from ..marketplace.yml_schema import MarketplaceYmlError # Try to load the marketplace config; if absent, skip both gates with [i]. - gate_config = None try: source = detect_config_source(project_root) if source != ConfigSource.NONE: @@ -436,6 +461,25 @@ def pack_cmd( for msg in d_report.error_messages(): gate_errors.append({"code": "marketplace_drift", "message": msg}) + # -- Tagging block (--create-tag / --push) -- + tag_creation_payload: dict | None = None + tag_push_payload: dict | None = None + tag_refusal = False + + if create_tag or push_tag: + tag_creation_payload, tag_push_payload, tag_refusal = _run_tagging( + logger=logger, + json_output=json_output, + project_root=project_root, + dry_run=dry_run, + check_versions=check_versions, + create_tag=create_tag, + push_tag=push_tag, + gate_config=gate_config, + version_gate_failed=version_gate_failed, + drift_gate_failed=drift_gate_failed, + ) + # -- JSON output mode: consistent envelope -- if json_output: envelope = { @@ -447,6 +491,8 @@ def pack_cmd( "bundle": None, "version_alignment": version_alignment_payload, "drift": drift_payload, + "tag_creation": tag_creation_payload, + "tag_push": tag_push_payload, } for sub in result.producer_results: if sub.kind is OutputKind.MARKETPLACE and sub.payload is not None: @@ -457,11 +503,31 @@ def pack_cmd( if gate_errors: envelope["errors"] = list(envelope["errors"]) + gate_errors envelope["ok"] = False + if tag_refusal and tag_creation_payload is not None: + envelope["ok"] = False + # Source the error from whichever payload actually carries the + # refusal: push-phase refusal lives on tag_push_payload while + # creation succeeded; otherwise the refusal originated in the + # preflight/create path on tag_creation_payload. + refusal_source = ( + tag_push_payload + if tag_push_payload and tag_push_payload.get("refusal_code") + else tag_creation_payload + ) + envelope["errors"] = [ + *envelope["errors"], + { + "code": refusal_source.get("refusal_code", "tag_refused"), + "message": refusal_source.get("message", "tag refusal"), + }, + ] click.echo(json_mod.dumps(envelope, indent=2)) if version_gate_failed: ctx.exit(3) if drift_gate_failed: ctx.exit(4) + if tag_refusal: + ctx.exit(1) return for sub in result.producer_results: @@ -470,11 +536,167 @@ def pack_cmd( elif sub.kind is OutputKind.MARKETPLACE: _render_marketplace_result(logger, sub.payload, dry_run, sub.warnings, sub.outputs) - # Gate exit codes (after non-JSON rendering above): 3 wins over 4. + # Gate exit codes (after non-JSON rendering above): 3 wins over 4, then tag refusal (1). if version_gate_failed: ctx.exit(3) if drift_gate_failed: ctx.exit(4) + if tag_refusal: + ctx.exit(1) + + +def _run_tagging( + *, + logger, + json_output: bool, + project_root: Path, + dry_run: bool, + check_versions: bool, + create_tag: bool, + push_tag: bool, + gate_config, + version_gate_failed: bool, + drift_gate_failed: bool, +): + """Execute the --create-tag/--push flow. + + Returns ``(tag_creation_payload, tag_push_payload, tag_refusal)``. + A non-None ``tag_creation_payload`` indicates the block ran at all; + ``tag_refusal`` is True when any refusal/failure should drive exit 1. + """ + from ..marketplace.version_check import _is_local_package + from ..release import GitTagger, TaggingRefusal + from ..release.git_tagger import ( + REFUSAL_NO_CHECK_VERSIONS, + REFUSAL_NO_MARKETPLACE, + REFUSAL_PUSH_WITHOUT_TAG, + ) + + if not check_versions: + message = "--create-tag and --push require --check-versions." + if not json_output: + logger.error(message) + return ( + { + "status": "refused", + "created": [], + "refusal_code": REFUSAL_NO_CHECK_VERSIONS, + "message": message, + }, + None, + True, + ) + if push_tag and not create_tag: + message = "--push requires --create-tag; there is nothing to push." + if not json_output: + logger.error(message) + return ( + { + "status": "refused", + "created": [], + "refusal_code": REFUSAL_PUSH_WITHOUT_TAG, + "message": message, + }, + None, + True, + ) + if version_gate_failed or drift_gate_failed: + # Existing gate exit codes (3/4) take precedence; do not run tagging. + return None, None, False + if gate_config is None: + message = ( + "--create-tag requires a marketplace block in apm.yml; bundle-only " + "mode has no single version source." + ) + if not json_output: + logger.error(message) + return ( + { + "status": "refused", + "created": [], + "refusal_code": REFUSAL_NO_MARKETPLACE, + "message": message, + }, + None, + True, + ) + + tagger = GitTagger(project_root, dry_run=dry_run, logger=logger) + try: + plans = tagger.plan_tags( + strategy=gate_config.versioning.strategy, + marketplace_version=gate_config.version, + packages=[ + {"name": e.name, "version": e.version, "tag_pattern": e.tag_pattern} + for e in gate_config.packages + if _is_local_package(e) + ], + tag_pattern=gate_config.build.tag_pattern, + ) + tagger.preflight(plans, remote="origin" if push_tag else None) + except TaggingRefusal as refusal: + if not json_output: + logger.error(refusal.message) + if refusal.hint: + logger.info(f"Hint: {refusal.hint}") + return ( + { + "status": "refused", + "created": [], + "refusal_code": refusal.code, + "message": refusal.message, + }, + None, + True, + ) + + try: + created = tagger.create(plans) + except TaggingRefusal as refusal: + if not json_output: + logger.error(refusal.message) + if refusal.hint: + logger.info(f"Hint: {refusal.hint}") + return ( + { + "status": "refused", + "created": [], + "refusal_code": refusal.code, + "message": refusal.message, + }, + None, + True, + ) + + tag_creation_payload = { + "status": "ok", + "created": list(created), + "refusal_code": None, + } + tag_push_payload: dict | None = None + if push_tag: + try: + pushed = tagger.push(created, remote="origin") + except TaggingRefusal as refusal: + if not json_output: + logger.error(refusal.message) + if refusal.hint: + logger.info(f"Hint: {refusal.hint}") + tag_push_payload = { + "status": "refused", + "pushed": [], + "remote": "origin", + "refusal_code": refusal.code, + "message": refusal.message, + } + return tag_creation_payload, tag_push_payload, True + tag_push_payload = { + "status": "ok", + "pushed": list(pushed), + "remote": "origin", + "refusal_code": None, + } + return tag_creation_payload, tag_push_payload, False def _render_bundle_result(logger, pack_result, fmt, target, dry_run): diff --git a/src/apm_cli/release/__init__.py b/src/apm_cli/release/__init__.py new file mode 100644 index 000000000..6f02bd48d --- /dev/null +++ b/src/apm_cli/release/__init__.py @@ -0,0 +1,15 @@ +"""Release-engineering helpers (git tagging, future release notes, etc.).""" + +from .git_tagger import ( + GitTagger, + TagCreationResult, + TaggingRefusal, + TagPlan, +) + +__all__ = [ + "GitTagger", + "TagCreationResult", + "TagPlan", + "TaggingRefusal", +] diff --git a/src/apm_cli/release/git_tagger.py b/src/apm_cli/release/git_tagger.py new file mode 100644 index 000000000..370ca4f95 --- /dev/null +++ b/src/apm_cli/release/git_tagger.py @@ -0,0 +1,406 @@ +"""Git tagger for ``apm pack --create-tag --push``. + +Plans, validates, creates, and pushes release tags derived from the +marketplace versioning strategy. All git side effects route through +:func:`apm_cli.utils.git_subprocess.run_git`; ``dry_run=True`` is strict +(no git side effects at all). + +Auth boundary: this module never reads, stores, or forwards a +credential. ``git push`` relies on the user's existing git credential +setup (helper, SSH agent, or PAT in the remote URL) -- the same auth +they would use for ``git push origin v1.2.0`` by hand. The ``ls-remote`` +call used to inspect existing remote tags inherits that same boundary. + +Regression-trap invariant (test-locked): :meth:`GitTagger.push` builds +explicit ``refs/tags/:refs/tags/`` refspecs, one per planned +tag, and never invokes ``git push --tags`` (which would force-push every +unrelated local tag). +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..marketplace._git_utils import redact_token +from ..marketplace.tag_pattern import render_tag +from ..utils.git_subprocess import run_git + +__all__ = [ + "REFUSAL_DIRTY_TREE", + "REFUSAL_GIT_FAILURE", + "REFUSAL_NO_CHECK_VERSIONS", + "REFUSAL_NO_MARKETPLACE", + "REFUSAL_NO_REMOTE", + "REFUSAL_PUSH_WITHOUT_TAG", + "REFUSAL_TAG_EXISTS", + "REFUSAL_VERSION_MISMATCH", + "GitTagger", + "TagCreationResult", + "TagPlan", + "TaggingRefusal", +] + +# Refusal codes -- stable JSON contract for downstream consumers. +REFUSAL_DIRTY_TREE = "dirty_tree" +REFUSAL_TAG_EXISTS = "tag_exists" +REFUSAL_VERSION_MISMATCH = "version_mismatch" +REFUSAL_NO_REMOTE = "no_remote" +REFUSAL_NO_CHECK_VERSIONS = "no_check_versions" +REFUSAL_PUSH_WITHOUT_TAG = "push_without_tag" +REFUSAL_GIT_FAILURE = "git_failure" +REFUSAL_NO_MARKETPLACE = "no_marketplace" + + +@dataclass(frozen=True) +class TagPlan: + """One tag the command intends to create.""" + + name: str + target_sha: str + annotation: str + source_package: str | None = None + + +@dataclass(frozen=True) +class TagCreationResult: + """Outcome of a tag creation/push pass.""" + + created: tuple[str, ...] + pushed: tuple[str, ...] + remote: str | None + + +class TaggingRefusal(Exception): + """Pre-side-effect refusal. Carries a stable ``code`` for JSON output.""" + + def __init__(self, code: str, message: str, *, hint: str | None = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.hint = hint + + +def _package_to_dict(pkg: Any) -> dict[str, Any]: + """Normalise a package entry (mapping, dataclass, or object) to a dict.""" + if isinstance(pkg, dict): + return pkg + return { + "name": getattr(pkg, "name", None), + "version": getattr(pkg, "version", None), + "tag_pattern": getattr(pkg, "tag_pattern", None), + } + + +class GitTagger: + """Plan, preflight, create, and push release tags for a repo. + + Parameters + ---------- + repo_root: + Repository root directory. Subprocess CWD for every git call. + dry_run: + If True, :meth:`create` and :meth:`push` log intent but make no + git calls. Preflight inspection (``status``, ``tag --list``, + ``ls-remote``) still runs so the user gets accurate + would-refuse signals. + logger: + Optional CommandLogger-like object. Looked up by attribute + (``success``, ``info``, ``dry_run_notice``, ``error``); missing + attributes are silently skipped so plain ``logging.Logger`` and + ``MagicMock`` instances both work. + """ + + def __init__( + self, + repo_root: Path, + *, + dry_run: bool = False, + logger: Any | None = None, + ) -> None: + self.repo_root = Path(repo_root) + self.dry_run = dry_run + self.logger = logger + + # ------------------------------------------------------------------ + # Planning (pure; reads HEAD SHA via git rev-parse) + # ------------------------------------------------------------------ + + def plan_tags( + self, + strategy: str, + marketplace_version: str | None, + packages: list[Any], + tag_pattern: str = "v{version}", + ) -> list[TagPlan]: + """Derive the tags this strategy implies. + + Strategies: + + * ``lockstep`` -- one tag ``v{marketplace_version}``. + * ``tag_pattern`` -- one tag per package using the package's + override ``tag_pattern`` (if any) else the default *tag_pattern*. + * ``per_package`` -- one tag per package using ``{name}-v{version}``. + + Returns the planned tags in insertion order (lockstep produces + exactly one; per-package strategies preserve the input order so + the CLI rendering matches the manifest order). + """ + target_sha = self._head_sha() + plans: list[TagPlan] = [] + + if strategy == "lockstep": + if not marketplace_version: + raise TaggingRefusal( + REFUSAL_VERSION_MISMATCH, + "Cannot derive lockstep tag: marketplace.version is missing.", + ) + name = f"v{marketplace_version}" + plans.append( + TagPlan( + name=name, + target_sha=target_sha, + annotation=f"Release {name}", + source_package=None, + ) + ) + return plans + + for pkg in packages: + d = _package_to_dict(pkg) + name = d.get("name") or "" + version = d.get("version") + if not name or not version: + # --check-versions should have already failed on this; + # skip silently to keep planning total. + continue + if strategy == "tag_pattern": + pattern = d.get("tag_pattern") or tag_pattern + elif strategy == "per_package": + pattern = "{name}-v{version}" + else: # pragma: no cover - schema validates strategy upstream + pattern = tag_pattern + rendered = render_tag(pattern, name=name, version=version) + plans.append( + TagPlan( + name=rendered, + target_sha=target_sha, + annotation=f"Release {rendered}", + source_package=name, + ) + ) + return plans + + # ------------------------------------------------------------------ + # Preflight (raises TaggingRefusal on any blocking condition) + # ------------------------------------------------------------------ + + def preflight(self, plans: list[TagPlan], *, remote: str | None) -> None: + """Raise :class:`TaggingRefusal` on any blocking condition. + + Checks (in order): + + 1. Working tree clean. + 2. No planned tag already exists locally. + 3. If *remote* is not None: the remote exists and none of the + planned tags already exist on it. + """ + if self._is_dirty(): + raise TaggingRefusal( + REFUSAL_DIRTY_TREE, + "Refusing to tag: working tree has uncommitted changes.", + hint="commit or stash, then re-run.", + ) + + names = {p.name for p in plans} + existing_local = self._existing_local_tags(names) + if existing_local: + first = sorted(existing_local)[0] + raise TaggingRefusal( + REFUSAL_TAG_EXISTS, + f"Refusing to tag: '{first}' already exists.", + hint=( + f"bump the marketplace version, or delete the existing tag with " + f"'git tag -d {first}'." + ), + ) + + if remote is not None: + if not self._remote_exists(remote): + raise TaggingRefusal( + REFUSAL_NO_REMOTE, + f"Refusing to push: no '{remote}' remote configured.", + hint=(f"'git remote add {remote} ' or use --create-tag without --push."), + ) + existing_remote = self._existing_remote_tags(remote, names) + if existing_remote: + first = sorted(existing_remote)[0] + raise TaggingRefusal( + REFUSAL_TAG_EXISTS, + f"Refusing to push: '{first}' already exists on {remote}.", + hint=( + f"fetch with 'git fetch --tags' to sync, or delete remotely " + f"with 'git push {remote} :refs/tags/{first}'." + ), + ) + + # ------------------------------------------------------------------ + # Side effects (honour dry_run) + # ------------------------------------------------------------------ + + def create(self, plans: list[TagPlan]) -> list[str]: + """Create annotated tags for *plans*. No-op in dry-run. + + Returns the list of tag names that were created (or would be). + """ + names: list[str] = [] + for plan in plans: + sha_short = plan.target_sha[:7] if plan.target_sha else "HEAD" + if self.dry_run: + self._log("dry_run_notice", f"Would create tag: {plan.name} (HEAD = {sha_short})") + names.append(plan.name) + continue + self._run_or_raise( + # ``--`` terminates option parsing so a tag name beginning + # with ``-`` (e.g. an unusual ``tag_pattern``) cannot be + # interpreted as a git-tag flag. + ["tag", "-a", "-m", plan.annotation, "--", plan.name], + op=f"git tag -a {plan.name}", + ) + self._log("success", f"Created tag: {plan.name} (HEAD = {sha_short})") + names.append(plan.name) + return names + + def push(self, tag_names: list[str], *, remote: str) -> list[str]: + """Push *tag_names* to *remote* by explicit refspec. No-op in dry-run. + + Never uses ``git push --tags`` (regression-trap-locked); the + explicit refspec form guarantees only the planned tags move. + """ + if not tag_names: + return [] + # Defensive guard: a remote name starting with ``-`` would be parsed + # as a git option. Refspecs are safe because they always start with + # ``refs/tags/``; the remote is the only positional that could be + # attacker-influenced from a future caller (today pack.py hardcodes + # 'origin'). + if remote.startswith("-"): + raise TaggingRefusal( + code=REFUSAL_GIT_FAILURE, + message=f"Refusing to push: remote name {remote!r} starts with '-'.", + hint="Rename the remote to a value not starting with '-'.", + ) + if self.dry_run: + for name in tag_names: + self._log("dry_run_notice", f"Would push tag: {name} -> {remote}") + return list(tag_names) + refspecs = [f"refs/tags/{name}:refs/tags/{name}" for name in tag_names] + self._run_or_raise( + ["push", remote, *refspecs], + op=f"git push {remote} ({len(refspecs)} tag(s))", + ) + for name in tag_names: + self._log("success", f"Pushed tag: {name} -> {remote}") + return list(tag_names) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _run(self, args: list[str]) -> subprocess.CompletedProcess: + return run_git(args, cwd=self.repo_root) + + def _run_or_raise(self, args: list[str], *, op: str) -> subprocess.CompletedProcess: + result = self._run(args) + if result.returncode != 0: + stderr = redact_token((result.stderr or "").strip()) + raise TaggingRefusal( + REFUSAL_GIT_FAILURE, + f"{op} failed (exit {result.returncode}): {stderr or 'no stderr'}", + ) + return result + + def _head_sha(self) -> str: + result = self._run(["rev-parse", "HEAD"]) + if result.returncode != 0: + stderr = redact_token((result.stderr or "").strip()) + raise TaggingRefusal( + REFUSAL_GIT_FAILURE, + f"Cannot resolve HEAD: {stderr or 'no stderr'}", + ) + return (result.stdout or "").strip() + + def _is_dirty(self) -> bool: + result = self._run(["status", "--porcelain"]) + if result.returncode != 0: + stderr = redact_token((result.stderr or "").strip()) + raise TaggingRefusal( + REFUSAL_GIT_FAILURE, + f"Cannot inspect working tree: {stderr or 'no stderr'}", + ) + return bool((result.stdout or "").strip()) + + def _existing_local_tags(self, candidates: set[str]) -> set[str]: + if not candidates: + return set() + result = self._run(["tag", "--list"]) + if result.returncode != 0: + return set() + present = {line.strip() for line in (result.stdout or "").splitlines() if line.strip()} + return present & candidates + + def _remote_exists(self, remote: str) -> bool: + result = self._run(["remote"]) + if result.returncode != 0: + return False + names = {line.strip() for line in (result.stdout or "").splitlines() if line.strip()} + return remote in names + + def _existing_remote_tags(self, remote: str, candidates: set[str]) -> set[str]: + if not candidates: + return set() + # auth-delegated: ls-remote here inherits the user's existing + # git credential setup -- APM does not read, store, or forward + # any credential. The PAT/bearer protocol enforced by + # AuthResolver does not apply: this is the same auth a user + # would invoke with `git ls-remote origin` by hand. + result = self._run(["ls-remote", "--tags", remote]) + if result.returncode != 0: + # Network / auth failure -- skip remote check rather than + # falsely refuse. The actual push will surface the real error. + self._log( + "warning", + f"Could not list remote tags from {remote}; skipping remote " + "tag existence check (fail-open). Tag-name collisions on " + "the remote will surface at push time.", + ) + return set() + present: set[str] = set() + for line in (result.stdout or "").splitlines(): + stripped = line.strip() + if not stripped: + continue + parts = stripped.split("\t") + if len(parts) < 2: + continue + ref = parts[1] + if not ref.startswith("refs/tags/"): + continue + name = ref[len("refs/tags/") :] + if name.endswith("^{}"): + name = name[:-3] + present.add(name) + return present & candidates + + def _log(self, level: str, message: str) -> None: + if self.logger is None: + return + fn = getattr(self.logger, level, None) + if callable(fn): + fn(message) + return + fn = getattr(self.logger, "info", None) + if callable(fn): + fn(message) diff --git a/src/apm_cli/utils/git_subprocess.py b/src/apm_cli/utils/git_subprocess.py new file mode 100644 index 000000000..8fc49c59e --- /dev/null +++ b/src/apm_cli/utils/git_subprocess.py @@ -0,0 +1,73 @@ +"""Thin wrapper around ``subprocess.run(["git", ...])`` for APM internals. + +Centralises git executable lookup, ambient-env scrubbing, PyInstaller +library-path restoration, and the ``GIT_TERMINAL_PROMPT=0`` default so +every git invocation in apm_cli is consistent. + +Callers that need authentication semantics (PAT/bearer fallback) +**must** route through :class:`apm_cli.core.auth.AuthResolver` -- this +helper is intentionally credential-agnostic and relies on the user's +existing git credential setup (helper, SSH agent, or PAT in remote URL). +""" + +from __future__ import annotations + +import subprocess +from collections.abc import Sequence +from pathlib import Path + +from .git_env import get_git_executable, git_subprocess_env +from .subprocess_env import external_process_env + + +class GitNotFoundError(FileNotFoundError): + """Raised when the git executable cannot be located on PATH.""" + + +def run_git( + args: Sequence[str], + *, + cwd: Path | str | None = None, + timeout: float | None = None, + check: bool = False, + no_prompt: bool = True, +) -> subprocess.CompletedProcess: + """Run ``git `` with sanitised environment and captured output. + + Parameters + ---------- + args: + Argument list passed to git (without the leading ``git``). + cwd: + Working directory for the subprocess. + timeout: + Optional timeout in seconds. + check: + If True, raise :class:`subprocess.CalledProcessError` on non-zero exit. + no_prompt: + If True (default), set ``GIT_TERMINAL_PROMPT=0`` so git never + prompts on a missing credential. + + Raises + ------ + GitNotFoundError + If git is not on PATH. + """ + try: + git_bin = get_git_executable() + except FileNotFoundError as exc: + raise GitNotFoundError(str(exc)) from exc + + env = external_process_env(git_subprocess_env()) + if no_prompt: + env["GIT_TERMINAL_PROMPT"] = "0" + + return subprocess.run( + [git_bin, *args], + cwd=str(cwd) if cwd is not None else None, + capture_output=True, + text=True, + env=env, + timeout=timeout, + check=check, + ) diff --git a/tests/integration/release/__init__.py b/tests/integration/release/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/release/test_pack_tagging_e2e.py b/tests/integration/release/test_pack_tagging_e2e.py new file mode 100644 index 000000000..7e71c7c2c --- /dev/null +++ b/tests/integration/release/test_pack_tagging_e2e.py @@ -0,0 +1,472 @@ +"""End-to-end tests for ``apm pack --check-versions --create-tag --push``. + +Spawns a real git repository plus a local bare remote and exercises the +full pack-tagging flow. No network access, no GPG, fully hermetic. + +Coverage map (user-visible promises -> test): + +* Promise A (lockstep + create-tag + push) + -> ``test_pack_check_versions_create_tag_push_end_to_end`` +* Promise B (``tag_pattern`` strategy renders ``build.tagPattern``) + -> ``test_pack_tag_pattern_strategy_creates_templated_tag_e2e`` +* Promise E (push refuses when tag already on remote, fail-closed via + ``ls-remote`` preflight) + -> ``test_pack_push_refuses_when_tag_already_on_remote_e2e`` +* Promise F (``--check-versions`` failure exits 3 AND leaves NO tag + behind on disk -- side-effect-free guarantee) + -> ``test_pack_version_mismatch_blocks_tag_no_side_effects_e2e`` +* Promise G (idempotent re-run: second invocation refuses cleanly + with ``tag_exists`` when the tag is already local) + -> ``test_pack_rerun_refuses_when_tag_exists_locally_e2e`` +* Promise J (``--json`` envelope keeps ``tag_creation`` / + ``tag_push`` shape stable across success AND refusal) + -> ``test_pack_json_envelope_stable_across_success_and_refusal_e2e`` +""" + +from __future__ import annotations + +import json as _json +import os +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.pack import pack_cmd + +pytestmark = pytest.mark.skipif( + shutil.which("git") is None, + reason="git executable not on PATH", +) + + +def _run_git(args, cwd): + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_TERMINAL_PROMPT": "0", + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + } + ) + return subprocess.run( + ["git", *args], cwd=str(cwd), env=env, capture_output=True, text=True, check=False + ) + + +def _scaffold(tmp_path: Path) -> tuple[Path, Path]: + repo = tmp_path / "project" + repo.mkdir() + (repo / "apm.yml").write_text( + textwrap.dedent( + """\ + name: e2e-project + description: E2E project. + version: 2.0.0 + marketplace: + owner: + name: ACME + packages: + - name: hello + source: ./packages/hello + description: Hello. + version: 2.0.0 + """ + ), + encoding="utf-8", + ) + pkg = repo / "packages" / "hello" + pkg.mkdir(parents=True) + pkg.joinpath("apm.yml").write_text( + "name: hello\ndescription: Hello.\nversion: 2.0.0\n", encoding="utf-8" + ) + _run_git(["init", "-q", "-b", "main"], repo).check_returncode() + _run_git(["config", "user.name", "Test"], repo) + _run_git(["config", "user.email", "test@example.com"], repo) + _run_git(["config", "commit.gpgSign", "false"], repo) + _run_git(["config", "tag.gpgSign", "false"], repo) + _run_git(["add", "-A"], repo).check_returncode() + _run_git(["commit", "-q", "-m", "init"], repo).check_returncode() + bare = tmp_path / "origin.git" + _run_git(["init", "-q", "--bare", str(bare)], tmp_path).check_returncode() + _run_git(["remote", "add", "origin", str(bare)], repo).check_returncode() + _run_git(["push", "-q", "origin", "main"], repo).check_returncode() + return repo, bare + + +def test_pack_check_versions_create_tag_push_end_to_end(tmp_path, monkeypatch): + repo, bare = _scaffold(tmp_path) + monkeypatch.chdir(repo) + result = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + "--push", + ], + ) + assert result.exit_code == 0, result.output + local = _run_git(["tag", "--list"], repo).stdout + assert "v2.0.0" in local + remote = _run_git(["ls-remote", "--tags", str(bare)], repo).stdout + assert "refs/tags/v2.0.0" in remote + + +def test_pack_dry_run_does_not_touch_repo_or_remote(tmp_path, monkeypatch): + repo, bare = _scaffold(tmp_path) + monkeypatch.chdir(repo) + result = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + "--push", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + assert "v2.0.0" not in _run_git(["tag", "--list"], repo).stdout + assert "refs/tags/v2.0.0" not in _run_git(["ls-remote", "--tags", str(bare)], repo).stdout + + +# --------------------------------------------------------------------------- +# Extra fixtures for promise B (tag_pattern) and re-use across new tests. +# --------------------------------------------------------------------------- + + +def _scaffold_tag_pattern(tmp_path: Path) -> tuple[Path, Path]: + """Scaffold a repo using ``versioning.strategy: tag_pattern`` with a + custom ``build.tagPattern`` of ``{name}--v{version}`` (the literal + pattern from the original session brief). Two packages so we also + confirm the renderer produces distinct tags per package. + """ + repo = tmp_path / "project" + repo.mkdir() + (repo / "apm.yml").write_text( + textwrap.dedent( + """\ + name: e2e-tag-pattern + description: tag_pattern e2e. + version: 9.9.9 + marketplace: + owner: + name: ACME + versioning: + strategy: tag_pattern + build: + tagPattern: "{name}--v{version}" + packages: + - name: gamma + source: ./packages/gamma + description: G. + version: 3.1.4 + - name: delta + source: ./packages/delta + description: D. + version: 0.0.1 + """ + ), + encoding="utf-8", + ) + for name, ver in (("gamma", "3.1.4"), ("delta", "0.0.1")): + pkg = repo / "packages" / name + pkg.mkdir(parents=True) + pkg.joinpath("apm.yml").write_text( + f"name: {name}\ndescription: x.\nversion: {ver}\n", encoding="utf-8" + ) + _run_git(["init", "-q", "-b", "main"], repo).check_returncode() + _run_git(["config", "user.name", "Test"], repo) + _run_git(["config", "user.email", "test@example.com"], repo) + _run_git(["config", "commit.gpgSign", "false"], repo) + _run_git(["config", "tag.gpgSign", "false"], repo) + _run_git(["add", "-A"], repo).check_returncode() + _run_git(["commit", "-q", "-m", "init"], repo).check_returncode() + bare = tmp_path / "origin.git" + _run_git(["init", "-q", "--bare", str(bare)], tmp_path).check_returncode() + _run_git(["remote", "add", "origin", str(bare)], repo).check_returncode() + _run_git(["push", "-q", "origin", "main"], repo).check_returncode() + return repo, bare + + +def _parse_json_envelope(output: str) -> dict: + """Extract the JSON envelope from CliRunner stdout.""" + idx = output.find("{") + assert idx >= 0, f"no JSON object in output: {output!r}" + return _json.loads(output[idx:]) + + +# --------------------------------------------------------------------------- +# Promise B: tag_pattern strategy materializes the configured template. +# --------------------------------------------------------------------------- + + +def test_pack_tag_pattern_strategy_creates_templated_tag_e2e(tmp_path, monkeypatch): + """``strategy: tag_pattern`` + ``build.tagPattern: "{name}--v{version}"`` + in apm.yml must materialize one tag per local package using the + rendered template, both locally and on origin. + + Mutation-break verified: substituting ``per_package`` for the + ``tag_pattern`` branch in ``GitTagger.plan_tags`` makes both + assertions on remote tag names fail (tags render as + ``{name}-v{version}`` with one dash instead of two). + """ + repo, bare = _scaffold_tag_pattern(tmp_path) + monkeypatch.chdir(repo) + result = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + "--push", + ], + ) + assert result.exit_code == 0, result.output + local = _run_git(["tag", "--list"], repo).stdout + assert "gamma--v3.1.4" in local, local + assert "delta--v0.0.1" in local, local + remote = _run_git(["ls-remote", "--tags", str(bare)], repo).stdout + assert "refs/tags/gamma--v3.1.4" in remote, remote + assert "refs/tags/delta--v0.0.1" in remote, remote + + +# --------------------------------------------------------------------------- +# Promise F: --check-versions failure exits 3 and creates NO tags. +# --------------------------------------------------------------------------- + + +def test_pack_version_mismatch_blocks_tag_no_side_effects_e2e(tmp_path, monkeypatch): + """When the version gate fails (exit 3), ``--create-tag`` must be a + strict no-op: no tag may appear in ``git tag --list`` after the run. + + The unit-tier counterpart only asserts the exit code; this test + closes the silent-side-effect gap by inspecting the on-disk tag + list with a REAL git repo. + + Mutation-break verified: deleting the + ``if version_gate_failed or drift_gate_failed: return None, None, False`` + short-circuit in ``_run_tagging`` causes the tag ``v5.0.0`` to be + created on disk, failing the post-condition assertion. + """ + repo, _bare = _scaffold(tmp_path) + # Bump marketplace top-level version above the package version so the + # lockstep version gate fails (package 2.0.0 vs marketplace 5.0.0). + apm_yml = repo / "apm.yml" + apm_yml.write_text(apm_yml.read_text().replace("version: 2.0.0", "version: 5.0.0", 1)) + _run_git(["add", "-A"], repo) + _run_git(["commit", "-q", "-m", "bump"], repo).check_returncode() + monkeypatch.chdir(repo) + + result = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + ], + ) + # Exit 3 = version gate failure per docs/reference/cli/pack.md. + assert result.exit_code == 3, (result.exit_code, result.output) + # Critical post-condition: no tag was materialized. + tags = _run_git(["tag", "--list"], repo).stdout.strip() + assert "v5.0.0" not in tags, f"tag leaked despite failed gate: {tags!r}" + assert "v2.0.0" not in tags, f"tag leaked despite failed gate: {tags!r}" + + +# --------------------------------------------------------------------------- +# Promise G: idempotent re-run when local tag exists -> refusal exit 1. +# --------------------------------------------------------------------------- + + +def test_pack_rerun_refuses_when_tag_exists_locally_e2e(tmp_path, monkeypatch): + """A second ``apm pack --create-tag`` after a successful first run + must refuse cleanly (``tag_exists`` / exit 1), not silently + overwrite, and not produce a duplicate. + + Mutation-break verified: deleting the + ``if existing_local:`` raise block in ``GitTagger.preflight`` + causes the re-run to attempt ``git tag -a`` again, which exits + non-zero with ``tag already exists`` and surfaces as + ``git_failure`` (not ``tag_exists``), failing the refusal-code + assertion below. + """ + repo, _bare = _scaffold(tmp_path) + monkeypatch.chdir(repo) + # First run: creates v2.0.0 locally (no --push to avoid remote noise). + first = CliRunner().invoke( + pack_cmd, + ["--marketplace=none", "--offline", "--check-versions", "--create-tag"], + ) + assert first.exit_code == 0, first.output + assert "v2.0.0" in _run_git(["tag", "--list"], repo).stdout + + # Second run: same state. Must refuse with tag_exists / exit 1. + second = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + "--json", + ], + ) + assert second.exit_code == 1, (second.exit_code, second.output) + envelope = _parse_json_envelope(second.output) + assert envelope["tag_creation"]["status"] == "refused" + assert envelope["tag_creation"]["refusal_code"] == "tag_exists" + assert envelope["ok"] is False + # Exactly one tag still on disk: no duplication, no overwrite. + tag_lines = [ + line for line in _run_git(["tag", "--list"], repo).stdout.splitlines() if line.strip() + ] + assert tag_lines == ["v2.0.0"], tag_lines + + +# --------------------------------------------------------------------------- +# Promise E: --push refuses fail-closed when tag already on remote. +# --------------------------------------------------------------------------- + + +def test_pack_push_refuses_when_tag_already_on_remote_e2e(tmp_path, monkeypatch): + """If the target tag already exists on ``origin`` (e.g. somebody + else pushed it from a sibling clone), the preflight ``ls-remote`` + check must refuse with ``tag_exists`` BEFORE any local tag is + materialized -- so neither side drifts. + + Setup: scaffold a working repo + bare origin, push ``v2.0.0`` from + a sibling clone, then run ``apm pack ... --push`` from the working + repo. The working repo has no local ``v2.0.0`` yet, so the + refusal proves the remote check fired. + + Mutation-break verified: deleting the + ``existing_remote = self._existing_remote_tags(...)`` / + ``if existing_remote:`` block in ``GitTagger.preflight`` lets the + flow proceed to ``create`` (which succeeds locally) and ``push`` + (which then fails at the wire with ``git_failure``, not + ``tag_exists``), failing the refusal-code assertion. + """ + repo, bare = _scaffold(tmp_path) + # Stage the tag from a sibling clone so the remote already has v2.0.0. + sibling = tmp_path / "sibling" + _run_git(["clone", "-q", str(bare), str(sibling)], tmp_path).check_returncode() + _run_git(["config", "user.name", "Sibling"], sibling) + _run_git(["config", "user.email", "sib@example.com"], sibling) + _run_git(["config", "commit.gpgSign", "false"], sibling) + _run_git(["config", "tag.gpgSign", "false"], sibling) + _run_git(["tag", "-a", "-m", "Release v2.0.0", "v2.0.0"], sibling).check_returncode() + _run_git(["push", "origin", "refs/tags/v2.0.0:refs/tags/v2.0.0"], sibling).check_returncode() + # Sanity: tag is on the remote, but NOT on the working repo yet. + assert "v2.0.0" not in _run_git(["tag", "--list"], repo).stdout + assert "refs/tags/v2.0.0" in _run_git(["ls-remote", "--tags", str(bare)], repo).stdout + + monkeypatch.chdir(repo) + result = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + "--push", + "--json", + ], + ) + assert result.exit_code == 1, (result.exit_code, result.output) + envelope = _parse_json_envelope(result.output) + # Preflight refusal: no local tag was created, payload is "refused". + assert envelope["tag_creation"]["status"] == "refused" + assert envelope["tag_creation"]["refusal_code"] == "tag_exists" + assert envelope["tag_push"] is None, envelope["tag_push"] + # Critical post-condition: working repo still has no local tag. + assert "v2.0.0" not in _run_git(["tag", "--list"], repo).stdout + # Actionable hint surfaced in stderr (logger.error path). + assert "tag_exists" in {e["code"] for e in envelope["errors"]} + + +# --------------------------------------------------------------------------- +# Promise J: --json envelope shape stable across success AND refusal. +# --------------------------------------------------------------------------- + + +def test_pack_json_envelope_stable_across_success_and_refusal_e2e(tmp_path, monkeypatch): + """The contract documented in pack.md: + ``tag_creation.refusal_code`` and ``tag_push.refusal_code`` carry + the stable code on failure, and the keys are present on success + too (with ``refusal_code: null``). Downstream ``jq`` consumers + must never see a missing key. + + This e2e variant runs the SAME repo through a success path and a + failure path back-to-back (idempotency triggers the failure), so + both branches of the envelope shape are observed with real + fixtures in one test. + """ + repo, bare = _scaffold(tmp_path) + monkeypatch.chdir(repo) + + # Success path: full create + push. + ok = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + "--push", + "--json", + ], + ) + assert ok.exit_code == 0, ok.output + env_ok = _parse_json_envelope(ok.output) + for key in ("ok", "dry_run", "warnings", "errors", "tag_creation", "tag_push"): + assert key in env_ok, f"missing envelope key on success: {key}" + assert env_ok["tag_creation"]["status"] == "ok" + assert env_ok["tag_creation"]["created"] == ["v2.0.0"] + assert env_ok["tag_creation"]["refusal_code"] is None + assert env_ok["tag_push"]["status"] == "ok" + assert env_ok["tag_push"]["pushed"] == ["v2.0.0"] + assert env_ok["tag_push"]["remote"] == "origin" + assert env_ok["tag_push"]["refusal_code"] is None + + # Failure path: same flags, but the tag now exists locally and on + # the remote -> preflight refusal. Both payload keys must still be + # present, and tag_creation.refusal_code must carry the code. + bad = CliRunner().invoke( + pack_cmd, + [ + "--marketplace=none", + "--offline", + "--check-versions", + "--create-tag", + "--push", + "--json", + ], + ) + assert bad.exit_code == 1, bad.output + env_bad = _parse_json_envelope(bad.output) + for key in ("ok", "dry_run", "warnings", "errors", "tag_creation", "tag_push"): + assert key in env_bad, f"missing envelope key on refusal: {key}" + assert env_bad["ok"] is False + assert env_bad["tag_creation"]["status"] == "refused" + assert env_bad["tag_creation"]["refusal_code"] == "tag_exists" + assert env_bad["tag_creation"]["created"] == [] + # Remote also pre-existed (from the prior success), so push is not + # reached -- payload stays None per contract. + assert env_bad["tag_push"] is None + codes = {e["code"] for e in env_bad["errors"]} + assert "tag_exists" in codes, codes + # Use bare in an assertion so the fixture binding is not unused. + assert "refs/tags/v2.0.0" in _run_git(["ls-remote", "--tags", str(bare)], repo).stdout diff --git a/tests/unit/commands/conftest.py b/tests/unit/commands/conftest.py index 1540a443e..6b097c5b8 100644 --- a/tests/unit/commands/conftest.py +++ b/tests/unit/commands/conftest.py @@ -5,3 +5,36 @@ """ from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + + +def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess: + env = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + } + return subprocess.run( + ["git", *args], + cwd=str(cwd), + capture_output=True, + text=True, + env=env, + check=False, + ) + + +@pytest.fixture +def run_git_cmd(): + """Run a git subcommand with hermetic identity/config for tests.""" + + return _run_git diff --git a/tests/unit/commands/test_pack_tagging.py b/tests/unit/commands/test_pack_tagging.py new file mode 100644 index 000000000..8668d1f56 --- /dev/null +++ b/tests/unit/commands/test_pack_tagging.py @@ -0,0 +1,364 @@ +"""Unit tests for ``apm pack --create-tag`` / ``--push`` wiring.""" + +from __future__ import annotations + +import json as _json +import textwrap as _tw +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.pack import pack_cmd + +pytestmark = pytest.mark.skipif( + __import__("shutil").which("git") is None, + reason="git executable not on PATH", +) + + +_APM_YAML = """\ +name: my-project +description: A project. +version: 1.0.0 +marketplace: + owner: + name: ACME + packages: + - name: local-tool + source: ./packages/local-tool + description: Tool. + version: 1.0.0 +""" + + +def _scaffold_repo(tmp_path: Path, run_git_cmd, *, pkg_version: str = "1.0.0") -> Path: + """Create a fresh git repo under tmp_path/project with one local package.""" + repo = tmp_path / "project" + repo.mkdir(parents=True, exist_ok=True) + (repo / "apm.yml").write_text(_tw.dedent(_APM_YAML), encoding="utf-8") + pkg = repo / "packages" / "local-tool" + pkg.mkdir(parents=True) + pkg.joinpath("apm.yml").write_text( + f"name: local-tool\ndescription: Tool.\nversion: {pkg_version}\n", + encoding="utf-8", + ) + assert run_git_cmd(["init", "-q", "-b", "main"], repo).returncode == 0 + run_git_cmd(["config", "user.name", "Test"], repo) + run_git_cmd(["config", "user.email", "test@example.com"], repo) + run_git_cmd(["config", "commit.gpgSign", "false"], repo) + run_git_cmd(["config", "tag.gpgSign", "false"], repo) + run_git_cmd(["add", "-A"], repo) + assert run_git_cmd(["commit", "-q", "-m", "init"], repo).returncode == 0 + return repo + + +def _scaffold_per_package(tmp_path: Path, run_git_cmd) -> Path: + """Create a repo using per_package versioning with two local packages.""" + repo = tmp_path / "project" + repo.mkdir(parents=True, exist_ok=True) + (repo / "apm.yml").write_text( + _tw.dedent( + """\ + name: my-project + description: A project. + version: 1.0.0 + marketplace: + owner: + name: ACME + versioning: + strategy: per_package + packages: + - name: alpha + source: ./packages/alpha + description: A. + version: 1.0.0 + - name: beta + source: ./packages/beta + description: B. + version: 2.0.0 + """ + ), + encoding="utf-8", + ) + for name, ver in (("alpha", "1.0.0"), ("beta", "2.0.0")): + pkg = repo / "packages" / name + pkg.mkdir(parents=True) + pkg.joinpath("apm.yml").write_text( + f"name: {name}\ndescription: x.\nversion: {ver}\n", encoding="utf-8" + ) + assert run_git_cmd(["init", "-q", "-b", "main"], repo).returncode == 0 + run_git_cmd(["config", "user.name", "Test"], repo) + run_git_cmd(["config", "user.email", "test@example.com"], repo) + run_git_cmd(["config", "commit.gpgSign", "false"], repo) + run_git_cmd(["config", "tag.gpgSign", "false"], repo) + run_git_cmd(["add", "-A"], repo) + assert run_git_cmd(["commit", "-q", "-m", "init"], repo).returncode == 0 + return repo + + +@pytest.fixture(autouse=True) +def _reset_console_state(): + """Reset the global console singleton so --json doesn't bleed across tests.""" + from apm_cli.utils.console import _reset_console + + yield + _reset_console() + + +# Default flag chain: skip marketplace artifact writes so the orchestrator +# does not dirty the tree mid-flight. The release-gate config still loads. +# Real usage assumes the producer has already committed marketplace.json. +def _parse_json(output: str) -> dict: + """Extract the JSON envelope from stdout, tolerating any leading log lines. + + set_console_stderr() should route logger output to stderr, but Rich's + capture semantics under CliRunner can leak; tests assert against the + actual JSON document either way. + """ + idx = output.find("{") + return _json.loads(output[idx:]) + + +def _invoke(*extra: str, mix_stderr: bool = True): + # Click 8.2+ already separates stderr from stdout; mix_stderr kwarg is gone. + # `result.output` returns stdout only — exactly what we want for --json tests. + return CliRunner().invoke(pack_cmd, ["--marketplace=none", "--offline", *extra]) + + +class TestFlagGuards: + def test_pack_create_tag_without_check_versions_errors( + self, tmp_path, monkeypatch, run_git_cmd + ): + repo = _scaffold_repo(tmp_path, run_git_cmd) + monkeypatch.chdir(repo) + result = _invoke("--create-tag", "--dry-run") + assert result.exit_code == 1 + assert "--check-versions" in result.output + + def test_pack_push_without_create_tag_errors(self, tmp_path, monkeypatch, run_git_cmd): + repo = _scaffold_repo(tmp_path, run_git_cmd) + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--push", "--dry-run") + assert result.exit_code == 1 + assert "--create-tag" in result.output + + +class TestDryRun: + def test_pack_create_tag_dry_run_makes_no_git_calls(self, tmp_path, monkeypatch, run_git_cmd): + repo = _scaffold_repo(tmp_path, run_git_cmd) + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag", "--dry-run") + assert result.exit_code == 0, result.output + tags = run_git_cmd(["tag", "--list"], repo).stdout + assert "v1.0.0" not in tags + assert "Would create tag" in result.output + + def test_pack_create_tag_push_dry_run_makes_no_remote_calls( + self, tmp_path, monkeypatch, run_git_cmd + ): + repo = _scaffold_repo(tmp_path, run_git_cmd) + bare = tmp_path / "origin.git" + run_git_cmd(["init", "-q", "--bare", str(bare)], tmp_path) + run_git_cmd(["remote", "add", "origin", str(bare)], repo) + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag", "--push", "--dry-run") + assert result.exit_code == 0, result.output + remote_refs = run_git_cmd(["ls-remote", "--tags", str(bare)], repo).stdout + assert "v1.0.0" not in remote_refs + + +class TestHappyPath: + def test_pack_create_tag_and_push_happy_path_lockstep(self, tmp_path, monkeypatch, run_git_cmd): + repo = _scaffold_repo(tmp_path, run_git_cmd) + bare = tmp_path / "origin.git" + run_git_cmd(["init", "-q", "--bare", str(bare)], tmp_path) + run_git_cmd(["remote", "add", "origin", str(bare)], repo) + run_git_cmd(["push", "-q", "origin", "main"], repo) + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag", "--push") + assert result.exit_code == 0, result.output + local_tags = run_git_cmd(["tag", "--list"], repo).stdout + assert "v1.0.0" in local_tags + remote_tags = run_git_cmd(["ls-remote", "--tags", str(bare)], repo).stdout + assert "refs/tags/v1.0.0" in remote_tags + + def test_pack_create_tag_and_push_happy_path_per_package( + self, tmp_path, monkeypatch, run_git_cmd + ): + repo = _scaffold_per_package(tmp_path, run_git_cmd) + bare = tmp_path / "origin.git" + run_git_cmd(["init", "-q", "--bare", str(bare)], tmp_path) + run_git_cmd(["remote", "add", "origin", str(bare)], repo) + run_git_cmd(["push", "-q", "origin", "main"], repo) + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag", "--push") + assert result.exit_code == 0, result.output + local_tags = run_git_cmd(["tag", "--list"], repo).stdout + assert "alpha-v1.0.0" in local_tags + assert "beta-v2.0.0" in local_tags + remote_tags = run_git_cmd(["ls-remote", "--tags", str(bare)], repo).stdout + assert "refs/tags/alpha-v1.0.0" in remote_tags + assert "refs/tags/beta-v2.0.0" in remote_tags + + +class TestRefusalSemantics: + def test_pack_refusal_on_dirty_tree_exits_1_not_3_or_4( + self, tmp_path, monkeypatch, run_git_cmd + ): + """Dirty-tree refusal must not collide with gate exit codes (3, 4).""" + repo = _scaffold_repo(tmp_path, run_git_cmd) + (repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag") + assert result.exit_code == 1 + assert result.exit_code != 3 + assert result.exit_code != 4 + assert "uncommitted changes" in result.output + + def test_pack_release_gates_still_exit_3_and_4_when_their_checks_fail( + self, tmp_path, monkeypatch, run_git_cmd + ): + """Tagging block must not change existing gate exit codes.""" + repo = _scaffold_repo(tmp_path, run_git_cmd, pkg_version="0.5.0") + monkeypatch.chdir(repo) + # check-versions fails -> exit 3, even with --create-tag set. + result = _invoke("--check-versions", "--create-tag", "--dry-run") + assert result.exit_code == 3 + + def test_pack_create_tag_refuses_on_missing_remote_for_push( + self, tmp_path, monkeypatch, run_git_cmd + ): + repo = _scaffold_repo(tmp_path, run_git_cmd) + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag", "--push") + assert result.exit_code == 1 + assert "no 'origin' remote" in result.output + + +class TestJsonEnvelope: + def test_pack_json_envelope_contains_tag_creation_block_on_success( + self, tmp_path, monkeypatch, run_git_cmd + ): + repo = _scaffold_repo(tmp_path, run_git_cmd) + monkeypatch.chdir(repo) + result = _invoke( + "--check-versions", "--create-tag", "--dry-run", "--json", mix_stderr=False + ) + assert result.exit_code == 0, result.output + data = _parse_json(result.output) + assert data["tag_creation"] is not None + assert data["tag_creation"]["status"] == "ok" + assert data["tag_creation"]["created"] == ["v1.0.0"] + assert data["tag_creation"]["refusal_code"] is None + # No push requested -> tag_push key present but null. + assert data["tag_push"] is None + + def test_pack_json_envelope_contains_refusal_code_on_failure( + self, tmp_path, monkeypatch, run_git_cmd + ): + repo = _scaffold_repo(tmp_path, run_git_cmd) + (repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag", "--json", mix_stderr=False) + assert result.exit_code == 1, result.output + data = _parse_json(result.output) + assert data["tag_creation"]["status"] == "refused" + assert data["tag_creation"]["refusal_code"] == "dirty_tree" + assert data["ok"] is False + codes = {e["code"] for e in data["errors"]} + assert "dirty_tree" in codes + + def test_pack_json_envelope_no_check_versions_refusal_includes_code( + self, tmp_path, monkeypatch, run_git_cmd + ): + repo = _scaffold_repo(tmp_path, run_git_cmd) + monkeypatch.chdir(repo) + result = _invoke("--create-tag", "--dry-run", "--json", mix_stderr=False) + assert result.exit_code == 1 + data = _parse_json(result.output) + assert data["tag_creation"]["refusal_code"] == "no_check_versions" + + def test_pack_json_envelope_keys_always_present(self, tmp_path, monkeypatch, run_git_cmd): + """Even without tagging flags, the envelope keys must appear (as null).""" + repo = _scaffold_repo(tmp_path, run_git_cmd) + monkeypatch.chdir(repo) + result = _invoke("--dry-run", "--json", mix_stderr=False) + data = _parse_json(result.output) + assert "tag_creation" in data + assert "tag_push" in data + assert data["tag_creation"] is None + assert data["tag_push"] is None + + def test_pack_json_envelope_sources_error_from_tag_push_when_push_refuses( + self, tmp_path, monkeypatch, run_git_cmd + ): + """Regression-trap: when push refuses (creation succeeded), the + envelope.errors[].code must come from tag_push_payload, not from + tag_creation_payload (which has refusal_code=None for ok status).""" + repo = _scaffold_repo(tmp_path, run_git_cmd) + # Point origin to a non-existent path so push fails with git_failure. + # ls-remote also fails (fail-open warning) but preflight still passes. + run_git_cmd(["remote", "add", "origin", str(tmp_path / "no-such-remote.git")], repo) + monkeypatch.chdir(repo) + result = _invoke("--check-versions", "--create-tag", "--push", "--json", mix_stderr=False) + assert result.exit_code == 1, result.output + data = _parse_json(result.output) + # Creation succeeded locally... + assert data["tag_creation"]["status"] == "ok" + assert data["tag_creation"]["refusal_code"] is None + # ...push refused... + assert data["tag_push"]["status"] == "refused" + assert data["tag_push"]["refusal_code"] == "git_failure" + # ...and the top-level error MUST carry the push refusal code, + # not None (the bug python-architect flagged). + codes = {e["code"] for e in data["errors"]} + assert "git_failure" in codes, ( + f"Expected git_failure in {codes}; bug if None or tag_refused appears" + ) + assert None not in codes + + def test_pack_create_tag_refuses_no_marketplace_on_bundle_only_project( + self, tmp_path, monkeypatch, run_git_cmd + ): + """Bundle-only project (no marketplace block) cannot derive tag names.""" + repo = tmp_path / "bundle-only" + repo.mkdir() + (repo / "apm.yml").write_text( + _tw.dedent( + """\ + name: bundle-only + description: bundle. + version: 1.0.0 + dependencies: + apm: [] + """ + ), + encoding="utf-8", + ) + # Minimal lockfile so the bundle path resolves. + (repo / "apm.lock.yaml").write_text( + "apm_lockfile_version: 1\nentries: []\n", encoding="utf-8" + ) + assert run_git_cmd(["init", "-q", "-b", "main"], repo).returncode == 0 + run_git_cmd(["config", "user.name", "Test"], repo) + run_git_cmd(["config", "user.email", "test@example.com"], repo) + run_git_cmd(["config", "commit.gpgSign", "false"], repo) + run_git_cmd(["config", "tag.gpgSign", "false"], repo) + run_git_cmd(["add", "-A"], repo) + assert run_git_cmd(["commit", "-q", "-m", "init"], repo).returncode == 0 + monkeypatch.chdir(repo) + result = _invoke( + "--check-versions", "--create-tag", "--dry-run", "--json", mix_stderr=False + ) + assert result.exit_code == 1 + data = _parse_json(result.output) + assert data["tag_creation"]["refusal_code"] == "no_marketplace" + + +class TestHelp: + def test_help_mentions_create_tag_and_push(self): + result = CliRunner().invoke(pack_cmd, ["--help"]) + assert result.exit_code == 0 + assert "--create-tag" in result.output + assert "--push" in result.output diff --git a/tests/unit/release/__init__.py b/tests/unit/release/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/release/conftest.py b/tests/unit/release/conftest.py new file mode 100644 index 000000000..060745c11 --- /dev/null +++ b/tests/unit/release/conftest.py @@ -0,0 +1,113 @@ +"""Shared fixtures for release-engineering tests.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def _git_available() -> bool: + return shutil.which("git") is not None + + +requires_git = pytest.mark.skipif( + not _git_available(), + reason="git executable not on PATH", +) + + +def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess: + env = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + # Disable any user-level gpg signing config that would prompt. + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + } + return subprocess.run( + ["git", *args], + cwd=str(cwd), + capture_output=True, + text=True, + env=env, + check=False, + ) + + +@pytest.fixture +def git_repo_factory(tmp_path: Path) -> Callable[..., Path]: + """Factory creating an initialised git repo for tagger tests. + + Returns a callable. Each call yields a fresh directory under tmp_path. + + Kwargs: + subdir: subdirectory name (default: 'repo') + dirty: if True, leave an unstaged file behind after the initial commit + existing_tags: list of tag names to pre-create on HEAD + with_remote: if True, attach a local bare repo as 'origin' + remote_tags: list of tag names to pre-create on the remote + """ + counter = {"n": 0} + + def _factory( + *, + subdir: str | None = None, + dirty: bool = False, + existing_tags: list[str] | None = None, + with_remote: bool = False, + remote_tags: list[str] | None = None, + ) -> Path: + counter["n"] += 1 + name = subdir or f"repo{counter['n']}" + repo = tmp_path / name + repo.mkdir(parents=True, exist_ok=True) + assert _run(["init", "-q", "-b", "main"], repo).returncode == 0 + # Local repo identity (avoid relying on global config). + _run(["config", "user.name", "Test"], repo) + _run(["config", "user.email", "test@example.com"], repo) + _run(["config", "commit.gpgSign", "false"], repo) + _run(["config", "tag.gpgSign", "false"], repo) + (repo / "README.md").write_text("init\n", encoding="utf-8") + _run(["add", "README.md"], repo) + assert _run(["commit", "-q", "-m", "init"], repo).returncode == 0 + + if with_remote: + bare = tmp_path / f"{name}-remote.git" + assert _run(["init", "-q", "--bare", str(bare)], tmp_path).returncode == 0 + _run(["remote", "add", "origin", str(bare)], repo) + # Push main so origin has a usable ref. + _run(["push", "-q", "origin", "main"], repo) + if remote_tags: + for t in remote_tags: + _run(["tag", t], repo) + _run(["push", "-q", "origin", f"refs/tags/{t}:refs/tags/{t}"], repo) + _run(["tag", "-d", t], repo) + + if existing_tags: + for t in existing_tags: + _run(["tag", "-a", "-m", f"pre {t}", t], repo) + + if dirty: + (repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") + + return repo + + return _factory + + +@pytest.fixture +def run_git_cmd(): + """Wrapper to invoke git from tests without leaking env.""" + + def _run_cmd(args: list[str], cwd: Path) -> subprocess.CompletedProcess: + return _run(args, cwd) + + return _run_cmd diff --git a/tests/unit/release/test_git_tagger.py b/tests/unit/release/test_git_tagger.py new file mode 100644 index 000000000..5c6fbae5d --- /dev/null +++ b/tests/unit/release/test_git_tagger.py @@ -0,0 +1,354 @@ +"""Unit tests for ``apm_cli.release.git_tagger.GitTagger``.""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.release.git_tagger import ( + REFUSAL_DIRTY_TREE, + REFUSAL_GIT_FAILURE, + REFUSAL_NO_REMOTE, + REFUSAL_TAG_EXISTS, + REFUSAL_VERSION_MISMATCH, + GitTagger, + TaggingRefusal, +) + +pytestmark = pytest.mark.skipif( + __import__("shutil").which("git") is None, + reason="git executable not on PATH", +) + + +# --------------------------------------------------------------------------- +# plan_tags +# --------------------------------------------------------------------------- + + +class TestPlanTags: + def test_plan_tags_lockstep_produces_single_tag(self, git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + plans = tagger.plan_tags( + strategy="lockstep", + marketplace_version="1.2.0", + packages=[{"name": "alpha", "version": "1.2.0"}], + ) + assert len(plans) == 1 + assert plans[0].name == "v1.2.0" + assert plans[0].source_package is None + assert plans[0].annotation == "Release v1.2.0" + assert len(plans[0].target_sha) == 40 + + def test_plan_tags_per_package_produces_one_per_package(self, git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + plans = tagger.plan_tags( + strategy="per_package", + marketplace_version="1.0.0", + packages=[ + {"name": "alpha", "version": "1.2.0"}, + {"name": "beta", "version": "2.0.0"}, + ], + ) + assert [p.name for p in plans] == ["alpha-v1.2.0", "beta-v2.0.0"] + assert [p.source_package for p in plans] == ["alpha", "beta"] + + def test_plan_tags_tag_pattern_honors_per_package_overrides(self, git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + plans = tagger.plan_tags( + strategy="tag_pattern", + marketplace_version="1.0.0", + packages=[ + {"name": "alpha", "version": "1.2.0"}, # uses default + { + "name": "beta", + "version": "2.0.0", + "tag_pattern": "release/{name}-{version}", + }, + ], + tag_pattern="v{version}", + ) + assert plans[0].name == "v1.2.0" + assert plans[1].name == "release/beta-2.0.0" + + def test_plan_tags_lockstep_without_version_raises(self, git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + with pytest.raises(TaggingRefusal) as exc: + tagger.plan_tags( + strategy="lockstep", + marketplace_version=None, + packages=[], + ) + assert exc.value.code == REFUSAL_VERSION_MISMATCH + + def test_plan_tags_accepts_dataclass_packages(self, git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + pkg = SimpleNamespace(name="alpha", version="1.0.0", tag_pattern=None) + plans = tagger.plan_tags( + strategy="per_package", + marketplace_version=None, + packages=[pkg], + ) + assert plans[0].name == "alpha-v1.0.0" + + +# --------------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------------- + + +class TestPreflight: + def test_preflight_passes_when_all_clean(self, git_repo_factory): + repo = git_repo_factory(with_remote=True) + tagger = GitTagger(repo) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + # Should not raise. + tagger.preflight(plans, remote="origin") + + def test_preflight_raises_on_dirty_tree(self, git_repo_factory): + repo = git_repo_factory(dirty=True) + tagger = GitTagger(repo) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + with pytest.raises(TaggingRefusal) as exc: + tagger.preflight(plans, remote=None) + assert exc.value.code == REFUSAL_DIRTY_TREE + assert "uncommitted changes" in exc.value.message + assert exc.value.hint is not None + + def test_preflight_raises_on_existing_local_tag(self, git_repo_factory): + repo = git_repo_factory(existing_tags=["v1.2.0"]) + tagger = GitTagger(repo) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + with pytest.raises(TaggingRefusal) as exc: + tagger.preflight(plans, remote=None) + assert exc.value.code == REFUSAL_TAG_EXISTS + assert "v1.2.0" in exc.value.message + + def test_preflight_raises_on_existing_remote_tag(self, git_repo_factory): + repo = git_repo_factory(with_remote=True, remote_tags=["v1.2.0"]) + tagger = GitTagger(repo) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + with pytest.raises(TaggingRefusal) as exc: + tagger.preflight(plans, remote="origin") + assert exc.value.code == REFUSAL_TAG_EXISTS + assert "origin" in exc.value.message + + def test_preflight_raises_on_missing_remote(self, git_repo_factory): + repo = git_repo_factory(with_remote=False) + tagger = GitTagger(repo) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + with pytest.raises(TaggingRefusal) as exc: + tagger.preflight(plans, remote="origin") + assert exc.value.code == REFUSAL_NO_REMOTE + + def test_preflight_passes_when_remote_unreachable(self, git_repo_factory, run_git_cmd): + """If ls-remote fails (e.g. network), skip remote-tag check rather than refuse.""" + repo = git_repo_factory() + # Add a remote pointing at a non-existent local path. + bogus = repo.parent / "does-not-exist.git" + run_git_cmd(["remote", "add", "origin", str(bogus)], repo) + tagger = GitTagger(repo) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + # Should not raise: ls-remote returns non-zero, we degrade gracefully. + tagger.preflight(plans, remote="origin") + + def test_preflight_raises_on_manifest_tag_version_mismatch(self, git_repo_factory): + """A lockstep plan derived from None version must refuse.""" + repo = git_repo_factory() + tagger = GitTagger(repo) + with pytest.raises(TaggingRefusal) as exc: + tagger.plan_tags(strategy="lockstep", marketplace_version="", packages=[]) + assert exc.value.code == REFUSAL_VERSION_MISMATCH + + +# --------------------------------------------------------------------------- +# create +# --------------------------------------------------------------------------- + + +class TestCreate: + def test_create_is_no_op_in_dry_run(self, git_repo_factory, run_git_cmd): + repo = git_repo_factory() + logger = MagicMock() + tagger = GitTagger(repo, dry_run=True, logger=logger) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + names = tagger.create(plans) + assert names == ["v1.2.0"] + # No tag actually created. + out = run_git_cmd(["tag", "--list"], repo).stdout + assert "v1.2.0" not in out + # Dry-run notice emitted. + logger.dry_run_notice.assert_called() + + def test_create_invokes_git_tag_minus_a_minus_m(self, git_repo_factory, run_git_cmd): + repo = git_repo_factory() + logger = MagicMock() + tagger = GitTagger(repo, logger=logger) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + names = tagger.create(plans) + assert names == ["v1.2.0"] + # The tag exists and is annotated. + kinds = run_git_cmd(["cat-file", "-t", "v1.2.0"], repo).stdout.strip() + assert kinds == "tag" # annotated, not "commit" + msg = run_git_cmd(["tag", "-l", "--format=%(contents:subject)", "v1.2.0"], repo) + assert "Release v1.2.0" in msg.stdout + logger.success.assert_called() + + def test_create_propagates_failure_as_refusal(self, git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + # Plan a tag with the same name as an existing tag we will create + # behind tagger's back to force a collision. + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + # Sneakily create the tag. + subprocess.run(["git", "tag", "v1.2.0"], cwd=str(repo), check=True) + with pytest.raises(TaggingRefusal) as exc: + tagger.create(plans) + assert exc.value.code == REFUSAL_GIT_FAILURE + + +# --------------------------------------------------------------------------- +# push +# --------------------------------------------------------------------------- + + +class TestPush: + def test_push_is_no_op_in_dry_run(self, git_repo_factory, run_git_cmd): + repo = git_repo_factory(with_remote=True) + logger = MagicMock() + tagger = GitTagger(repo, dry_run=True, logger=logger) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + # Create locally so we have a candidate (dry-run: nothing happens) + names = tagger.create(plans) + result = tagger.push(names, remote="origin") + assert result == ["v1.2.0"] + # Verify nothing pushed to the bare remote. + remote_refs = run_git_cmd(["ls-remote", "--tags", "origin"], repo).stdout + assert "v1.2.0" not in remote_refs + logger.dry_run_notice.assert_called() + + def test_push_invokes_git_push_with_explicit_tag_refs_not_minus_minus_tags( + self, git_repo_factory + ): + """REGRESSION TRAP: push must NEVER use 'git push --tags'. + + Locks in the explicit ``refs/tags/:refs/tags/`` form + so the command can never silently force-push every local tag. + """ + repo = git_repo_factory(with_remote=True) + tagger = GitTagger(repo) + plans = tagger.plan_tags( + strategy="per_package", + marketplace_version=None, + packages=[ + {"name": "alpha", "version": "1.0.0"}, + {"name": "beta", "version": "2.0.0"}, + ], + ) + tagger.create(plans) + captured_calls: list[list[str]] = [] + + from apm_cli.release import git_tagger as gt_module + + real_run = gt_module.run_git + + def spy(args, **kwargs): + captured_calls.append(list(args)) + return real_run(args, **kwargs) + + with patch.object(gt_module, "run_git", side_effect=spy): + tagger.push(["alpha-v1.0.0", "beta-v2.0.0"], remote="origin") + + push_calls = [c for c in captured_calls if c and c[0] == "push"] + assert len(push_calls) == 1 + assert "--tags" not in push_calls[0] + # Explicit refspecs for both tags, both directions. + joined = " ".join(push_calls[0]) + assert "refs/tags/alpha-v1.0.0:refs/tags/alpha-v1.0.0" in joined + assert "refs/tags/beta-v2.0.0:refs/tags/beta-v2.0.0" in joined + + def test_push_actually_lands_tag_on_remote(self, git_repo_factory, run_git_cmd): + repo = git_repo_factory(with_remote=True) + tagger = GitTagger(repo) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.2.0", packages=[]) + tagger.create(plans) + tagger.push(["v1.2.0"], remote="origin") + remote_refs = run_git_cmd(["ls-remote", "--tags", "origin"], repo).stdout + assert "refs/tags/v1.2.0" in remote_refs + + def test_push_empty_list_is_no_op(self, git_repo_factory): + repo = git_repo_factory(with_remote=True) + tagger = GitTagger(repo) + result = tagger.push([], remote="origin") + assert result == [] + + +# --------------------------------------------------------------------------- +# Subprocess failure -> token-scrubbed propagation +# --------------------------------------------------------------------------- + + +def test_subprocess_failure_propagates_with_token_scrubbed_stderr(git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + + # Fake a push failure whose stderr contains an auth token in a URL. + fake_result = subprocess.CompletedProcess( + args=["git", "push", "origin"], + returncode=128, + stdout="", + stderr="fatal: unable to access 'https://abcdef123456@example.com/repo': denied", + ) + + from apm_cli.release import git_tagger as gt_module + + with patch.object(gt_module, "run_git", return_value=fake_result): + with pytest.raises(TaggingRefusal) as exc: + tagger.push(["v1.0.0"], remote="origin") + + assert exc.value.code == REFUSAL_GIT_FAILURE + # Token must be redacted. + assert "abcdef123456" not in exc.value.message + assert "***" in exc.value.message + + +# --------------------------------------------------------------------------- +# Misc: tag names with slashes, head detection +# --------------------------------------------------------------------------- + + +def test_plan_tags_allows_slash_in_tag_name(git_repo_factory): + repo = git_repo_factory() + tagger = GitTagger(repo) + plans = tagger.plan_tags( + strategy="tag_pattern", + marketplace_version=None, + packages=[{"name": "alpha", "version": "1.0.0", "tag_pattern": "release/{version}"}], + ) + assert plans[0].name == "release/1.0.0" + + +def test_log_levels_fall_back_to_info(git_repo_factory): + """Loggers missing a level still receive the message via ``info``.""" + repo = git_repo_factory() + + class InfoOnlyLogger: + def __init__(self): + self.messages: list[str] = [] + + def info(self, msg): + self.messages.append(msg) + + logger = InfoOnlyLogger() + tagger = GitTagger(repo, dry_run=True, logger=logger) + plans = tagger.plan_tags(strategy="lockstep", marketplace_version="1.0.0", packages=[]) + tagger.create(plans) + assert any("Would create tag" in m for m in logger.messages)