chore(notices): automate NOTICE.md generation + CI drift gate#1045
chore(notices): automate NOTICE.md generation + CI drift gate#1045danielmeppiel merged 2 commits intomainfrom
Conversation
Adds an automated NOTICE.md maintenance pipeline so the file stays in
sync with the install graph as deps evolve, plus a license-policy gate
on PRs.
Pieces:
1. scripts/generate-notice.py -- renders NOTICE.md from
[project] dependencies in pyproject.toml + a curated
scripts/notice-metadata.yaml of per-component overrides
(SPDX, upstream URL, copyright snippet, optional NOTICE/AUTHORS
attribution). Reads the verbatim license text from the installed
.dist-info/licenses/ directory in the uv-managed env so it tracks
whatever uv.lock resolves to. Default mode regenerates; --check
diffs against the committed file and exits 1 with a unified diff
if they differ.
2. .github/workflows/notice-drift.yml -- pull_request + merge_group
workflow that runs the generator in --check mode and surfaces the
diff in the Actions log if NOTICE.md is stale. Adds
actions/dependency-review-action@v4 on PR-time only as a
license-policy gate (denies GPL/AGPL/SSPL additions; allows
permissive + MPL-2.0 explicitly).
3. .github/workflows/merge-gate.yml -- adds 'NOTICE Drift Check'
to EXPECTED_CHECKS in both the pull_request and merge_group
branches so the new check participates in the unified merge gate.
4. Makefile target 'notice' for local DX
(`make notice` regenerates the file).
Why no SBOM emission: GitHub's dependency graph already produces an
SPDX SBOM for this repo (REST: GET /repos/{owner}/{repo}/dependency-graph/sbom)
and Dependabot owns vulnerability + version updates over the same graph.
A second SBOM generator here would duplicate that surface without value.
If a CycloneDX SBOM is later required for compliance reporting, add a
separate workflow keyed off release events.
Closes #1044
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
9184e0d to
8495b2c
Compare
The action rejects specifying both `allow-licenses` and `deny-licenses`
('You cannot specify both'). Drop the allow-list -- the deny-list
already enforces the policy intent (block strong-copyleft families)
and avoids noisy false positives on novel-but-permissive SPDX
identifiers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an automated pipeline for keeping NOTICE.md (third-party attributions) in sync with the repo’s direct runtime dependencies, and adds CI gating (plus SBOM emission) so drift is caught on every PR and merge-queue entry.
Changes:
- Add a NOTICE generator script driven by
pyproject.tomldeps + curatedscripts/notice-metadata.yaml, with--checkmode and CycloneDX SBOM emission. - Add a new required CI workflow (
NOTICE Drift Check) and wire it intomerge-gate.ymlaggregation. - Add local developer shortcuts via
make notice/make notice-check.
Show a summary per file
| File | Description |
|---|---|
scripts/notice-metadata.yaml |
Adds curated metadata/overrides per dependency to render deterministic NOTICE sections. |
scripts/generate-notice.py |
Implements NOTICE rendering, license text extraction from dist-info, and SBOM emission. |
Makefile |
Adds DX targets to run the generator locally. |
.github/workflows/notice-drift.yml |
Adds PR + merge-queue gate to enforce NOTICE drift checks and license policy checks, plus SBOM artifact. |
.github/workflows/merge-gate.yml |
Adds NOTICE Drift Check to the set of checks aggregated by the PR-time gate. |
Copilot's findings
Comments suppressed due to low confidence (1)
scripts/generate-notice.py:63
tempfileis imported but not used (the code renders to memory). Please drop the unused import (and any related code/comments) to keep the script tidy.
import difflib
import importlib.metadata as ilmd
import re
- Files reviewed: 5/5 changed files
- Comments generated: 4
| on_disk = NOTICE_OUT.read_text(encoding="utf-8") if NOTICE_OUT.exists() else "" | ||
| if on_disk == rendered: | ||
| return 0 | ||
| diff = difflib.unified_diff( | ||
| on_disk.splitlines(keepends=True), | ||
| rendered.splitlines(keepends=True), | ||
| fromfile=str(NOTICE_OUT.relative_to(REPO_ROOT)) + " (on disk)", |
There was a problem hiding this comment.
In the PackageNotFoundError path, version = dep.raw_specifier.lstrip(...) can produce values like "1.2.0; python_version<'3.11'" (tomli under Python 3.12 in CI), which includes spaces/markers and is not a real version. This can yield an invalid/misleading PURL and potentially break SBOM consumers. Consider omitting the version (pass None), or properly parse the requirement (split on ; and extract a concrete specifier) when the dist isn't installed.
| if: github.event_name == 'pull_request' | ||
| uses: actions/dependency-review-action@v4 | ||
| with: | ||
| deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0, SSPL-1.0 |
There was a problem hiding this comment.
The dependency-review-action config appears to specify both a deny-list and an allow-list of licenses (allow-licenses is present later in this same with: block). The action errors if both are set, so the workflow will fail at runtime; keep only one of these inputs.
|
|
||
|
|
||
| def _is_license_file(rel_path: str) -> bool: | ||
| fname = rel_path.rsplit("/", 1)[-1] |
There was a problem hiding this comment.
_is_license_file() splits paths using only '/' (rel_path.rsplit("/", 1)), but importlib.metadata file paths can contain backslashes on Windows. That will cause license discovery to fail on Windows even when the license file exists. Normalize separators (e.g., replace \\ with / before splitting) or use PurePosixPath/PurePath for robust filename extraction.
| fname = rel_path.rsplit("/", 1)[-1] | |
| """Return True when ``rel_path`` points to a plausible license file. | |
| ``importlib.metadata`` file paths may contain either forward slashes or | |
| backslashes depending on platform and representation. Normalize to ``/`` | |
| before extracting the filename so license discovery works consistently on | |
| Windows and POSIX. | |
| """ | |
| normalized_path = rel_path.replace("\\", "/") | |
| fname = normalized_path.rsplit("/", 1)[-1] |
| Security > Software bill of materials, or via the REST API at | ||
| GET /repos/{owner}/{repo}/dependency-graph/sbom). Adding a second SBOM | ||
| generator here would duplicate that surface without adding value. | ||
|
|
There was a problem hiding this comment.
The docstring says --check regenerates to a tempfile, but the implementation compares an in-memory rendered string against NOTICE.md on disk. Please update the docstring to match reality (or actually use a tempfile) so future maintainers don't chase a non-existent behavior.
This issue also appears on line 61 of the same file.
Promotes [Unreleased] -> [0.11.0] - 2026-04-29 and bumps pyproject.toml + uv.lock to 0.11.0. Version-bump rationale: 0.11.0 (minor bump) chosen over 0.10.1 because this release ships one BREAKING removal (`apm marketplace build` -> exits 2, use `apm pack`) plus several net-new features (Dev Container Feature, Codex project-scoped MCP, `marketplace:` block in apm.yml, `apm pack` unification, multi-org `apps[]`). Strict semver in 0.x: minor for features-with-break, patch only for bugfixes. Milestone admin (done out-of-band): - Renamed milestone #8 `0.10.1` -> `0.11.0` - Created milestone #9 `0.12.0` as next-up bucket - Moved 43 open items (42 issues + 1 open PR #999) from `0.11.0` -> `0.12.0` - 6 closed items stay in `0.11.0` PRs shipping in 0.11.0 (22 commits since v0.10.0): User-facing features: - #1042/#722 `apm pack` unifies bundle + marketplace.json (BREAKING: `apm marketplace build` removed) - #1038 `marketplace:` block in apm.yml + `apm marketplace migrate` - #803 /#502 Codex project-scoped MCP (`.codex/config.toml`) + user-scope primitives - #861 Dev Container Feature `ghcr.io/microsoft/apm/apm-cli` - #982/#984 shared/apm.md `apps:` array for cross-org private packages - #820 `target:` in apm.yml validates at parse time - #1032 `apm marketplace add` honors manifest.name (Claude Code parity) - #1000/#998/#994 unified `--policy` / `--policy-source` accepted forms User-facing fixes: - #1015 ADO Entra ID auth + `apm install --update` pre-flight abort - #1019/#1020 GEMINI.md only created when target requested - #1008 marketplace producer respects GITHUB_HOST + multi-host URL forms - #1018 POSIX paths in auto-discovery output (Windows compat) - #996 drop stray 'specify' from generated file footer Maintainer tooling: - #1043 NOTICE.md per CELA template - #1045/#1044 NOTICE drift gate + license-policy gate in CI - #1033 shared/apm.md `[a b]` import-input repair (gh-aw#29076 paper-cut) - #1030 panel workflows skip-don't-fail on unmatched labels; gh-aw v0.71.1 - #1026 shared/apm.md recompiled to apm-action v1.5.0 + bundles-file - #1022 review-panel: true fan-out + binary verdict + label automation - #918 complexity audit + benchmarks suite - #1002 CodeQL clear-text-storage false-positive resolved (token -> placeholder) Files changed: - pyproject.toml: 0.10.0 -> 0.11.0 - uv.lock: regenerated (version field only) - CHANGELOG.md: [Unreleased] promoted to [0.11.0] - 2026-04-29 NOTICE drift check passes against the bumped lockfile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Promotes [Unreleased] -> [0.11.0] - 2026-04-29 and bumps pyproject.toml + uv.lock to 0.11.0. Version-bump rationale: 0.11.0 (minor bump) chosen over 0.10.1 because this release ships one BREAKING removal (`apm marketplace build` -> exits 2, use `apm pack`) plus several net-new features (Dev Container Feature, Codex project-scoped MCP, `marketplace:` block in apm.yml, `apm pack` unification, multi-org `apps[]`). Strict semver in 0.x: minor for features-with-break, patch only for bugfixes. Milestone admin (done out-of-band): - Renamed milestone #8 `0.10.1` -> `0.11.0` - Created milestone #9 `0.12.0` as next-up bucket - Moved 43 open items (42 issues + 1 open PR #999) from `0.11.0` -> `0.12.0` - 6 closed items stay in `0.11.0` PRs shipping in 0.11.0 (22 commits since v0.10.0): User-facing features: - #1042/#722 `apm pack` unifies bundle + marketplace.json (BREAKING: `apm marketplace build` removed) - #1038 `marketplace:` block in apm.yml + `apm marketplace migrate` - #803 /#502 Codex project-scoped MCP (`.codex/config.toml`) + user-scope primitives - #861 Dev Container Feature `ghcr.io/microsoft/apm/apm-cli` - #982/#984 shared/apm.md `apps:` array for cross-org private packages - #820 `target:` in apm.yml validates at parse time - #1032 `apm marketplace add` honors manifest.name (Claude Code parity) - #1000/#998/#994 unified `--policy` / `--policy-source` accepted forms User-facing fixes: - #1015 ADO Entra ID auth + `apm install --update` pre-flight abort - #1019/#1020 GEMINI.md only created when target requested - #1008 marketplace producer respects GITHUB_HOST + multi-host URL forms - #1018 POSIX paths in auto-discovery output (Windows compat) - #996 drop stray 'specify' from generated file footer Maintainer tooling: - #1043 NOTICE.md per CELA template - #1045/#1044 NOTICE drift gate + license-policy gate in CI - #1033 shared/apm.md `[a b]` import-input repair (gh-aw#29076 paper-cut) - #1030 panel workflows skip-don't-fail on unmatched labels; gh-aw v0.71.1 - #1026 shared/apm.md recompiled to apm-action v1.5.0 + bundles-file - #1022 review-panel: true fan-out + binary verdict + label automation - #918 complexity audit + benchmarks suite - #1002 CodeQL clear-text-storage false-positive resolved (token -> placeholder) Files changed: - pyproject.toml: 0.10.0 -> 0.11.0 - uv.lock: regenerated (version field only) - CHANGELOG.md: [Unreleased] promoted to [0.11.0] - 2026-04-29 NOTICE drift check passes against the bumped lockfile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(release): cut 0.11.0 Promotes [Unreleased] -> [0.11.0] - 2026-04-29 and bumps pyproject.toml + uv.lock to 0.11.0. Version-bump rationale: 0.11.0 (minor bump) chosen over 0.10.1 because this release ships one BREAKING removal (`apm marketplace build` -> exits 2, use `apm pack`) plus several net-new features (Dev Container Feature, Codex project-scoped MCP, `marketplace:` block in apm.yml, `apm pack` unification, multi-org `apps[]`). Strict semver in 0.x: minor for features-with-break, patch only for bugfixes. Milestone admin (done out-of-band): - Renamed milestone #8 `0.10.1` -> `0.11.0` - Created milestone #9 `0.12.0` as next-up bucket - Moved 43 open items (42 issues + 1 open PR #999) from `0.11.0` -> `0.12.0` - 6 closed items stay in `0.11.0` PRs shipping in 0.11.0 (22 commits since v0.10.0): User-facing features: - #1042/#722 `apm pack` unifies bundle + marketplace.json (BREAKING: `apm marketplace build` removed) - #1038 `marketplace:` block in apm.yml + `apm marketplace migrate` - #803 /#502 Codex project-scoped MCP (`.codex/config.toml`) + user-scope primitives - #861 Dev Container Feature `ghcr.io/microsoft/apm/apm-cli` - #982/#984 shared/apm.md `apps:` array for cross-org private packages - #820 `target:` in apm.yml validates at parse time - #1032 `apm marketplace add` honors manifest.name (Claude Code parity) - #1000/#998/#994 unified `--policy` / `--policy-source` accepted forms User-facing fixes: - #1015 ADO Entra ID auth + `apm install --update` pre-flight abort - #1019/#1020 GEMINI.md only created when target requested - #1008 marketplace producer respects GITHUB_HOST + multi-host URL forms - #1018 POSIX paths in auto-discovery output (Windows compat) - #996 drop stray 'specify' from generated file footer Maintainer tooling: - #1043 NOTICE.md per CELA template - #1045/#1044 NOTICE drift gate + license-policy gate in CI - #1033 shared/apm.md `[a b]` import-input repair (gh-aw#29076 paper-cut) - #1030 panel workflows skip-don't-fail on unmatched labels; gh-aw v0.71.1 - #1026 shared/apm.md recompiled to apm-action v1.5.0 + bundles-file - #1022 review-panel: true fan-out + binary verdict + label automation - #918 complexity audit + benchmarks suite - #1002 CodeQL clear-text-storage false-positive resolved (token -> placeholder) Files changed: - pyproject.toml: 0.10.0 -> 0.11.0 - uv.lock: regenerated (version field only) - CHANGELOG.md: [Unreleased] promoted to [0.11.0] - 2026-04-29 NOTICE drift check passes against the bumped lockfile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(changelog): tighten 0.11.0 entries to lead with user impact Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(changelog): move Dev Container Feature to Maintainer tooling (not yet published) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(changelog): de-dupe within 0.11.0 (combine #722 Removed bullets, drop #820 Fixed pointer) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Closes #1044.
Summary
Make
NOTICE.mdself-maintaining. After PR #1043 landed the file by hand, every future dep change would otherwise require a contributor to remember to update it. This PR makes the file generated and gates every PR on a drift check, with an additional license-policy gate as supply-chain hygiene.What changes
scripts/generate-notice.pypyproject.tomldeps + curated per-component metadata, reading verbatim license text from the installed.dist-info/licenses/dir of the uv-managed env.--checkmode for CI.scripts/notice-metadata.yaml.github/workflows/notice-drift.ymlpull_request+merge_grouptriggers (matchesci.ymlpattern).uv sync --frozenfor reproducibility.actions/dependency-review-action@v4on PR-time only as a license-policy gate..github/workflows/merge-gate.ymlNOTICE Drift ChecktoEXPECTED_CHECKSin both event branches so the unified gate waits for it.Makefilemake noticefor local regeneration before pushing.pyproject.tomlruamel.yamlis already a runtime dep, reused for the metadata YAML)Why no SBOM in this PR
GitHub's dependency graph already exposes an SPDX SBOM for this repo on demand (
GET /repos/{owner}/{repo}/dependency-graph/sbom), and Dependabot owns vulnerability + version updates over the same graph. Adding a second generator here would duplicate that surface without adding value. If a CycloneDX-format SBOM becomes required for compliance reporting, add a separate workflow keyed off release events.Lenses applied
GitHub Actions specialization — minimum permissions (
contents: read),uv sync --frozen(no silent lockfile mutation in CI),@majoraction pinning matching repo style, dualpull_request+merge_grouptrigger so the merge queue temp-branch gets the check (post-mortem on PR #899 inmerge-gate.ymlis the template), wired intomerge-gate.ymlEXPECTED_CHECKSso it's a real required check rather than an advisory one.Supply chain & licensing — the generator reads license text from the actual installed
.dist-info, so an upstream re-license caught by Dependabot triggers our drift check on the same PR (no separate manual review).dependency-review-actiondenies the strong-copyleft families (GPL / AGPL / SSPL) that would virally relicense the apm-cli binary; explicitly allows permissive + MPL-2.0 (file-level copyleft, no viral effect on combined work). Frozen syncs prevent CI from silently pulling a new LICENSE.Proof of working
1. Byte-parity with the manually-authored NOTICE.md
The generator was developed against a captured copy (
NOTICE.md.golden) of the file PR #1043 landed. Iterated untildiff -u NOTICE.md NOTICE.md.goldenproduced empty output, then deleted the golden. This commit'sNOTICE.mdis unchanged from the file that landed in PR #1043.2.
--checkmode behavior3.
actionlintclean4. Drift simulation -- adding a dep
If a contributor adds a new dep without regenerating NOTICE, the gate fails the PR with the exact diff in the Actions log, plus a fix command in the error message (
make noticeand commit). Themake noticetarget makes the local fix one command.Out of scope (deferred)
build-release.yml— separate, release-event-keyed workflow if/when needed.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com