From bfe8699877702ec106284d2b0b02effc3b98bc2e Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Sun, 10 May 2026 20:44:26 +0100 Subject: [PATCH 1/3] chore: init branch for fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 60142b27d9cf03b1f293bfcebad05970a05af955 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Sun, 10 May 2026 20:51:34 +0100 Subject: [PATCH 2/3] fix: warn when apm.yml is missing but APM artifacts exist (#1255) Closes #1056 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + src/apm_cli/policy/ci_checks.py | 18 +++++++++++++- src/apm_cli/policy/models.py | 1 + tests/unit/policy/test_ci_checks.py | 37 +++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc695ab4..04adbe656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm install --frozen` performs a CI-safe, read-only install that fails fast (exit 1) when `apm.lock.yaml` is missing or out of sync with `apm.yml`; mutually exclusive with `--update`. Structural presence check only; use `apm audit` for on-disk SHA integrity. (#1244) - `apm install` now emits a one-line "Run 'apm update' to check for newer versions." hint on the no-op path when a lockfile is already present, pointing users at the verb that actually checks for newer refs. (#1244) - Virtual subdirectory and raw-file packages now resolve from self-hosted Git services (Gitea, Gogs) via raw URL with API v1/v3 fallback. (#587) +- `apm audit --ci` now emits a `manifest-missing` warning when `apm.yml` is absent but APM artifacts (`.apm/` or `apm.lock.yaml`) are found, preventing silent bypass of all baseline checks. (#1255) - `shared/apm.md` gh-aw shared workflow exposes a `target:` import input (default `all`) so consumer workflows can ship slim, single-harness bundles instead of always packing every layout. (#1184) - **GitLab host support:** `gitlab.com` and self-managed instances (via `GITLAB_HOST` / `APM_GITLAB_HOSTS`) use GitLab REST **v4** for `marketplace.json` and install-time raw file reads; nested GitLab group paths are disambiguated in dependency references with object-form `git:` + `path:` where shorthand is ambiguous. GitHub, GHES, Azure DevOps, and registry-proxy behavior remain unchanged. (#1149) - **`git: parent` monorepo transitive dependency inheritance:** packages in a git monorepo can reference sibling paths via `{ git: parent, path: ... }` without repeating the full `git:` URL; the lockfile stores expanded host, repository, subdirectory path, and resolved ref/commit like other virtual git dependencies (no `parent` sentinel as durable identity). (#1149) diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index 7b2ab1222..a0f8b5e1c 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -545,8 +545,24 @@ def run_baseline_checks( lockfile_path = get_lockfile_path(project_root) # If there's no apm.yml or no lockfile, the first check already passed - # (no deps needed). Skip remaining checks. + # (no deps needed). Skip remaining checks -- but warn if APM artifacts + # exist without a manifest (evidence of a deleted apm.yml). if not apm_yml_path.exists() or not lockfile_path.exists(): + if not apm_yml_path.exists(): + apm_dir = project_root / ".apm" + lock_file = project_root / "apm.lock.yaml" + if apm_dir.exists() or lock_file.exists(): + result.checks.append( + CheckResult( + name="manifest-missing", + passed=True, + message=( + "apm.yml is missing but APM artifacts" + " (.apm/ or apm.lock.yaml) were found" + " -- this may indicate a deleted manifest" + ), + ) + ) return result lock = LockFile.read(lockfile_path) diff --git a/src/apm_cli/policy/models.py b/src/apm_cli/policy/models.py index 5ee31aae4..a0d6cc880 100644 --- a/src/apm_cli/policy/models.py +++ b/src/apm_cli/policy/models.py @@ -34,6 +34,7 @@ "scripts-policy": "apm.yml", "unmanaged-files": "apm.yml", "manifest-parse": "apm.yml", + "manifest-missing": "apm.yml", } diff --git a/tests/unit/policy/test_ci_checks.py b/tests/unit/policy/test_ci_checks.py index 0dacfaede..62637192d 100644 --- a/tests/unit/policy/test_ci_checks.py +++ b/tests/unit/policy/test_ci_checks.py @@ -1119,3 +1119,40 @@ def test_remediation_hint_present_in_error_message(self, tmp_path: Path) -> None assert parse_check.name == "manifest-parse" assert "Cannot parse apm.yml" in parse_check.message assert "fix the YAML syntax error in apm.yml and re-run" in parse_check.message + + +class TestManifestMissingWarning: + """Tests for the manifest-missing warning when apm.yml is absent.""" + + def test_no_artifacts_no_warning(self, tmp_path: Path) -> None: + """Clean non-APM project: no apm.yml, no .apm/, no lockfile -> no warning.""" + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" not in names + assert result.passed + + def test_apm_dir_triggers_warning(self, tmp_path: Path) -> None: + """apm.yml absent but .apm/ dir exists -> manifest-missing warning.""" + (tmp_path / ".apm").mkdir() + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" in names + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is True + assert ".apm/" in check.message or "apm.lock.yaml" in check.message + + def test_lockfile_triggers_warning(self, tmp_path: Path) -> None: + """apm.yml absent but apm.lock.yaml exists -> manifest-missing warning.""" + (tmp_path / "apm.lock.yaml").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" in names + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is True + + def test_manifest_present_no_warning(self, tmp_path: Path) -> None: + """apm.yml present -> no manifest-missing check at all.""" + _write_apm_yml(tmp_path) + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" not in names From 8be444f4f2bbb133d744a2f95d329e5bf6f27a6d Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Mon, 11 May 2026 03:12:28 +0100 Subject: [PATCH 3/3] fix(audit): manifest-missing check fails in CI mode, use lockfile constants - Make manifest-missing check passed=False in ci_mode, passed=True otherwise - Import LOCKFILE_NAME / LEGACY_LOCKFILE_NAME from deps.lockfile - Check legacy apm.lock alongside apm.lock.yaml - Use .is_dir() for .apm/ directory check - Move CHANGELOG entry from Added to Fixed - Add CI-mode and legacy-lockfile test coverage Closes #1056 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- src/apm_cli/commands/audit.py | 2 +- src/apm_cli/policy/ci_checks.py | 14 ++++++---- tests/unit/policy/test_ci_checks.py | 40 +++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04adbe656..0a32d7be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm install --frozen` performs a CI-safe, read-only install that fails fast (exit 1) when `apm.lock.yaml` is missing or out of sync with `apm.yml`; mutually exclusive with `--update`. Structural presence check only; use `apm audit` for on-disk SHA integrity. (#1244) - `apm install` now emits a one-line "Run 'apm update' to check for newer versions." hint on the no-op path when a lockfile is already present, pointing users at the verb that actually checks for newer refs. (#1244) - Virtual subdirectory and raw-file packages now resolve from self-hosted Git services (Gitea, Gogs) via raw URL with API v1/v3 fallback. (#587) -- `apm audit --ci` now emits a `manifest-missing` warning when `apm.yml` is absent but APM artifacts (`.apm/` or `apm.lock.yaml`) are found, preventing silent bypass of all baseline checks. (#1255) - `shared/apm.md` gh-aw shared workflow exposes a `target:` import input (default `all`) so consumer workflows can ship slim, single-harness bundles instead of always packing every layout. (#1184) - **GitLab host support:** `gitlab.com` and self-managed instances (via `GITLAB_HOST` / `APM_GITLAB_HOSTS`) use GitLab REST **v4** for `marketplace.json` and install-time raw file reads; nested GitLab group paths are disambiguated in dependency references with object-form `git:` + `path:` where shorthand is ambiguous. GitHub, GHES, Azure DevOps, and registry-proxy behavior remain unchanged. (#1149) - **`git: parent` monorepo transitive dependency inheritance:** packages in a git monorepo can reference sibling paths via `{ git: parent, path: ... }` without repeating the full `git:` URL; the lockfile stores expanded host, repository, subdirectory path, and resolved ref/commit like other virtual git dependencies (no `parent` sentinel as durable identity). (#1149) @@ -38,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `apm audit --ci` now fails with a `manifest-missing` check when `apm.yml` is absent but APM artifacts (`.apm/`, `apm.lock.yaml`, or `apm.lock`) are found, preventing silent bypass of all baseline checks; interactive `apm audit` still warns without failing. (#1056) - `apm install` no longer silently overwrites pre-existing governance files; `check_collision()` now treats `managed_files=None` (first install, no lockfile yet) as an empty set so hand-rolled files in `.github/instructions/` and other governance directories are correctly detected and protected from silent overwrite. (#1256) - `test_find_server_by_reference_uuid_not_found` no longer leaks a live HTTP call to `api.mcp.github.com` (fixing Windows CI failures from `socket.gaierror`); the `search_servers` fallback is now mocked. (#1264) - ADO full HTTPS URLs with sub-path virtual packages (e.g. `https://dev.azure.com/org/proj/_git/repo/sub/path`) are now parsed correctly instead of being rejected. (#1254) diff --git a/src/apm_cli/commands/audit.py b/src/apm_cli/commands/audit.py index c701f1826..053a21e61 100644 --- a/src/apm_cli/commands/audit.py +++ b/src/apm_cli/commands/audit.py @@ -436,7 +436,7 @@ def _audit_ci_gate( fail_fast = not no_fail_fast # Always run baseline checks - ci_result = run_baseline_checks(cfg.project_root, fail_fast=fail_fast) + ci_result = run_baseline_checks(cfg.project_root, fail_fast=fail_fast, ci_mode=True) # Resolve policy source: explicit --policy wins; otherwise mirror # install's auto-discovery (closes #827) so CI catches sideloaded diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index a0f8b5e1c..d71f69a53 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Sequence # noqa: F401, UP035 -from ..deps.lockfile import _SELF_KEY +from ..deps.lockfile import _SELF_KEY, LEGACY_LOCKFILE_NAME, LOCKFILE_NAME from .models import CheckResult, CIAuditResult if TYPE_CHECKING: @@ -503,11 +503,14 @@ def run_baseline_checks( project_root: Path, *, fail_fast: bool = True, + ci_mode: bool = False, ) -> CIAuditResult: """Run all baseline CI checks against a project directory. When *fail_fast* is ``True`` (default), stops after the first failing check to skip expensive I/O (e.g. content integrity scan). + When *ci_mode* is ``True``, the ``manifest-missing`` check is a hard + failure (``passed=False``); otherwise it is an advisory warning only. Returns :class:`CIAuditResult` with individual check results. """ from ..deps.lockfile import LockFile, get_lockfile_path @@ -550,15 +553,16 @@ def run_baseline_checks( if not apm_yml_path.exists() or not lockfile_path.exists(): if not apm_yml_path.exists(): apm_dir = project_root / ".apm" - lock_file = project_root / "apm.lock.yaml" - if apm_dir.exists() or lock_file.exists(): + lock_file = project_root / LOCKFILE_NAME + legacy_lock_file = project_root / LEGACY_LOCKFILE_NAME + if apm_dir.is_dir() or lock_file.exists() or legacy_lock_file.exists(): result.checks.append( CheckResult( name="manifest-missing", - passed=True, + passed=not ci_mode, message=( "apm.yml is missing but APM artifacts" - " (.apm/ or apm.lock.yaml) were found" + " (.apm/ or apm.lock.yaml or apm.lock) were found" " -- this may indicate a deleted manifest" ), ) diff --git a/tests/unit/policy/test_ci_checks.py b/tests/unit/policy/test_ci_checks.py index 62637192d..4e885f950 100644 --- a/tests/unit/policy/test_ci_checks.py +++ b/tests/unit/policy/test_ci_checks.py @@ -1156,3 +1156,43 @@ def test_manifest_present_no_warning(self, tmp_path: Path) -> None: result = run_baseline_checks(tmp_path) names = [c.name for c in result.checks] assert "manifest-missing" not in names + + def test_apm_dir_ci_mode_fails(self, tmp_path: Path) -> None: + """In CI mode, .apm/ without apm.yml fails the check.""" + (tmp_path / ".apm").mkdir() + result = run_baseline_checks(tmp_path, ci_mode=True) + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is False + assert not result.passed + + def test_lockfile_ci_mode_fails(self, tmp_path: Path) -> None: + """In CI mode, apm.lock.yaml without apm.yml fails the check.""" + (tmp_path / "apm.lock.yaml").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path, ci_mode=True) + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is False + assert not result.passed + + def test_legacy_lockfile_triggers_warning(self, tmp_path: Path) -> None: + """apm.yml absent but legacy apm.lock exists -> manifest-missing warning.""" + (tmp_path / "apm.lock").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" in names + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is True + + def test_legacy_lockfile_ci_mode_fails(self, tmp_path: Path) -> None: + """In CI mode, legacy apm.lock without apm.yml fails the check.""" + (tmp_path / "apm.lock").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path, ci_mode=True) + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is False + assert not result.passed + + def test_no_artifacts_ci_mode_still_passes(self, tmp_path: Path) -> None: + """Clean project with no APM artifacts passes even in CI mode.""" + result = run_baseline_checks(tmp_path, ci_mode=True) + names = [c.name for c in result.checks] + assert "manifest-missing" not in names + assert result.passed