Skip to content

Commit ba30947

Browse files
danielmeppielDaniel MeppielCopilot
authored
chore(release): cut 0.12.0 (#1112)
* 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 <local-bundle>, 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> * 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:<hex>'. 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:<hex>' 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> * 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> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f88a11f commit ba30947

7 files changed

Lines changed: 180 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.12.0] - 2026-05-03
11+
1012
### Added
1113

1214
- **`--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
4345

4446
- **`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.
4547
- `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)
46-
- `apm install` no longer masks local-bundle install failures with `UnboundLocalError`. (#PR_NUMBER)
48+
- `apm install` no longer masks local-bundle install failures with `UnboundLocalError`. (#1108)
4749
- **`apm install <pkg>@<marketplace>` 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)
4850
- **`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)
4951
- `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)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "apm-cli"
7-
version = "0.11.0"
7+
version = "0.12.0"
88
description = "MCP configuration tool"
99
readme = "README.md"
1010
requires-python = ">=3.10"

scripts/test-integration.sh

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,43 @@ run_e2e_tests() {
461461
log_error "Agent-skills target E2E tests failed!"
462462
exit 1
463463
fi
464-
464+
465+
# Run unified pack format E2E tests -- offline, no tokens needed
466+
# Guards the 0.12.0 default flip from --format apm to --format plugin.
467+
log_info "Running unified pack format E2E tests..."
468+
echo "Command: pytest tests/integration/test_pack_unified.py -v -s --tb=short"
469+
470+
if pytest tests/integration/test_pack_unified.py -v -s --tb=short; then
471+
log_success "Unified pack format E2E tests passed!"
472+
else
473+
log_error "Unified pack format E2E tests failed!"
474+
exit 1
475+
fi
476+
477+
# Run Copilot compile target E2E tests -- offline, no tokens needed
478+
# Guards .github/copilot-instructions.md generation + idempotent cleanup.
479+
log_info "Running Copilot compile target E2E tests..."
480+
echo "Command: pytest tests/integration/test_compile_copilot_root_instructions.py -v -s --tb=short"
481+
482+
if pytest tests/integration/test_compile_copilot_root_instructions.py -v -s --tb=short; then
483+
log_success "Copilot compile target E2E tests passed!"
484+
else
485+
log_error "Copilot compile target E2E tests failed!"
486+
exit 1
487+
fi
488+
489+
# Run transitive local-path chain E2E tests -- offline, no tokens needed
490+
# Guards local_path anchoring across multi-level local dependency chains.
491+
log_info "Running transitive local-path chain E2E tests..."
492+
echo "Command: pytest tests/integration/test_transitive_chain_e2e.py -v -s --tb=short"
493+
494+
if pytest tests/integration/test_transitive_chain_e2e.py -v -s --tb=short; then
495+
log_success "Transitive local-path chain E2E tests passed!"
496+
else
497+
log_error "Transitive local-path chain E2E tests failed!"
498+
exit 1
499+
fi
500+
465501
log_success "All integration test suites completed successfully!"
466502

467503

src/apm_cli/install/services.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,8 @@ def integrate_local_bundle(
377377
import hashlib
378378
import shutil
379379

380+
from apm_cli.utils.content_hash import compute_file_hash
381+
380382
from ..core.scope import InstallScope
381383
from ..utils.path_security import (
382384
PathTraversalError,
@@ -490,7 +492,13 @@ def integrate_local_bundle(
490492

491493
if dry_run:
492494
deployed_files.append(record)
493-
deployed_hashes[record] = expected_hash
495+
# Normalize to "sha256:<hex>" so the dry-run lockfile preview
496+
# matches the format written by ``compute_file_hash`` on the
497+
# real deploy path. ``expected_hash`` here is bare hex from
498+
# ``pack.bundle_files``; without the prefix, downstream
499+
# exact-match comparisons (e.g. ``cleanup.py`` provenance
500+
# check) treat the file as user-edited and skip cleanup.
501+
deployed_hashes[record] = f"sha256:{expected_hash}"
494502
if logger:
495503
logger.verbose_detail(f"[dry-run] would deploy {record}")
496504
continue
@@ -521,7 +529,12 @@ def integrate_local_bundle(
521529
# provenance now keeps the lockfile honest if future transforms
522530
# (frontmatter injection, etc.) mutate content during deploy.
523531
deployed_files.append(record)
524-
deployed_hashes[record] = hashlib.sha256(dest.read_bytes()).hexdigest()
532+
# Use ``compute_file_hash`` so the recorded value carries the
533+
# canonical ``sha256:<hex>`` prefix. Matches the format written
534+
# by the regular install pipeline (``compute_deployed_hashes``)
535+
# so subsequent stale-cleanup provenance checks compare equal
536+
# instead of mis-classifying these files as user-edited.
537+
deployed_hashes[record] = compute_file_hash(dest)
525538
if logger:
526539
logger.verbose_detail(f"deployed {record}")
527540

src/apm_cli/integration/cleanup.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,17 @@ def remove_stale_deployed_files(
221221
package=dep_key,
222222
)
223223
continue
224-
if actual_hash != expected_hash:
224+
225+
# Defensive normalization: ``recorded_hashes`` may carry either
226+
# the canonical ``sha256:<hex>`` (regular install pipeline) or
227+
# bare ``<hex>`` (legacy local-bundle installs prior to the
228+
# 0.12.0 fix). ``compute_file_hash`` always returns the
229+
# prefixed form, so strip the prefix from both sides before
230+
# comparing to avoid false "user-edited" classifications.
231+
def _strip_sha256(h: str) -> str:
232+
return h[len("sha256:") :] if h.startswith("sha256:") else h
233+
234+
if _strip_sha256(actual_hash) != _strip_sha256(expected_hash):
225235
result.skipped_user_edit.append(stale_path)
226236
diagnostics.warn(
227237
(

tests/integration/test_install_local_bundle_e2e.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,17 +491,126 @@ def test_local_lockfile_records_deployed_files(
491491
# Every recorded path must have a matching hash entry.
492492
assert set(deployed_files) == set(deployed_hashes.keys())
493493

494-
# Each recorded path's hash must match the on-disk file's actual SHA-256.
494+
# Each recorded path's hash must match the on-disk file's actual
495+
# SHA-256, written in the canonical ``sha256:<hex>`` form so it
496+
# compares equal against ``compute_file_hash`` output (regression
497+
# guard: prior to 0.12.0 the local-bundle path wrote bare hex,
498+
# which mis-classified files as "user-edited" in stale-cleanup).
495499
for record_path, expected_hash in deployed_hashes.items():
496500
# Records may be absolute or project-relative; resolve both.
497501
candidate = Path(record_path)
498502
if not candidate.is_absolute():
499503
candidate = project / candidate
500504
assert candidate.is_file(), f"missing deployed file: {candidate}"
501-
actual_hash = hashlib.sha256(candidate.read_bytes()).hexdigest()
505+
assert expected_hash.startswith("sha256:"), (
506+
f"hash for {record_path!r} must use canonical 'sha256:<hex>' "
507+
f"form, got {expected_hash!r}"
508+
)
509+
actual_hash = "sha256:" + hashlib.sha256(candidate.read_bytes()).hexdigest()
502510
assert actual_hash == expected_hash, f"hash mismatch for {candidate}"
503511

504512

513+
# ---------------------------------------------------------------------------
514+
# E2E: Hash-format consistency across install flows (regression for 0.12.0)
515+
# ---------------------------------------------------------------------------
516+
517+
518+
class TestLocalBundleHashFormatCrossFlow:
519+
"""Pin the hash format contract that ties ``apm install <bundle>`` to
520+
the stale-cleanup provenance check.
521+
522+
Prior to the 0.12.0 fix, ``integrate_local_bundle`` wrote bare
523+
``<hex>`` into ``local_deployed_file_hashes`` while
524+
``compute_file_hash`` (used by ``cleanup.py``) emitted the canonical
525+
``sha256:<hex>``. An exact-match comparison in
526+
``remove_stale_deployed_files`` then mis-classified every
527+
bundle-deployed file as "user-edited" and refused to remove stale
528+
entries on subsequent installs.
529+
"""
530+
531+
def test_local_bundle_hash_matches_compute_file_hash_format(
532+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
533+
) -> None:
534+
"""Hash recorded by local-bundle install must equal compute_file_hash output."""
535+
from apm_cli.utils.content_hash import compute_file_hash
536+
537+
bundle = _make_plugin_bundle(tmp_path / "src")
538+
project = _make_project(tmp_path / "dst")
539+
540+
result = _invoke_install(
541+
project, str(bundle), "--target", "copilot", monkeypatch=monkeypatch
542+
)
543+
assert result.exit_code == 0, f"stdout={result.output!r}\nstderr={result.stderr!r}"
544+
545+
data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8"))
546+
deployed_hashes = data.get("local_deployed_file_hashes") or {}
547+
assert deployed_hashes, "local_deployed_file_hashes is empty"
548+
549+
for record_path, recorded in deployed_hashes.items():
550+
candidate = Path(record_path)
551+
if not candidate.is_absolute():
552+
candidate = project / candidate
553+
# Equality with compute_file_hash is the contract: this is the
554+
# exact comparison cleanup.py uses for stale-file provenance.
555+
assert recorded == compute_file_hash(candidate), (
556+
f"hash format drift for {record_path!r}: "
557+
f"recorded={recorded!r} vs compute_file_hash={compute_file_hash(candidate)!r}. "
558+
"Stale-cleanup provenance check would mis-classify this file as user-edited."
559+
)
560+
561+
def test_recorded_hash_compares_equal_in_cleanup_provenance_check(
562+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
563+
) -> None:
564+
"""Hashes recorded by local-bundle install must NOT trip the
565+
``cleanup.remove_stale_deployed_files`` "user-edited" guard
566+
when the deployed file is unchanged.
567+
568+
This drives the actual code path that the regression broke:
569+
``cleanup.py`` reads ``recorded_hashes`` from the lockfile (set
570+
by ``integrate_local_bundle``), recomputes via
571+
``compute_file_hash``, and compares. Prior to 0.12.0 the
572+
comparison always failed (bare hex vs ``sha256:<hex>``), so
573+
every bundle-deployed file was permanently classified as
574+
user-edited and stale-cleanup was a no-op.
575+
"""
576+
from apm_cli.integration.cleanup import remove_stale_deployed_files
577+
from apm_cli.utils.diagnostics import DiagnosticCollector
578+
579+
bundle = _make_plugin_bundle(tmp_path / "src")
580+
project = _make_project(tmp_path / "dst")
581+
result = _invoke_install(
582+
project, str(bundle), "--target", "copilot", monkeypatch=monkeypatch
583+
)
584+
assert result.exit_code == 0, f"install failed: {result.output}"
585+
586+
data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8"))
587+
deployed_files = list(data.get("local_deployed_files") or [])
588+
deployed_hashes = dict(data.get("local_deployed_file_hashes") or {})
589+
assert deployed_files and deployed_hashes
590+
591+
# Pretend every file is now stale and ask cleanup to remove them.
592+
# The provenance gate should pass (file is unchanged), so cleanup
593+
# actually deletes them -- not skip them as "user-edited".
594+
diagnostics = DiagnosticCollector()
595+
cleanup_result = remove_stale_deployed_files(
596+
deployed_files,
597+
project,
598+
dep_key="<local-bundle-test>",
599+
targets=None,
600+
diagnostics=diagnostics,
601+
recorded_hashes=deployed_hashes,
602+
)
603+
604+
assert not cleanup_result.skipped_user_edit, (
605+
"cleanup mis-classified bundle-deployed files as user-edited: "
606+
f"{cleanup_result.skipped_user_edit}. "
607+
"Likely a hash-format regression between integrate_local_bundle "
608+
"(write side) and compute_file_hash (read side in cleanup.py)."
609+
)
610+
# Every file passed the provenance check and was deleted.
611+
assert set(cleanup_result.deleted) == set(deployed_files)
612+
613+
505614
# ---------------------------------------------------------------------------
506615
# E2E: Air-gap proof (zero network I/O)
507616
# ---------------------------------------------------------------------------

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)