Skip to content

Commit e4438b9

Browse files
nadav-yclaude
andcommitted
feat(publish): include README.md, CHANGELOG.md, and LICENSE in auto-pack archive
Matches npm behaviour of bundling standard root-level documentation files when present. Matched case-insensitively; LICENSE has no required extension. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9519d6d commit e4438b9

2 files changed

Lines changed: 61 additions & 0 deletions

File tree

src/apm_cli/commands/publish.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ def _resolve_registry_name(name: str | None, registries: dict[str, str]) -> str:
186186
def _pack_archive(project_root: Path, apm_yml_path: Path, pkg, logger, verbose: bool) -> Path:
187187
"""Build a flat registry tarball (``apm.yml`` + ``.apm/`` at archive root).
188188
189+
Also includes ``README.md``, ``CHANGELOG.md``, and ``LICENSE`` (case-
190+
insensitive, no extension required for LICENSE) when present — matching
191+
npm's behaviour of bundling standard root-level documentation files.
192+
189193
Registry servers and ``apm install`` expect the APM source layout at the
190194
tarball root — not the ``apm pack --archive`` plugin bundle wrapper
191195
(``{name}-{version}/plugin.json``). See registry HTTP API §6.
@@ -214,9 +218,22 @@ def _tar_filter(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
214218
return None
215219
return ti
216220

221+
# Standard root-level doc files included when present (npm parity).
222+
# Matched case-insensitively; LICENSE has no required extension.
223+
_DOC_CANDIDATES = ("README.md", "CHANGELOG.md", "LICENSE", "LICENCE")
224+
217225
with tarfile.open(dest, mode="w:gz") as tar:
218226
tar.add(apm_yml_path, arcname="apm.yml", filter=_tar_filter, recursive=False)
219227
tar.add(apm_dir, arcname=".apm", filter=_tar_filter)
228+
for candidate in _DOC_CANDIDATES:
229+
# Case-insensitive match against actual filenames in project root.
230+
match = next(
231+
(f for f in project_root.iterdir()
232+
if f.is_file() and f.name.lower() == candidate.lower()),
233+
None,
234+
)
235+
if match:
236+
tar.add(match, arcname=match.name, filter=_tar_filter, recursive=False)
220237

221238
return dest
222239

tests/unit/commands/test_publish_registry_archive.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,50 @@ def test_skips_appledouble_sidecars(self, tmp_path: Path) -> None:
5656
members = _list_tar_members(tarball.read_bytes())
5757
assert not any("._" in m for m in members)
5858

59+
def test_includes_readme_when_present(self, tmp_path: Path) -> None:
60+
(tmp_path / "apm.yml").write_text(
61+
"name: demo\nversion: 1.0.0\ndescription: x\nauthor: a\n",
62+
encoding="utf-8",
63+
)
64+
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
65+
(tmp_path / ".apm" / "skills" / "demo").mkdir(parents=True)
66+
(tmp_path / ".apm" / "skills" / "demo" / "SKILL.md").write_text("# s\n")
67+
68+
pkg = APMPackage(name="demo", version="1.0.0")
69+
tarball = _pack_archive(tmp_path, tmp_path / "apm.yml", pkg, MagicMock(), verbose=False)
70+
assert "README.md" in _list_tar_members(tarball.read_bytes())
71+
72+
def test_includes_changelog_and_license_when_present(self, tmp_path: Path) -> None:
73+
(tmp_path / "apm.yml").write_text(
74+
"name: demo\nversion: 1.0.0\ndescription: x\nauthor: a\n",
75+
encoding="utf-8",
76+
)
77+
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8")
78+
(tmp_path / "LICENSE").write_text("MIT\n", encoding="utf-8")
79+
(tmp_path / ".apm" / "skills" / "demo").mkdir(parents=True)
80+
(tmp_path / ".apm" / "skills" / "demo" / "SKILL.md").write_text("# s\n")
81+
82+
pkg = APMPackage(name="demo", version="1.0.0")
83+
tarball = _pack_archive(tmp_path, tmp_path / "apm.yml", pkg, MagicMock(), verbose=False)
84+
members = _list_tar_members(tarball.read_bytes())
85+
assert "CHANGELOG.md" in members
86+
assert "LICENSE" in members
87+
88+
def test_doc_files_absent_are_skipped(self, tmp_path: Path) -> None:
89+
(tmp_path / "apm.yml").write_text(
90+
"name: demo\nversion: 1.0.0\ndescription: x\nauthor: a\n",
91+
encoding="utf-8",
92+
)
93+
(tmp_path / ".apm" / "skills" / "demo").mkdir(parents=True)
94+
(tmp_path / ".apm" / "skills" / "demo" / "SKILL.md").write_text("# s\n")
95+
96+
pkg = APMPackage(name="demo", version="1.0.0")
97+
tarball = _pack_archive(tmp_path, tmp_path / "apm.yml", pkg, MagicMock(), verbose=False)
98+
members = _list_tar_members(tarball.read_bytes())
99+
assert "README.md" not in members
100+
assert "CHANGELOG.md" not in members
101+
assert "LICENSE" not in members
102+
59103
def test_missing_dot_apm_rejected(self, tmp_path: Path) -> None:
60104
(tmp_path / "apm.yml").write_text(
61105
"name: demo\nversion: 1.0.0\ndescription: x\nauthor: a\n",

0 commit comments

Comments
 (0)