Validated the plugin-content-mirror fix (issue #90) against apm's own packing/CI documentation and source: no apm-native mechanism replaces the mirror script (apm's bundler treats .apm/ and root convention dirs as mutually exclusive, by design), but the investigation surfaced a real, separate gap -- this repo ran zero apm-native audit/check commands in CI, relying entirely on custom scripts and Claude Code's own client-side validator. Add three pre-push hooks matching apm's documented producer CI pattern: - apm marketplace check: validates every marketplace.packages[] entry resolves, including live network reachability for remote refs -- a blind spot check-manifests.sh explicitly skips (local sources only). - apm audit --ci: apm's own lockfile/policy/hidden-content integrity gate. - apm pack --check-versions --check-clean: closes issue #90's deferred item 3 (a check-clean-equivalent gate) using apm's native flag instead of bespoke drift logic, verifying .claude-plugin/marketplace.json still matches what apm.yml + .apm/ would currently generate. All three are network-tolerant and whole-repo in scope, so they belong at pre-push alongside check-manifests/check-plugin-content-sync/ validate-plugins -- not pre-commit, which stays fast/offline/per-file. Documented the packing/bundling/releasing/CI findings in docs/research/docs/microsoft-apm/releasing.md (new) and extended testing-and-validation.md with the apm-action wrapper and its documented CI patterns, sourced from Context7 and cross-checked against the installed apm-cli 0.28.0 package directly. Refs: #90
8.7 KiB
topic, source_keys
| topic | source_keys | ||
|---|---|---|---|
| releasing |
|
apm pack full reference
apm pack # plugin format (default), directory under ./build/
apm pack --archive # plugin bundle as .zip (default archive format)
apm pack --archive --archive-format tar.gz # legacy CI pipelines that expect .tar.gz
apm pack --format apm -o ./dist # legacy APM bundle layout, custom output path
apm pack --dry-run -v # resolve and print per-entry detail; write nothing
apm pack --offline # marketplace resolution: cached refs only
apm pack --include-prerelease # marketplace resolution: allow pre-release tags
apm pack --marketplace=claude --json # JSON output for CI pipelines, one format only
apm pack --marketplace-path claude=dist/marketplace.json # override one format's output path
apm pack --legacy-skill-paths # bundle skills under per-client paths, not shared .agents/skills/
apm pack reads both apm.yml and apm.lock.yaml. What it produces depends on which blocks apm.yml declares: a dependencies: block alone produces a bundle; a marketplace: block alone produces marketplace artifacts (.claude-plugin/marketplace.json etc.); both present produces both. A package with neither block (skills/agents authored directly, no dependency resolution, not marketplace-listed) has nothing for apm pack to do beyond the plugin.json/bundle synthesis.
--format plugin bundle structure
build/<name>-<version>/
├── plugin.json # synthesized, schema-conformant per json.schemastore.org/claude-code-plugin.json
├── apm.lock.yaml # enriched copy embedding a per-file bundle_files manifest (integrity check for `apm install <bundle>`)
├── agents/
├── skills/
├── commands/
└── hooks.json # merged from .apm/hooks/*.json, single file (not a hooks/ directory)
This is the default (--format plugin, no flags) and the one most CI/release workflows use. --format apm produces the older, distinct "legacy APM bundle layout" instead — different consumers, not a superset/subset of each other.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Build or runtime error (e.g. network failure, missing reference) |
| 2 | Manifest schema validation error |
| 3 | --check-versions: per-package versions don't align with the configured marketplace.versioning.strategy |
| 4 | --check-clean: working tree is dirty — on-disk marketplace output doesn't match what a fresh pack would produce |
These four are deliberately structured as release gates that block, not auto-fix — none of them rewrite files to make the check pass; a nonzero exit means stop the release and investigate, not "run again to self-heal."
--check-clean and --check-versions scope
Both are scoped to marketplace outputs specifically (whatever marketplace.claude/marketplace.codex in apm.yml configures — i.e. .claude-plugin/marketplace.json and equivalents), not to the bundle's plugin content:
--check-cleanregenerates every configured marketplace output to a temp representation (including any--marketplace-pathoverride) and diffs it against what's actually on disk at the effective path. Any difference is exit 4.--check-versionsverifies that every marketplace-listed package's version agrees with the configuredmarketplace.versioning.strategy(lockstep|tag_pattern|per_package— seemonorepo-and-repo-shapes.md).
Neither one inspects a plugin's agents//skills//hooks.json directories at all — there is no apm-native "does the packed bundle match what's committed at the plugin root" check. That gap has to be closed by project-specific tooling if a repo commits compiled bundle content back into its plugin directories (see this repo's scripts/sync-plugin-content.sh and its --check mode, and docs/adr/0017-*.md for why that gap exists and isn't an apm oversight to wait out).
The canonical release sequence
Documented as: apm pack (with release gates) → checksum the artifacts → create the GitHub release. A manual version, for repos that need custom packaging steps beyond what apm-action covers:
- run: pip install apm-cli
- run: |
apm pack --check-versions --check-clean --json > pack-report.json
for f in build/*.zip .claude-plugin/marketplace.json; do
[ -f "$f" ] || continue
sha256sum "$f" > "${f}.sha256"
done
gh release create "${GITHUB_REF_NAME}" \
build/*.zip build/*.zip.sha256 \
.claude-plugin/marketplace.json* \
--title "${GITHUB_REF_NAME}" --notes-file CHANGELOG.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--json on the combined pack --check-versions --check-clean invocation gives one machine-readable summary of both gates for the release job to inspect/log, rather than parsing prose output.
The equivalent using the official wrapper, for repos that don't need custom packaging:
on:
push:
tags: ["v*"]
jobs:
release:
permissions: { contents: write }
steps:
- uses: actions/checkout@v5
- uses: microsoft/apm-action@v1
with: { mode: release }
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
mode: release is a third distinct mode on the same action used for install/audit (see testing-and-validation.md) — it runs pack + the release gates + GitHub release creation internally, so a repo only needs this when it doesn't need the manual workflow's custom steps (checksums, custom release notes handling, non-GitHub-release publishing target).
apm publish — registry publish is a different artifact than apm pack's bundle
apm publish --package acme/my-skill --dry-run -v # always dry-run first -- not trivially reversible
apm publish --package acme/my-skill
apm publish --package acme/my-skill --registry corp-main # pick a registry when multiple are configured
apm publish --package acme/my-skill --zip ./build/my-package-1.0.0.zip # publish a pre-built zip, skip auto-pack
By default apm publish auto-packs a flat registry archive — apm.yml, the .apm/ directory, and standard documentation files — and uploads that. This is explicitly a different layout than the plugin bundle apm pack --format plugin produces (no synthesized plugin.json, no agents//skills/ convention-directory flattening, symlinks excluded). Registry consumers install via apm install <package>, which resolves and deploys from this registry archive shape, not the plugin bundle shape — the two pack paths exist for two different consumers (a plugin host vs. an apm-aware installer) and aren't interchangeable.
Constraints: apm.yml must have name and version set. Publishing the same version twice returns a 409 Conflict — versions are immutable once published; fix by bumping the version, not by re-publishing over it.
Registries
A project declares registries in its manifest:
registries:
internal:
url: https://artifactory.example.com/artifactory/api/skills/internal
aliases:
- mirror.example.com
default: internal
Registries are the "package-level hosting at scale" tier (see marketplace-and-registries.md's "Which mechanism to use" section) — heavier infrastructure than a git-based marketplace, worth it once package count or access-control needs outgrow git-based discovery.
Gotchas
apm pack's bundle (--format plugin, written under./build/by default) is a distribution artifact forapm install <bundle>consumers — it is not read by Claude Code's own installer, which clones/scans a plugin's git working directory directly viamarketplace.json'ssource:path (seecompile.md's note on this same point). Nothing inapm pack,--check-clean, orapm audit --cireconciles the bundle against a plugin's committed working-tree content — that reconciliation, if a repo needs Claude Code to discover.apm/-authored content without waiting on the bundle, is necessarily project-specific tooling, not something to look for as a missing/misused apm flag.--check-clean's "regenerate and diff" model only applies to marketplace-format outputs. A repo relying on committed plugin-bundle content (flatagents//skills//hooks.jsonat a plugin root, generated from.apm/) needs its own drift check for that content —--check-cleanwill not catch it going stale.apm publishandapm packare easy to conflate since both start from the sameapm.yml/.apm/source, but they produce structurally different artifacts for different consumers (registry install vs. plugin host install) — verifying one does not verify the other.