From ce77a7c48b4049dd53bc6daf5e066f1aa34d4b3c Mon Sep 17 00:00:00 2001 From: Daniel Meppiel Date: Sun, 3 May 2026 02:43:14 +0200 Subject: [PATCH 1/3] chore(release): cut 0.12.0 Promotes [Unreleased] -> [0.12.0] - 2026-05-03 and bumps pyproject.toml + uv.lock to 0.12.0. Sanitization: - Filled the residual (#PR_NUMBER) placeholder on the local-bundle UnboundLocalError fix entry -> (#1108) - Preserved an empty [Unreleased] section above 0.12.0 so the next contributor PR can append entries without re-introducing the heading Version-bump rationale: 0.12.0 (minor bump) chosen over 0.11.1 because this release ships TWO BREAKING changes: - 'apm pack' now produces a Claude Code plugin directory by default; the legacy bundle layout requires --format apm (#1061) - Dropped support for .collection.yml / .collection.yaml virtual packages (#1097) plus several net-new features (Windsurf target, Claude Code MCP install target, --target agent-skills, apm install , apm compile -t copilot, marketplace add HTTPS/nested URLs, slash-command argument hints in Claude). Strict semver in 0.x: minor for features-with-break, patch only for bugfixes. 44 commits since v0.11.0. Validation: - ruff check src/ tests/ -- silent - ruff format --check src/ tests/ -- silent - uv lock -- regenerated cleanly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d27bf5df3..03063022a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] - 2026-05-03 + ### Added - **`--target agent-skills` deploys skills to `.agents/skills/` (cross-client shared directory).** The new target writes `SKILL.md` files to the [agentskills.io](https://agentskills.io) standard location without tying them to a single client. Excluded from `--target all` (explicit opt-in only); combine with `--target all,agent-skills` for both. Deduplicates with Codex when both targets resolve to the same path. User-scope (`-g`) deploys to `~/.agents/skills/`. (closes #737) @@ -43,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`apm install` now anchors transitive `local_path` deps on the declaring package's directory (npm/pip/cargo parity).** Sibling/monorepo layouts (e.g. `../base` declared inside `packages/specialized/apm.yml`) now resolve relative to the declaring package, not the consumer's project root. **Security tightening:** remote-cloned packages can no longer declare `local_path` deps -- both relative and absolute paths are rejected at `ERROR` severity at resolve time. (#1111, closes #857) Thanks @JahanzaibTayyab. - `apm compile` no longer silently drops instructions without an `applyTo` pattern from generated `AGENTS.md` and `CLAUDE.md`; globals now render under a `## Global Instructions` section, matching the optimizer's existing `(global)` placement (#1088, closes #1072) -- `apm install` no longer masks local-bundle install failures with `UnboundLocalError`. (#PR_NUMBER) +- `apm install` no longer masks local-bundle install failures with `UnboundLocalError`. (#1108) - **`apm install @` no longer fails for all marketplace packages.** The install resolver now accepts both legacy and current marketplace key names: `repository`/`repo` for github sources, `url`/`repo` for git-subdir sources, and `type`/`source` as the source-type discriminator. A scheme guard rejects full URLs passed through the `url` fallback. (#1106, closes #1105) - **`apm install --update` no longer fails for GHES/generic hosts** that rely on git credential helpers (e.g., `git-credential-manager`) for authentication. The preflight auth probe was blocking credential helpers by setting `GIT_CONFIG_GLOBAL=/dev/null`; it now uses the same relaxed environment as the clone fallback path for non-GitHub/non-ADO hosts. (#1082) - `apm compile --dry-run -t copilot` now faithfully simulates the hand-authored file guard: a `.github/copilot-instructions.md` lacking the APM marker is reported as `skipped=1` (matching the real run) instead of as `generated=1`. Previously dry-run would claim a write that a real run would refuse, giving CI preview gates a false signal. (#1048) diff --git a/pyproject.toml b/pyproject.toml index 5c28cb945..828677e6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apm-cli" -version = "0.11.0" +version = "0.12.0" description = "MCP configuration tool" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index fc5c8648a..5eed38b54 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.11.0" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -1995,4 +1995,4 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, -] \ No newline at end of file +] From 45b15bf8b3f621e996e31c6b0bb7b80f8777d6e4 Mon Sep 17 00:00:00 2001 From: Daniel Meppiel Date: Sun, 3 May 2026 10:22:47 +0200 Subject: [PATCH 2/3] fix(install): align local-bundle hash format with compute_file_hash integrate_local_bundle() recorded bare hex hashes in local_deployed_file_hashes, but cleanup.py provenance check compares against compute_file_hash() which returns 'sha256:'. The mismatch caused stale-cleanup of local-bundle files to skip every file as 'user-edited' instead of removing files no longer in the bundle. - services.py: write 'sha256:' on real deploy and dry-run paths - cleanup.py: defensively normalize both sides of the equality check (handles legacy 0.12.0-rc lockfiles with bare hex) - regression tests cover both the format consistency and the cross-flow cleanup interaction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/install/services.py | 17 ++- src/apm_cli/integration/cleanup.py | 12 +- .../test_install_local_bundle_e2e.py | 113 +++++++++++++++++- 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index 10edbf959..aadac4c7e 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -377,6 +377,8 @@ def integrate_local_bundle( import hashlib import shutil + from apm_cli.utils.content_hash import compute_file_hash + from ..core.scope import InstallScope from ..utils.path_security import ( PathTraversalError, @@ -490,7 +492,13 @@ def integrate_local_bundle( if dry_run: deployed_files.append(record) - deployed_hashes[record] = expected_hash + # Normalize to "sha256:" so the dry-run lockfile preview + # matches the format written by ``compute_file_hash`` on the + # real deploy path. ``expected_hash`` here is bare hex from + # ``pack.bundle_files``; without the prefix, downstream + # exact-match comparisons (e.g. ``cleanup.py`` provenance + # check) treat the file as user-edited and skip cleanup. + deployed_hashes[record] = f"sha256:{expected_hash}" if logger: logger.verbose_detail(f"[dry-run] would deploy {record}") continue @@ -521,7 +529,12 @@ def integrate_local_bundle( # provenance now keeps the lockfile honest if future transforms # (frontmatter injection, etc.) mutate content during deploy. deployed_files.append(record) - deployed_hashes[record] = hashlib.sha256(dest.read_bytes()).hexdigest() + # Use ``compute_file_hash`` so the recorded value carries the + # canonical ``sha256:`` prefix. Matches the format written + # by the regular install pipeline (``compute_deployed_hashes``) + # so subsequent stale-cleanup provenance checks compare equal + # instead of mis-classifying these files as user-edited. + deployed_hashes[record] = compute_file_hash(dest) if logger: logger.verbose_detail(f"deployed {record}") diff --git a/src/apm_cli/integration/cleanup.py b/src/apm_cli/integration/cleanup.py index 07d09e086..8c9610893 100644 --- a/src/apm_cli/integration/cleanup.py +++ b/src/apm_cli/integration/cleanup.py @@ -221,7 +221,17 @@ def remove_stale_deployed_files( package=dep_key, ) continue - if actual_hash != expected_hash: + + # Defensive normalization: ``recorded_hashes`` may carry either + # the canonical ``sha256:`` (regular install pipeline) or + # bare ```` (legacy local-bundle installs prior to the + # 0.12.0 fix). ``compute_file_hash`` always returns the + # prefixed form, so strip the prefix from both sides before + # comparing to avoid false "user-edited" classifications. + def _strip_sha256(h: str) -> str: + return h[len("sha256:") :] if h.startswith("sha256:") else h + + if _strip_sha256(actual_hash) != _strip_sha256(expected_hash): result.skipped_user_edit.append(stale_path) diagnostics.warn( ( diff --git a/tests/integration/test_install_local_bundle_e2e.py b/tests/integration/test_install_local_bundle_e2e.py index 704458974..6a8d085f3 100644 --- a/tests/integration/test_install_local_bundle_e2e.py +++ b/tests/integration/test_install_local_bundle_e2e.py @@ -491,17 +491,126 @@ def test_local_lockfile_records_deployed_files( # Every recorded path must have a matching hash entry. assert set(deployed_files) == set(deployed_hashes.keys()) - # Each recorded path's hash must match the on-disk file's actual SHA-256. + # Each recorded path's hash must match the on-disk file's actual + # SHA-256, written in the canonical ``sha256:`` form so it + # compares equal against ``compute_file_hash`` output (regression + # guard: prior to 0.12.0 the local-bundle path wrote bare hex, + # which mis-classified files as "user-edited" in stale-cleanup). for record_path, expected_hash in deployed_hashes.items(): # Records may be absolute or project-relative; resolve both. candidate = Path(record_path) if not candidate.is_absolute(): candidate = project / candidate assert candidate.is_file(), f"missing deployed file: {candidate}" - actual_hash = hashlib.sha256(candidate.read_bytes()).hexdigest() + assert expected_hash.startswith("sha256:"), ( + f"hash for {record_path!r} must use canonical 'sha256:' " + f"form, got {expected_hash!r}" + ) + actual_hash = "sha256:" + hashlib.sha256(candidate.read_bytes()).hexdigest() assert actual_hash == expected_hash, f"hash mismatch for {candidate}" +# --------------------------------------------------------------------------- +# E2E: Hash-format consistency across install flows (regression for 0.12.0) +# --------------------------------------------------------------------------- + + +class TestLocalBundleHashFormatCrossFlow: + """Pin the hash format contract that ties ``apm install `` to + the stale-cleanup provenance check. + + Prior to the 0.12.0 fix, ``integrate_local_bundle`` wrote bare + ```` into ``local_deployed_file_hashes`` while + ``compute_file_hash`` (used by ``cleanup.py``) emitted the canonical + ``sha256:``. An exact-match comparison in + ``remove_stale_deployed_files`` then mis-classified every + bundle-deployed file as "user-edited" and refused to remove stale + entries on subsequent installs. + """ + + def test_local_bundle_hash_matches_compute_file_hash_format( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Hash recorded by local-bundle install must equal compute_file_hash output.""" + from apm_cli.utils.content_hash import compute_file_hash + + bundle = _make_plugin_bundle(tmp_path / "src") + project = _make_project(tmp_path / "dst") + + result = _invoke_install( + project, str(bundle), "--target", "copilot", monkeypatch=monkeypatch + ) + assert result.exit_code == 0, f"stdout={result.output!r}\nstderr={result.stderr!r}" + + data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + deployed_hashes = data.get("local_deployed_file_hashes") or {} + assert deployed_hashes, "local_deployed_file_hashes is empty" + + for record_path, recorded in deployed_hashes.items(): + candidate = Path(record_path) + if not candidate.is_absolute(): + candidate = project / candidate + # Equality with compute_file_hash is the contract: this is the + # exact comparison cleanup.py uses for stale-file provenance. + assert recorded == compute_file_hash(candidate), ( + f"hash format drift for {record_path!r}: " + f"recorded={recorded!r} vs compute_file_hash={compute_file_hash(candidate)!r}. " + "Stale-cleanup provenance check would mis-classify this file as user-edited." + ) + + def test_recorded_hash_compares_equal_in_cleanup_provenance_check( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Hashes recorded by local-bundle install must NOT trip the + ``cleanup.remove_stale_deployed_files`` "user-edited" guard + when the deployed file is unchanged. + + This drives the actual code path that the regression broke: + ``cleanup.py`` reads ``recorded_hashes`` from the lockfile (set + by ``integrate_local_bundle``), recomputes via + ``compute_file_hash``, and compares. Prior to 0.12.0 the + comparison always failed (bare hex vs ``sha256:``), so + every bundle-deployed file was permanently classified as + user-edited and stale-cleanup was a no-op. + """ + from apm_cli.integration.cleanup import remove_stale_deployed_files + from apm_cli.utils.diagnostics import DiagnosticCollector + + bundle = _make_plugin_bundle(tmp_path / "src") + project = _make_project(tmp_path / "dst") + result = _invoke_install( + project, str(bundle), "--target", "copilot", monkeypatch=monkeypatch + ) + assert result.exit_code == 0, f"install failed: {result.output}" + + data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + deployed_files = list(data.get("local_deployed_files") or []) + deployed_hashes = dict(data.get("local_deployed_file_hashes") or {}) + assert deployed_files and deployed_hashes + + # Pretend every file is now stale and ask cleanup to remove them. + # The provenance gate should pass (file is unchanged), so cleanup + # actually deletes them -- not skip them as "user-edited". + diagnostics = DiagnosticCollector() + cleanup_result = remove_stale_deployed_files( + deployed_files, + project, + dep_key="", + targets=None, + diagnostics=diagnostics, + recorded_hashes=deployed_hashes, + ) + + assert not cleanup_result.skipped_user_edit, ( + "cleanup mis-classified bundle-deployed files as user-edited: " + f"{cleanup_result.skipped_user_edit}. " + "Likely a hash-format regression between integrate_local_bundle " + "(write side) and compute_file_hash (read side in cleanup.py)." + ) + # Every file passed the provenance check and was deleted. + assert set(cleanup_result.deleted) == set(deployed_files) + + # --------------------------------------------------------------------------- # E2E: Air-gap proof (zero network I/O) # --------------------------------------------------------------------------- From b8907811fe47d17b16ba119da17930bf490ebaad Mon Sep 17 00:00:00 2001 From: Daniel Meppiel Date: Sun, 3 May 2026 10:29:26 +0200 Subject: [PATCH 3/3] ci: wire pack/compile/transitive integration tests into CI These three integration test files exist and pass locally but were not enumerated in scripts/test-integration.sh, so CI silently skipped them and could not catch regressions in: - apm pack default format (0.12.0 flipped from 'apm' to 'plugin') - apm compile --target copilot (.github/copilot-instructions.md) - transitive local_path anchoring across multi-level local chains Surfaced by the test-coverage review of PR #1112. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/test-integration.sh | 38 ++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index a48653bc0..b5a255df3 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -461,7 +461,43 @@ run_e2e_tests() { log_error "Agent-skills target E2E tests failed!" exit 1 fi - + + # Run unified pack format E2E tests -- offline, no tokens needed + # Guards the 0.12.0 default flip from --format apm to --format plugin. + log_info "Running unified pack format E2E tests..." + echo "Command: pytest tests/integration/test_pack_unified.py -v -s --tb=short" + + if pytest tests/integration/test_pack_unified.py -v -s --tb=short; then + log_success "Unified pack format E2E tests passed!" + else + log_error "Unified pack format E2E tests failed!" + exit 1 + fi + + # Run Copilot compile target E2E tests -- offline, no tokens needed + # Guards .github/copilot-instructions.md generation + idempotent cleanup. + log_info "Running Copilot compile target E2E tests..." + echo "Command: pytest tests/integration/test_compile_copilot_root_instructions.py -v -s --tb=short" + + if pytest tests/integration/test_compile_copilot_root_instructions.py -v -s --tb=short; then + log_success "Copilot compile target E2E tests passed!" + else + log_error "Copilot compile target E2E tests failed!" + exit 1 + fi + + # Run transitive local-path chain E2E tests -- offline, no tokens needed + # Guards local_path anchoring across multi-level local dependency chains. + log_info "Running transitive local-path chain E2E tests..." + echo "Command: pytest tests/integration/test_transitive_chain_e2e.py -v -s --tb=short" + + if pytest tests/integration/test_transitive_chain_e2e.py -v -s --tb=short; then + log_success "Transitive local-path chain E2E tests passed!" + else + log_error "Transitive local-path chain E2E tests failed!" + exit 1 + fi + log_success "All integration test suites completed successfully!"