You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/10/04 01:06:59 UTC
(magpie) branch main updated: feat(tools): add maven-artifact-verify and wire JVM artefact checks into release-verify-rc (#1415)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git
The following commit(s) were added to refs/heads/main by this push:
new 9c075618 feat(tools): add maven-artifact-verify and wire JVM artefact checks into release-verify-rc (#1415)
9c075618 is described below
commit 9c0756180a742be54340afeb3e5c773666a81c21
Author: kuse <31...@qq.com>
AuthorDate: Sun Oct 4 09:06:54 2026 +0800
feat(tools): add maven-artifact-verify and wire JVM artefact checks into release-verify-rc (#1415)
* feat(tools): add maven-artifact-verify and wire JVM artefact checks into release-verify-rc
The JVM blocking checks agreed on in #1173 (checks 1-3) had no local,
offline enforcement path: release-verify-rc Step 6 covered source
tarballs only, so a staged RC could ship POMs without an ALv2 licence,
a podling without its incubation disclaimer, or companion
-sources/-javadoc jars missing their .asc signatures and checksums.
Add tools/maven-artifact-verify, a stdlib-only Python adapter that
verifies a staged directory:
- Check 1: every .pom carries an ALv2 licence, <developers> and
<scm>. Elements absent from the POM are resolved against a locally
staged parent POM; when that cannot resolve they are reported as
INHERITED-UNVERIFIED (WARN), not FAIL.
- Check 2 (--podling): the incubation disclaimer inside
<description>, accepting both the standard text and the
DISCLAIMER-WIP variant from the Incubator distribution guide,
tolerant of whitespace and line-wrapping. Gated by a DISCLAIMER
file in the source tree, leaving the project_stage mechanism of
#1172 untouched.
- Check 3: each staged main jar has its -sources.jar and
-javadoc.jar companions, each companion signed (.asc) and
checksummed. packaging=pom is exempt, classified jars (-tests,
-shaded, ...) are never mistaken for companions, and a main jar
not staged locally (Nexus-only publication) is recorded as an
ABSENT observation rather than a failure.
Wire the tool in as release-verify-rc Step 6b: skipped cleanly when
no JVM artefacts are staged, keeping the existing step numbering and
every cross-reference to Steps 7-9 intact. release-build.md gains
the matching jvm_artefact_checks configuration surface, the
capability map, docs/release-management/spec.md and the spec-loop
spec are synced, and a step-6b-jvm-artefacts eval suite (4 cases)
covers the new skill behaviour.
Refs #1173
Generated-by: WorkBuddy (GLM-5.3-Flash)
* chore: regenerate uv.lock for the maven-artifact-verify workspace member
Adding tools/maven-artifact-verify to the workspace in the previous
commit changed the member set, so `uv run --locked` in CI (skill-token-
count and every other uv-based job) rejected the stale lockfile. Re-run
`uv lock` with uv 0.12.17: the diff is the new member entry only
(16 added lines, lock revision unchanged at 3, no version bumps).
Generated-by: WorkBuddy (GLM-5.3-Flash)
* chore: regenerate labeler config and vendor-neutrality doc for the new tool
The labeler config and the generated vendor-neutrality block are
derived from the repo tree; adding tools/maven-artifact-verify made
both stale (`test_committed_config_is_in_sync` and the three
vendor-neutrality in-sync tests fail in CI).
- .github/labeler.yml: `tools/maven-artifact-verify/**` joins the
`substrate:release` label (one line, from
tools/dev/generate-labeler-config.py).
- docs/vendor-neutrality.md: the agent-harness section counts 29
substrate tools and lists maven-artifact-verify as
harness-agnostic; skill sections are unchanged.
Generated-by: WorkBuddy (GLM-5.3-Flash)
* docs: sync marketplace token figure and eval-suite counts for the new tool
check-doc-sync caught two hand-maintained figures the Step 6b addition
made stale:
- docs/setup/marketplace.md: magpie-release-management publishes
~1.4k always-on tokens (verify-rc grew by the Step 6b section).
- tools/skill-evals/README.md: release-verify-rc is 22 cases across
8 suites with the new step-6b-jvm-artefacts suite.
Generated-by: WorkBuddy (GLM-5.3-Flash)
* fix(maven-artifact-verify): drop the loop-else that double-reported companions
CodeQL flagged two alerts on the new package; one was a real bug.
check_companions appended a PASS record in a loop `else` whose body
has no `break`, so the else ran unconditionally: a companion missing
its .asc (or checksum) was reported twice — once FAIL, once PASS.
Nothing downstream misclassified (status aggregation takes the FAIL),
but the JSON carried contradictory records for the same companion.
Collect the missing files first and branch on the result; a new
regression assertion pins one classification per companion.
The second alert was cosmetic: the POM fixture built one XML attribute
string by implicit concatenation inside a list literal; the parts are
now joined with explicit `+`.
Generated-by: WorkBuddy (GLM-5.3-Flash)
* fix(maven-artifact-verify): resolve inherited POM elements against the staged parent chain
check_pom_entries reported PASS for an element absent from the POM
itself whenever any parent POM was staged, without ever reading the
parent: a child of a staged parent declaring MIT (or no licence at
all) passed a blocking check, and the walk never went past the direct
parent. A POM with no <parent> and a missing element got the
INHERITED-UNVERIFIED warning, but nothing can be inherited there and
Maven Central rejects such a POM.
Resolution now walks the locally staged parent chain with cycle
protection: the first ancestor declaring the element is judged as-is
(the same _evaluate_element used locally), a complete chain that
supplies nothing — including a parentless POM — is a hard FAIL, and
only a chain that cannot be fully resolved offline stays
INHERITED-UNVERIFIED. check_disclaimer shares the chain, so an
inherited description is judged as-is too, and report details name the
ancestor coordinate. Negative tests cover staged-parent-MIT,
staged-parent-without-scm, parentless-missing-element, a two-level
chain resolving through the grandparent, and a parent-chain cycle.
check_companions verifies checksum files with hashlib against the
companion jar's actual bytes (still offline); unknown digest
algorithms degrade to presence-only with an explicit finding instead
of a fake PASS. .asc signatures stay presence-only offline — the Step
6b recipe extends the Step 2 gpg --verify flow to the companions — and
the README says so. A top-level-only glob misreported a staging
directory in Maven-repository layout as a non-JVM artefact set, so the
scan is rglob-based and the main jar resolves next to its own POM.
Generated-by: WorkBuddy (AI agent)
* docs(release): wire jvm_artefact_checks and jvm_digest_set into Step 6b
release-build.md declared two keys nothing read: Step 6b now skips
cleanly (stating the skip) when the section declares
jvm_artefact_checks: off, and resolves the recipe's --digests from
jvm_digest_set when it is set, falling back to the Digest set. The
template documents the unset default for jvm_companion_location
(observation-only, same as nexus-staging) so copying the example value
staged is a deliberate opt-in to the stricter mode.
Step 6b text now describes the parent-chain resolution and the
companion checksum verification as the tool implements them, extends
the paste-ready recipe with gpg --verify lines for companion .asc
files, and drops the bare #1172/#1173 references from the
adopter-facing template (a bare #NNN resolves against the adopter's
tracker once the template is copied). The lifecycle spec keeps its
lifecycle step numbers only. The eval fixtures' tool-report excerpts
and the output spec are synced with the new detail strings and the
jvm_digest_set rule, and the measured_tokens stamp is re-measured.
Generated-by: WorkBuddy (AI agent)
* fix(skill-evals): grade step-6b finding fields semantically
A maintainer run of the release-verify-rc suite scored step-6b 1/4:
cases 2-4 reached the right verdicts but failed on pom_findings /
companion_findings, which the step's grading schema did not list as
prose fields, so the model's one-line paraphrases of the tool report
were compared verbatim against expected.json. The output spec defines
both as one-line model-written findings, not verbatim tool output, so
they belong to the semantic grader alongside paste_recipe and
tool_report; status and step stay exact. The suite README's
grading-methodology paragraph named only paste_recipe; state the
mechanism instead.
* fix(maven-artifact-verify): resolve <scm> per field along the parent chain
Maven merges <scm> per field: a child declaring only <scm><tag> still
inherits url/connection from an ancestor. The tool judged any local
<scm> in isolation, so such a child was a hard FAIL both when a staged
parent supplied the fields and when the parent was unstaged (where
INHERITED-UNVERIFIED is the honest answer) - a false FAIL on a correct
POM, the case #1173 says must never happen.
url/connection now resolve independently along the staged chain
(nearest non-empty declaration wins; empty and tag-only declarations
never fail a child on their own). Either field resolvable is a PASS;
neither field anywhere in a complete chain is a hard FAIL. <licenses>
and <developers> stay element-level.
* fix(setup): ship jvm_companion_location unset in the release-build template
The Value column shipped staged, so an adopter copying the template
verbatim was opted into strict mode, where a locally absent jar is a
FAIL. Unset matches the documented default (same as nexus-staging: an
absent jar is an observation) and mirrors jvm_digest_set.
* fix(maven-artifact-verify): close the non-blocking review polish comments
Four small review follow-ups:
- A cyclic parent chain is a FAIL, not INHERITED-UNVERIFIED: Maven
refuses to build one, so it cannot be a correct POM inheriting from
the ASF parent - the case the warning protects. Applies to check 1
(licences/developers), the <scm> per-field resolution and check 2.
- Checksum files are parsed leniently: the hex digest is extracted by
shape, so the BSD/tagged 'ALGO (file) = <hex>' (shasum --tag) and
'gpg --print-md' layouts match like the GNU coreutils one already
did. The old docstring called the GNU layout BSD-style; fixed.
- APACHE_LICENSE_NAME_RE also accepts the SPDX id 'Apache-2.0' and the
legacy 'The Apache Software License, Version 2.0' wording, which
previously only passed via the URL regex.
- artifactId/version are validated against Maven's [A-Za-z0-9_.-]
before a path is built from them, so a POM with <artifactId>../../x
can no longer point the companion checks outside the staged
directory; that POM's jar checks are skipped with a finding instead.
* chore: refresh verify-rc stamps and harness count after the rebase
Rebasing onto main pulled the upstream edits to release-verify-rc into
the merged file, so the generated stamps no longer describe it:
recompute surface_hash and measured_tokens, and bump the agent-harness
count to 30/30 now that maven-artifact-verify is part of the tree the
count measures.
Generated-by: WorkBuddy (AI agent)
* fixup: accept gpg --print-md checksums and tighten Step 6b output
- maven-artifact-verify: when no contiguous hex run is found, rejoin
the digest after the last ':' so real `gpg --print-md` output
(upper-case 8-char groups, wrapped with a hanging indent) verifies
instead of reporting a mismatch on a correct companion. Tests now use
the real grouped, wrapped layout, plus a wrong-digest case that still
fails.
- verify-rc Step 6b: resolve `<framework>` in the paste-ready recipe;
emit a companion `gpg --verify` line only when its `.asc` is staged;
`pom_findings` / `companion_findings` list only checks that did not
pass. measured_tokens restamped.
- step-6b eval fixtures: expected recipes include the companion
`gpg --verify` lines the step requires. Suite is 4/4.
Generated-by: Claude Opus 5
---------
Co-authored-by: Arnav <im...@gmail.com>
Co-authored-by: Jarek Potiuk <po...@apache.org>
---
.github/labeler.yml | 1 +
docs/labels-and-capabilities.md | 3 +-
docs/release-management/spec.md | 7 +-
docs/setup/marketplace.md | 2 +-
docs/vendor-neutrality.md | 5 +-
.../skills/verify-rc/SKILL.md | 124 +++-
plugins/magpie-setup/templates/release-build.md | 27 +
pyproject.toml | 1 +
tools/maven-artifact-verify/README.md | 148 +++++
tools/maven-artifact-verify/pyproject.toml | 90 +++
.../src/maven_artifact_verify/__init__.py | 676 +++++++++++++++++++
.../src/maven_artifact_verify/__main__.py | 23 +
.../tests/test_maven_artifact_verify.py | 740 +++++++++++++++++++++
tools/skill-evals/README.md | 2 +-
.../skill-evals/evals/release-verify-rc/README.md | 15 +-
.../case-1-clean-podling-staged-set/expected.json | 7 +
.../case-1-clean-podling-staged-set/report.md | 39 ++
.../case-2-wrong-pom-licence/expected.json | 9 +
.../fixtures/case-2-wrong-pom-licence/report.md | 43 ++
.../expected.json | 9 +
.../case-3-missing-companion-signature/report.md | 37 ++
.../case-4-jar-absent-nexus-only/expected.json | 7 +
.../case-4-jar-absent-nexus-only/report.md | 49 ++
.../fixtures/grading-schema.json | 3 +
.../step-6b-jvm-artefacts/fixtures/output-spec.md | 38 ++
.../fixtures/step-config.json | 4 +
.../specs/release-management-lifecycle.md | 5 +-
uv.lock | 16 +
28 files changed, 2115 insertions(+), 15 deletions(-)
diff --git a/.github/labeler.yml b/.github/labeler.yml
index b0020074..e56c1f9d 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -180,6 +180,7 @@ substrate:release:
- any:
- changed-files:
- any-glob-to-any-file:
+ - 'tools/maven-artifact-verify/**'
- 'tools/reproducible-archive/**'
substrate:review:
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 5c4854eb..88ef785a 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -147,7 +147,7 @@ framework substrate:
| `substrate:action-guard` | substrate | Deterministic pre-tool-use command guards. |
| `substrate:privacy` | substrate | PII redaction / approved-LLM gating. |
| `substrate:framework-dev` | substrate | Build / validate / eval the framework itself. |
-| `substrate:release` | substrate | Release-artefact helpers an adopter's release process runs: reproducible-archive build, lint and comparison. |
+| `substrate:release` | substrate | Release-artefact helpers an adopter's release process runs: reproducible-archive build, lint and comparison; maven-artifact-verify for staged JVM artefacts. |
| `substrate:setup` | substrate | Adopter-side setup state an agent resolves at runtime rather than at development time: lock parsing, floor comparison, reconciliation fingerprints. |
| `substrate:review` | substrate | A second model's read of a change before it is published: other models' CLIs run read-only, findings merged. |
@@ -363,6 +363,7 @@ or a contract-free mix of substrates (e.g. `tools/spec-inventory` is
| [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/) | `substrate:framework-dev` + `substrate:analytics` | Deterministic vendor-neutrality score — reads each contract tool's `**Kind:**` / `**Vendor:**` metadata and scores per-contract + per-skill neutrality (`analytics`); backs the score block in [`docs/vendor-neutrality.md`](vendor-neutrality.md) |
| [`tools/spec-validator`](../tools/spec-validator/) | `substrate:framework-dev` | Spec-frontmatter and body-section validator — counterpart to `skill-and-tool-validator` for `tools/spec-loop/specs/` |
| [`tools/reproducible-archive`](../tools/reproducible-archive/) | `substrate:release` | `repro-archive` — build, lint and compare reproducible source archives from a git ref (`git archive` + `.gitattributes` `export-ignore`, every reproducible-builds.org archive rule applied); used by `release-rc-cut` Step 2/2b and `release-verify-rc` Step 9 |
+| [`tools/maven-artifact-verify`](../tools/maven-artifact-verify/) | `substrate:release` | `maven-artifact-verify` — verify locally staged JVM RC artefacts: POM ALv2 licence / developers / scm (with parent-POM inheritance resolution), podling incubation disclaimer in `<description>`, companion `-sources.jar` / `-javadoc.jar` presence with their own signatures and checksums; used by `release-verify-rc` Step 6b |
| [`tools/symlink-lint`](../tools/symlink-lint/) | `substrate:framework-dev` | Self-adoption symlink hygiene — rejects cyclic symlinks, misdirected skill relays (canonical/relay target-correctness), and incomplete self-adoption symlink sets |
| [`tools/pilot-report-validator`](../tools/pilot-report-validator/) | `substrate:framework-dev` | Adopter pilot-report validator — required frontmatter keys, no unfilled placeholders, valid profile, and required body sections; counterpart to `spec-validator` for `docs/pilot-report-template.md` |
| [`tools/skill-reconciler-diff`](../tools/skill-reconciler-diff/) | `substrate:framework-dev` | Deterministic structural diff between two skill trees — parses frontmatter, section headings, step inventory, placeholders, support files, and safety-baseline clauses into a JSON diff object for the `skill-reconciler` skill |
diff --git a/docs/release-management/spec.md b/docs/release-management/spec.md
index 39c57310..badbac5b 100644
--- a/docs/release-management/spec.md
+++ b/docs/release-management/spec.md
@@ -408,8 +408,11 @@ loop before posting `+1`.
- A pass/fail report per check (signatures, checksums, license
headers via Apache RAT, NOTICE / LICENSE presence + diff vs
- previous release, no prohibited binaries, source-tree integrity,
- version-string consistency, and — optional per
+ previous release, no prohibited binaries, published-JVM-artefact
+ compliance via `tools/maven-artifact-verify` — POM licence set,
+ podling incubation disclaimer, companion `-sources.jar` /
+ `-javadoc.jar` with signatures and checksums — source-tree
+ integrity, version-string consistency, and — optional per
`release-build.md § Reproducibility checks` — reproducibility: the
source artefact rebuilt from the tag with `repro-archive build` at
the recorded `SOURCE_DATE_EPOCH` and compared (`identical` /
diff --git a/docs/setup/marketplace.md b/docs/setup/marketplace.md
index bf607482..aafdb53e 100644
--- a/docs/setup/marketplace.md
+++ b/docs/setup/marketplace.md
@@ -157,7 +157,7 @@ can say so, because it is the floor everything else is managed from.
|---|---|---|
| `magpie-security` | 15 | ~1.1k |
| `magpie-setup` | 10 | ~0.7k |
-| `magpie-release-management` | 10 | ~1.3k |
+| `magpie-release-management` | 10 | ~1.4k |
| `magpie-pr-management` | 8 | ~0.8k |
| `magpie-issue` | 8 | ~0.7k |
| `magpie-repo-health` | 7 | ~0.6k |
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index 05952420..62a40b7f 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -603,7 +603,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 16, agnostic = 62.
**LLM / agent-integration neutrality**
-**Agent harness: 29/29 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness.
+**Agent harness: 30/30 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness.
| Substrate tool | Substrate | Harness support | Verdict |
|---|---|---|---|
@@ -615,6 +615,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 16, agnostic = 62.
| `dashboard-generator` | analytics | any | ✅ agnostic |
| `dev` | framework-dev | any | ✅ agnostic |
| `egress-gateway` | sandbox | any | ✅ agnostic |
+| `maven-artifact-verify` | release | any | ✅ agnostic |
| `permission-audit` | sandbox | any | ✅ agnostic |
| `pilot-report-validator` | framework-dev | any | ✅ agnostic |
| `pr-management-stats` | analytics | any | ✅ agnostic |
@@ -645,7 +646,7 @@ Harness → substrate tools it supports:
- **Gemini CLI** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
- **Kiro** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
- **OpenCode** (3): `agent-guard`, `sandbox-lint`, `spec-loop`
-- **any harness** (26): `adversarial-review`, `agent-isolation`, `container-gateway`, `contributor-metrics`, `dashboard-generator`, `dev`, `egress-gateway`, `permission-audit`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `reproducible-archive`, `security-tracker-stats-dashboard`, `setup-preflight`, `skill-and-tool-validator`, `skill-evals`, `skill-reconciler-diff`, `skill-token-count`, `spec-inventory`, `spec-status-index`, `spec- [...]
+- **any harness** (27): `adversarial-review`, `agent-isolation`, `container-gateway`, `contributor-metrics`, `dashboard-generator`, `dev`, `egress-gateway`, `maven-artifact-verify`, `permission-audit`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `reproducible-archive`, `security-tracker-stats-dashboard`, `setup-preflight`, `skill-and-tool-validator`, `skill-evals`, `skill-reconciler-diff`, `skill-token-count`, `spec-inventory`, `s [...]
**Model endpoint: neutral by construction — 4 default-approved endpoint classes across independent trust domains, plus adopter opt-in.** From the [`privacy-llm` registry](../tools/privacy-llm/models.md): the framework keys approval on *endpoint identity*, not on who hosts the model, so no single LLM vendor is privileged.
diff --git a/plugins/magpie-release-management/skills/verify-rc/SKILL.md b/plugins/magpie-release-management/skills/verify-rc/SKILL.md
index 0d113e7a..6ea72328 100644
--- a/plugins/magpie-release-management/skills/verify-rc/SKILL.md
+++ b/plugins/magpie-release-management/skills/verify-rc/SKILL.md
@@ -13,8 +13,11 @@ description: |
for `<upstream>`. Checks artefact integrity (GPG signatures and
checksums), Apache RAT licence headers, NOTICE/LICENSE completeness,
prohibited-binary absence (including `.pyc` / `__pycache__`),
- source-tree integrity (no dangling symlinks or broken internal
- references), version-string consistency, and — optionally, per
+ published-JVM-artefact compliance (POM licence/developers/scm,
+ podling incubation disclaimer, companion `-sources.jar` /
+ `-javadoc.jar` with their own signatures and checksums), source-tree
+ integrity (no dangling symlinks or broken internal references),
+ version-string consistency, and — optionally, per
`release-build.md § Reproducibility checks` — reproducibility: the
source archive is rebuilt from the tag with `repro-archive` and
compared byte-for-byte with the staged artefact, and convenience
@@ -32,9 +35,9 @@ when_to_use: |
run standalone with no other release-* skill in the session.
argument-hint: "<version>-rcN [--post-to <planning-issue-url>] [--skip-repro] [--trusted-hardware]"
capability: capability:triage
-surface_hash: sha256:42f7f872b6dcd0e8
+surface_hash: sha256:ed944a58facaae21
license: Apache-2.0
-measured_tokens: 10725
+measured_tokens: 12502
---
<!-- SPDX-License-Identifier: Apache-2.0
@@ -550,6 +553,107 @@ tarball was not exported clean from the tag.
---
+## Step 6b — JVM artefact checks (when the RC stages jars)
+
+**When it runs.** Only when the Step 1 listing contains at least one
+`.jar` or `.pom`, and only when `release-build.md § JVM artefact
+checks` does not declare `jvm_artefact_checks: off` (absent or `on`
+means run). A non-JVM project's RC, or a project that has turned the
+checks off, skips this step cleanly — state the skip explicitly, do
+not silently pass.
+
+Step 6 treats a `.jar` as contraband inside the **source** artefact.
+The jars downstream consumers actually resolve are a separate surface:
+the published `.pom` files, the main jars, and their companion
+`-sources.jar` / `-javadoc.jar`. This step validates that surface with
+the [`maven-artifact-verify`](../../../../tools/maven-artifact-verify/README.md)
+tool — blocking checks 1–3 of
+[issue #1173](https://github.com/apache/magpie/issues/1173), which
+implement [ASF Incubator distribution policy § Maven
+distribution](https://incubator.apache.org/guides/distribution.html)
+and [Maven Central's publishing
+requirements](https://central.sonatype.org/publish/requirements/):
+
+1. **POM licence entry** — every `.pom` declares ALv2, `<developers>`
+ and `<scm>`. An element absent from the POM itself resolves against
+ the chain of locally staged parent POMs: the first ancestor
+ declaring the element is judged as-is, so a staged parent carrying
+ a non-ALv2 licence fails the child too. An element no staged
+ ancestor declares when the chain ends at a POM with no `<parent>`
+ — including a POM with no `<parent>` at all — is a `FAIL`, the
+ same judgement Maven Central applies. `INHERITED-UNVERIFIED` — a
+ warning naming what to verify — is reserved for a chain that
+ cannot be fully resolved offline; it never fails a correct POM
+ that inherits from the ASF parent.
+2. **Incubator disclaimer in `<description>`** — podlings only, when
+ `--podling` is passed. Accepts the standard disclaimer text and the
+ `DISCLAIMER-WIP` variant, tolerating whitespace and line-wrapping.
+ An inherited description is judged the same way as a local one.
+3. **Companion jars** — for every main jar staged locally,
+ `-sources.jar` and `-javadoc.jar` exist and each carries its own
+ `.asc` and checksums, the checksums verified against the jar's
+ actual bytes. Offline the tool checks `.asc` presence only:
+ extend the paste-ready recipe with one `gpg --verify <companion>.asc
+ <companion>` line per companion whose `.asc` is staged (same `KEYS`
+ flow as Step 2) so the companions get the same signature
+ verification as the main artefacts; a companion with no `.asc` is
+ already a finding and gets no line. A main
+ jar declared by a staged POM but not staged locally is an
+ observation (`ABSENT`), not a failure: in the common ASF workflow
+ the jars are staged in the Nexus staging repository, which this
+ step never reads (read-only, and check 4 is a later PR on
+ [#1173](https://github.com/apache/magpie/issues/1173)). Classify an
+ `ABSENT` jar against `release-build.md § JVM artefact checks` —
+ when that file declares `jvm_companion_location: staged`, an
+ absent jar is a `FAIL`.
+
+Emit the paste-ready recipe. Resolve every placeholder to a concrete
+value: `<framework>` is the framework root (`.apache-magpie` in an
+adopter repository), `<staged-dir>` is the local directory holding the staged RC
+artefacts, `<digest-set>` is the `jvm_digest_set` key of
+`release-build.md § JVM artefact checks` when it is set, otherwise the
+§ Digest set (comma-separated, default `sha512`), and pass
+`--podling` **only** when the unpacked source artefact ships a
+`DISCLAIMER` or `DISCLAIMER-WIP` file at its root — that is the
+podling signal this step uses until the `project_stage` plumbing
+lands.
+
+```bash
+uv run --project <framework>/tools/maven-artifact-verify \
+ maven-artifact-verify "<staged-dir>" --digests <digest-set> [--podling]
+# without uv:
+# python3 <framework>/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py ...
+```
+
+The step never modifies anything: the tool is offline and reads the
+staged directory only, so any voter may run it.
+
+Return ONLY valid JSON with this structure:
+
+```json
+{
+ "step": "jvm-artefacts",
+ "status": "PASS" | "WARN" | "FAIL" | "SKIP",
+ "tool_report": "<the maven-artifact-verify JSON report verbatim>",
+ "pom_findings": ["<one line per POM finding>"],
+ "companion_findings": ["<one line per jar finding>"],
+ "paste_recipe": "<multi-line shell commands>"
+}
+```
+
+`pom_findings` and `companion_findings` list only the checks that did
+not pass (`FAIL`, `INHERITED-UNVERIFIED`, `ABSENT`), one line each
+naming the artefact and what is wrong; a passing check is not a
+finding, and an empty list means there is nothing to report.
+
+`status` is the tool report's `status`, except that an `ABSENT` jar
+becomes `FAIL` when `release-build.md § JVM artefact checks` declares
+`jvm_companion_location: staged` (the RC was expected to stage it).
+`SKIP` when no jars or POMs are staged, or when the section declares
+`jvm_artefact_checks: off`.
+
+---
+
## Step 7 — Source-tree integrity (dangling symlinks + broken references)
A source archive can be signed, checksummed and licence-clean and
@@ -914,6 +1018,11 @@ the RM has not yet confirmed posting.
| Step 5 WARN — material diff | Licence or attribution changed vs previous release | RM reviews diff; if intentional, document in planning issue |
| Step 6 FAIL — prohibited binary | Binary sneaked into source artefact | RM removes binary, updates `.gitattributes` or build excludes, cuts new RC |
| Step 6 FAIL — `.pyc` / `__pycache__` present | Tarball zipped from a working tree that ran tests, not exported clean from the tag | RM rebuilds via `git archive <tag>` (never `zip -r`), cuts new RC |
+| Step 6b FAIL — POM licence/developers/scm wrong | The POM does not satisfy ASF Incubator distribution policy § Maven distribution | RM fixes the POM, re-deploys, re-cuts RC |
+| Step 6b FAIL — companion jar or its `.asc`/checksum missing | Maven Central requires `-sources.jar` / `-javadoc.jar` companions, each signed and checksummed; Nexus close-time validation fails without them | RM re-deploys with signed companions, re-cuts RC |
+| Step 6b FAIL — companion checksum mismatch | The recorded digest does not match the companion jar's bytes (stale or corrupted checksum file) | RM re-deploys the companion set with regenerated checksums, re-cuts RC |
+| Step 6b WARN — `INHERITED-UNVERIFIED` | POM element inherited from a parent POM that is not staged locally | Verify against the effective POM (`mvn help:effective-pom`); if correct, no action |
+| Step 6b FAIL — jar absent but `jvm_companion_location: staged` | The RC was expected to stage its jars locally and did not | RM re-stages the jar set or corrects `release-build.md` |
| Step 7 FAIL — dangling symlink | A committed symlink's target was stripped by `export-ignore` (or is otherwise absent) | RM fixes `.gitattributes` to ship the target (or drops the symlink), cuts new RC |
| Step 7 FAIL — broken internal reference | A shipped file links to a path stripped from the artefact | RM stops stripping the referenced path, or repoints the reference at shipped content, cuts new RC |
| Step 8 FAIL — version mismatch | Version bump missed one manifest file | RM fixes the manifest and cuts a new RC |
@@ -943,6 +1052,13 @@ the RM has not yet confirmed posting.
checks, and the 🪶 ASF-specific automated-signing validation.
- [`tools/reproducible-archive`](../../../../tools/reproducible-archive/README.md) —
`repro-archive check` / `build` / `compare`.
+- [`tools/maven-artifact-verify`](../../../../tools/maven-artifact-verify/README.md) —
+ the JVM-artefact checker behind Step 6b (blocking checks 1–3 of
+ [issue #1173](https://github.com/apache/magpie/issues/1173)).
+- [ASF Incubator distribution guidelines § Maven distribution](https://incubator.apache.org/guides/distribution.html) —
+ the policy behind the Step 6b POM and disclaimer checks.
+- [Maven Central publishing requirements](https://central.sonatype.org/publish/requirements/) —
+ the mandatory sources/javadoc companions behind Step 6b check 3.
- [reproducible-builds.org § Archive metadata](https://reproducible-builds.org/docs/archives/) —
the rules `repro-archive check` verifies.
- `release-keys-sync` (proposed) — remediation path when a signing
diff --git a/plugins/magpie-setup/templates/release-build.md b/plugins/magpie-setup/templates/release-build.md
index 73606d48..a87f0ab6 100644
--- a/plugins/magpie-setup/templates/release-build.md
+++ b/plugins/magpie-setup/templates/release-build.md
@@ -12,6 +12,7 @@
- [Source-tree validators](#source-tree-validators)
- [Expected artefact list](#expected-artefact-list)
- [Digest set](#digest-set)
+ - [JVM artefact checks](#jvm-artefact-checks)
- [Reproducibility checks](#reproducibility-checks)
- [Binary-exclude list](#binary-exclude-list)
- [Apache RAT configuration](#apache-rat-configuration)
@@ -189,6 +190,32 @@ Example shape:
> - `sha512`, required.
> - `sha256`, published for downstream-tool compatibility.
+## JVM artefact checks
+
+Optional, and relevant only when the release publishes Maven
+artefacts. `release-verify-rc` Step 6b verifies the staged `.pom`
+files (ALv2 licence, `<developers>`, `<scm>`, podling incubation
+disclaimer in `<description>`) and — for jars staged locally — the
+companion `-sources.jar` / `-javadoc.jar` set with their signatures
+and checksums, via
+[`tools/maven-artifact-verify`](../../../tools/maven-artifact-verify/README.md).
+When the staged artefact set contains no `.jar` and no `.pom`, the
+step skips cleanly and nothing here needs to be configured.
+
+| Key | Value | Notes |
+|---|---|---|
+| `jvm_artefact_checks` | `on` | `on` (default) — run Step 6b whenever the staged set contains jars or POMs; `off` — skip (e.g. the project stages jars only through a platform checked elsewhere) |
+| `jvm_companion_location` | *(unset)* | `staged` — the companion `.pom` / `-sources.jar` / `-javadoc.jar` set is staged with the RC and must be present, each companion signed and checksummed; `nexus-staging` — the jars publish through the Nexus staging repository only, so a locally absent jar is an observation, not a failure; *(unset)* — same as `nexus-staging` |
+| `jvm_digest_set` | *(unset)* | digests each companion must carry; unset means the § Digest set above applies as-is |
+
+Model the companion set in § Expected artefact list too: for a
+`kind: jar` convenience artefact, name the `.pom`,
+`-sources.jar` and `-javadoc.jar` companions expected alongside it,
+so a missing companion is caught by the Step 1 inventory as well.
+
+The incubation-disclaimer check is gated on the podling signal (a
+`DISCLAIMER` / `DISCLAIMER-WIP` file at the source artefact root).
+
## Reproducibility checks
Optional checks that `release-rc-cut` (Step 2b, RM self-check) and
diff --git a/pyproject.toml b/pyproject.toml
index f9e4939b..b09425d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -131,6 +131,7 @@ members = [
"tools/jira",
"tools/maildir",
"tools/mail-source/imap",
+ "tools/maven-artifact-verify",
"tools/permission-audit",
"tools/pr-management-stats",
"tools/preflight-audit",
diff --git a/tools/maven-artifact-verify/README.md b/tools/maven-artifact-verify/README.md
new file mode 100644
index 00000000..e3468e7e
--- /dev/null
+++ b/tools/maven-artifact-verify/README.md
@@ -0,0 +1,148 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+- [`maven-artifact-verify`](#maven-artifact-verify)
+ - [The checks it implements](#the-checks-it-implements)
+ - [Prerequisites](#prerequisites)
+ - [How to use](#how-to-use)
+ - [Wiring](#wiring)
+ - [Boundary conditions](#boundary-conditions)
+ - [Tests](#tests)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+# `maven-artifact-verify`
+
+**Capability:** substrate:release
+
+**Harness:** agnostic
+
+**Organization:** ASF
+
+Verifies **locally staged JVM release-candidate artefacts** — the
+`.pom` files, the main jars and their companion `-sources.jar` /
+`-javadoc.jar` — the way `release-verify-rc` verifies a staged source
+artefact today. Implements the blocking checks 1–3 proposed in
+[apache/magpie#1173](https://github.com/apache/magpie/issues/1173);
+the Nexus staging-repository check (check 4) and the informational
+checks (5–7) are later PRs on that issue.
+
+Until this tool exists, `release-verify-rc` handles a jar in exactly
+one direction: as *contraband inside the source tree* (Step 6's
+prohibited-binary baseline). The published jar — the artefact Maven
+Central actually serves to users — is never opened or validated. This
+tool closes that gap for the checks that [ASF Incubator distribution
+policy](https://incubator.apache.org/guides/distribution.html) and
+[Maven Central's publishing
+requirements](https://central.sonatype.org/publish/requirements/) make
+blocking.
+
+## The checks it implements
+
+1. **POM licence entry** — every staged `.pom` declares ALv2 in its
+ `<licenses>` block, plus `<developers>` and `<scm>`. An element
+ absent from the POM itself resolves against the chain of locally
+ staged parent POMs (with cycle protection, up through
+ grandparents): the first ancestor declaring the element is judged
+ as-is, so a staged parent carrying a non-ALv2 licence fails the
+ child too. `<scm>` resolves per field, the way Maven merges it: a
+ child declaring only `<scm><tag>` still inherits `url` /
+ `connection` from the nearest ancestor declaring them, and an
+ empty or tag-only declaration never fails the child on its own. An
+ element no staged ancestor declares when the chain
+ ends at a POM with no `<parent>` — including a POM with no
+ `<parent>` at all — is a `FAIL`, the same judgement Maven Central
+ applies. `INHERITED-UNVERIFIED` — a warning that names what to
+ verify, never a failure of a correct POM — is reserved for a chain
+ that cannot be fully resolved offline. A cyclic parent chain is a
+ `FAIL` instead: Maven refuses to build one, so it cannot be a
+ correct POM inheriting from the ASF parent.
+2. **Incubator disclaimer in `<description>`** — podlings only
+ (`--podling`). Accepts the standard disclaimer text and the
+ `DISCLAIMER-WIP` variant, tolerating whitespace and line-wrapping
+ differences inside the XML element. Matching is deliberately keyed
+ to the core both texts share ("is an effort undergoing incubation
+ at The Apache Software Foundation … has yet to be fully endorsed by
+ the ASF") rather than to one full verbatim text. An inherited
+ description is judged the same way as a local one.
+3. **Companion jars** — for every main jar, both `-sources.jar` and
+ `-javadoc.jar` exist and each carries its own `.asc` signature and
+ checksum files (the digest set is configurable, default `sha512`).
+ Checksum files are verified against the companion jar's actual
+ bytes (`hashlib`, still offline); `.asc` signatures are checked
+ for presence only — offline signature verification needs GPG and
+ the release key, which `release-verify-rc` Step 2 runs against the
+ main artefacts, and the Step 6b recipe extends to the companions.
+
+The overall `status` is `FAIL` when any check fails, `WARN` when only
+`INHERITED-UNVERIFIED` results remain, `PASS` otherwise, and `SKIP`
+when the staged set contains no `.pom` and no `.jar` at all — a
+non-JVM project's RC runs the tool and skips cleanly.
+
+## Prerequisites
+
+- **Runtime** — Python 3.11+ (stdlib only; can run as
+ `python3 -m maven_artifact_verify` or via `uv run --project`).
+- **CLIs** — None beyond the runtime.
+- **Credentials / auth** — None.
+- **Network** — fully offline; reads local files only.
+
+## How to use
+
+```bash
+# From a staged RC directory (e.g. dist/dev/<project>/<rc-tag>/):
+uv run --project tools/maven-artifact-verify maven-artifact-verify \
+ <staged-dir> --digests sha512 [--podling]
+```
+
+`--podling` gates check 2; pass it when the RC's source artefact ships
+a `DISCLAIMER` / `DISCLAIMER-WIP` file (the same signal a voter checks
+by hand today). Prints one JSON report on stdout; exit code 0 unless a
+check `FAIL`ed (then 1) or the arguments are wrong (2).
+
+## Wiring
+
+| Skill | Uses |
+|---|---|
+| `release-verify-rc` | Step 6b emits the command above when the staged listing contains jars and classifies the RC from its JSON report |
+
+## Boundary conditions
+
+Encoded from the issue's boundary-conditions section, so the tool does
+not fail correct releases:
+
+- `packaging=pom` modules have no jar and are exempt from check 3
+ (they still get checks 1 and 2).
+- Placeholder companion jars are a Maven-Central-sanctioned pattern;
+ check 3 only verifies presence, signatures and checksums, and never
+ opens a jar to judge its content (that is informational check 7, a
+ later PR).
+- Classified jars (`-tests`, `-shaded`, `-linux-x86_64`, …) are
+ neither mains nor companions: a jar whose classifier is not
+ `sources`/`javadoc` and that no staged POM declares is reported in
+ `unmatched_jars`, never failed in either direction.
+- The `-tests` jar of a project does not need its own sources/javadoc
+ companions; only POM-declared main jars do.
+- Inherited POM elements: `<licenses>`, `<developers>`, `<scm>` and
+ `<description>` are commonly inherited from the ASF parent POM
+ (`org.apache:apache`). Resolution walks the locally staged parent
+ chain (cycle-protected, up through grandparents) — the tool is
+ offline by design and never fetches a parent from a remote
+ repository. A complete staged chain that supplies the element
+ decides `PASS`/`FAIL` on its own content; a complete chain that
+ supplies nothing — like a POM with no `<parent>` at all — is a hard
+ `FAIL`; and only a chain that cannot be fully resolved offline
+ falls back to `INHERITED-UNVERIFIED` rather than guessing.
+
+## Tests
+
+```bash
+uv run --project tools/maven-artifact-verify pytest
+```
+
+The fixtures build synthetic POMs and jars (plain strings and
+`zipfile` output) at test time, so the repository carries no binary
+test blobs.
diff --git a/tools/maven-artifact-verify/pyproject.toml b/tools/maven-artifact-verify/pyproject.toml
new file mode 100644
index 00000000..a80968b9
--- /dev/null
+++ b/tools/maven-artifact-verify/pyproject.toml
@@ -0,0 +1,90 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "maven-artifact-verify"
+version = "0.1.0"
+description = "Verify locally staged JVM release-candidate artefacts: POM licence set, podling disclaimer, companion -sources/-javadoc jars with signatures and checksums."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+# stdlib-only on purpose — argparse, json, re, sys and xml.etree are all
+# it needs, and the checks are fully offline by design: a voter with no
+# Nexus karma must be able to run them against a staged directory.
+dependencies = []
+
+[project.scripts]
+maven-artifact-verify = "maven_artifact_verify:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/maven_artifact_verify"]
+
+[tool.ruff]
+line-length = 160
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "UP", # pyupgrade
+ "SIM", # flake8-simplify
+ "C4", # flake8-comprehensions
+ "RUF", # ruff-specific
+]
+ignore = [
+ "E501", # line-too-long — the limit above is already generous
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+warn_unused_ignores = true
+warn_redundant_casts = true
+warn_unreachable = true
+check_untyped_defs = true
+no_implicit_optional = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
+
+[dependency-groups]
+# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev`
+# (tools/dev), declared once for the whole workspace. The checks run each tool
+# via `uv run --directory <member> --project . python -m <tool>` — see
+# tools/dev/run-workspace-check.sh.
+dev = ["magpie-dev"]
diff --git a/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py
new file mode 100644
index 00000000..f19ff219
--- /dev/null
+++ b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py
@@ -0,0 +1,676 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Verify locally staged JVM release-candidate artefacts.
+
+Implements the blocking checks 1-3 of apache/magpie issue #1173
+(`release-verify-rc: validate rc jars`), local artefacts only:
+
+1. **POM licence entry** - every staged ``.pom`` must declare the
+ ALv2 licence, ``<developers>`` and ``<scm>``. An element absent
+ from the POM itself is resolved against the chain of locally
+ staged parent POMs: the first ancestor declaring the element
+ decides PASS or FAIL. When the staged chain proves that nothing
+ could supply the element - including a POM with no ``<parent>``
+ at all - the result is a failure, the same judgement Maven
+ Central applies. When the chain cannot be fully resolved offline
+ the result is ``inherited-unverified`` (a warning, never a
+ failure - the correct POM must not be failed, per the issue's
+ boundary conditions).
+2. **Incubator disclaimer in ``<description>``** - podlings only,
+ enabled by ``--podling``. Accepts the standard disclaimer and the
+ ``DISCLAIMER-WIP`` variant; matching tolerates whitespace and
+ line-wrapping differences. An absent description resolves against
+ the locally staged parent chain the same way: a failure when the
+ effective description verifiably lacks the disclaimer, a failure
+ when nothing could supply it, ``INHERITED-UNVERIFIED`` (a
+ warning) when the chain is not fully staged.
+3. **Companion jars** - for every staged main jar, the
+ ``-sources.jar`` and ``-javadoc.jar`` companions must exist and
+ each must carry its own ``.asc`` signature and checksum files.
+ Checksum files are verified against the companion jar's actual
+ bytes (``hashlib``, still offline); ``.asc`` signatures are
+ presence-only here - verifying a signature needs GPG and the
+ release key, which `release-verify-rc` Step 2 does.
+ ``packaging=pom`` modules are exempt (no jar), classified jars
+ (``-tests``, ``-shaded``, ...) are neither mains nor companions.
+
+The tool is stdlib-only and fully offline: it reads the staged
+directory, never the network. Nexus staging-repository checks are out
+of scope here (issue #1173, PR 2).
+
+Output is a single JSON document on stdout, in the shape
+`release-verify-rc` Step 6b consumes.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+MAVEN_NS = "http://maven.apache.org/POM/4.0.0"
+
+# The distinctive core shared by the standard incubation disclaimer and
+# the DISCLAIMER-WIP variant (https://incubator.apache.org/policy/incubation.html#disclaimers).
+# Matching this core (case-insensitively, whitespace-normalised) accepts
+# either text and tolerates wrapping inside the XML element.
+DISCLAIMER_CORE = [
+ "is an effort undergoing incubation at the apache software foundation",
+ "has yet to be fully endorsed by the asf",
+]
+
+# The licence <name> may carry the canonical ASF wording, the legacy
+# "The Apache Software License, Version 2.0" phrasing, or the SPDX id
+# "Apache-2.0" — all three identify ALv2.
+APACHE_LICENSE_NAME_RE = re.compile(r"apache(?:\s+software)?\s+license.*2\.0|apache\s*-\s*2\.0", re.IGNORECASE)
+APACHE_LICENSE_URL_RE = re.compile(r"apache\.org/licenses/LICENSE-2\.0", re.IGNORECASE)
+
+# Classifiers that mark a jar as a companion of another artefact rather
+# than a main artefact. Anything else (-tests, -shaded, -linux-x86_64,
+# ...) is neither a main nor a companion: it is reported as unmatched.
+COMPANION_CLASSIFIERS = ("sources", "javadoc")
+
+# A Maven version segment as it appears in filenames:
+# <artifactId>-<version>[-<classifier>].jar, where the version starts
+# with a digit and may carry a qualifier (rc1, alpha, SNAPSHOT, ...).
+VERSION_SPLIT_RE = re.compile(
+ r"^(?P<stem>.+?)-(?P<version>\d[\w.]*(?:-[A-Za-z]+)*)"
+ r"(?:-(?P<classifier>[a-zA-Z][\w-]*))?$"
+)
+
+# Maven coordinates as they may appear in <artifactId>/<version>: the
+# characters Central accepts. Coordinates go straight into paths when
+# the main jar is located, so anything else (e.g. "../..") is rejected
+# before the filesystem is touched.
+MAVEN_COORDINATE_RE = re.compile(r"[A-Za-z0-9_.-]+")
+
+
+def _local(tag: str) -> str:
+ """Strip the Maven XML namespace from an ElementTree tag."""
+ return tag.rsplit("}", 1)[-1]
+
+
+def _find_child(elem: ET.Element | None, name: str) -> ET.Element | None:
+ if elem is None:
+ return None
+ for child in elem:
+ if _local(child.tag) == name:
+ return child
+ return None
+
+
+def _find_children(elem: ET.Element | None, name: str) -> list[ET.Element]:
+ if elem is None:
+ return []
+ return [child for child in elem if _local(child.tag) == name]
+
+
+def _text(elem: ET.Element | None) -> str | None:
+ if elem is None or elem.text is None:
+ return None
+ value = elem.text.strip()
+ return value or None
+
+
+def _normalize_ws(text: str) -> str:
+ return " ".join(text.split()).lower()
+
+
+def parse_pom(path: Path) -> dict:
+ """Parse one POM into a plain dict of the fields the checks need."""
+ try:
+ root = ET.parse(path).getroot()
+ except ET.ParseError as exc:
+ return {"path": path, "error": f"XML parse error: {exc}"}
+
+ parent = _find_child(root, "parent")
+ return {
+ "path": path,
+ "group_id": _text(_find_child(root, "groupId")) or (_text(_find_child(parent, "groupId")) if parent is not None else None),
+ "artifact_id": _text(_find_child(root, "artifactId")),
+ "version": _text(_find_child(root, "version")) or (_text(_find_child(parent, "version")) if parent is not None else None),
+ "packaging": (_text(_find_child(root, "packaging")) or "jar"),
+ "parent": (
+ parent is not None and (_text(_find_child(parent, "groupId")), _text(_find_child(parent, "artifactId")), _text(_find_child(parent, "version")))
+ )
+ or None,
+ "licenses": _find_children(_find_child(root, "licenses"), "license"),
+ "developers": _find_children(_find_child(root, "developers"), "developer"),
+ "scm": _find_child(root, "scm"),
+ "description": _text(_find_child(root, "description")),
+ "description_present": _find_child(root, "description") is not None,
+ }
+
+
+def _has_apache_license(licenses: list[ET.Element]) -> bool:
+ for lic in licenses:
+ name = _text(_find_child(lic, "name")) or ""
+ url = _text(_find_child(lic, "url")) or ""
+ if APACHE_LICENSE_NAME_RE.search(name) or APACHE_LICENSE_URL_RE.search(url):
+ return True
+ return False
+
+
+def _pom_label(pom: dict) -> str:
+ """Human-readable GAV coordinate for report details."""
+ return ":".join(str(pom.get(key) or "?") for key in ("group_id", "artifact_id", "version"))
+
+
+def _element_present(pom: dict, key: str) -> bool:
+ # lists (licenses/developers): present when non-empty. <scm> is not
+ # judged here: Maven merges it per field, see _check_scm.
+ value = pom.get(key)
+ return bool(value) if isinstance(value, list) else value is not None
+
+
+def _evaluate_element(pom: dict, key: str) -> tuple[bool, str | None]:
+ """Judge one POM's own declaration of ``key``.
+
+ The same judgement whether the element is declared by the POM
+ itself or inherited from a staged ancestor.
+ """
+ if key == "licenses":
+ if _has_apache_license(pom["licenses"]):
+ return True, None
+ return False, (
+ "licences declared but none is ALv2 (expected name matching 'Apache License, Version 2.0' or url containing apache.org/licenses/LICENSE-2.0)"
+ )
+ if pom["developers"]:
+ return True, None
+ return False, "<developers> declared but empty"
+
+
+def _scm_field(pom: dict, field: str) -> str | None:
+ """One ``<scm>`` field, or None when this POM declares no non-empty value."""
+ scm = pom.get("scm")
+ if scm is None:
+ return None
+ return _text(_find_child(scm, field))
+
+
+def _check_scm(pom: dict, chain: list[dict], chain_reason: str) -> dict:
+ """Check 1, the ``<scm>`` part - resolved per field along the chain.
+
+ Maven merges ``<scm>`` per field: a child declaring only
+ ``<scm><tag>`` still inherits ``url`` / ``connection`` from an
+ ancestor, so a local ``<scm>`` is never judged in isolation. For
+ each field, the nearest POM in the staged chain declaring it
+ non-emptily wins (empty declarations are skipped, the walk
+ continues). Either field resolvable — from the POM itself or
+ inherited — is a PASS; neither field resolvable anywhere in a
+ complete chain is a hard FAIL, and so is a cyclic chain (Maven
+ refuses to build one); an unstaged chain reports
+ ``INHERITED-UNVERIFIED``.
+ """
+ source: dict[str, tuple[int, str]] = {}
+ for field in ("url", "connection"):
+ for index, chain_pom in enumerate(chain):
+ if _scm_field(chain_pom, field) is not None:
+ source[field] = (index, _pom_label(chain_pom))
+ break
+
+ if source:
+ inherited: dict[str, list[str]] = {}
+ for field, (index, label) in source.items():
+ if index > 0:
+ inherited.setdefault(label, []).append(field)
+ if inherited:
+ detail = "; ".join(f"{', '.join(fields)} inherited from locally staged parent POM {label}" for label, fields in inherited.items())
+ return {"scm": "PASS", "scm_detail": detail}
+ return {"scm": "PASS"}
+
+ if chain_reason == "cycle":
+ return {
+ "scm": "FAIL",
+ "scm_detail": f"<scm> declares no url or connection and the parent chain is cyclic ({_pom_label(pom)}); Maven refuses to build a cyclic parent chain",
+ }
+ if chain_reason == "complete":
+ if len(chain) == 1:
+ return {
+ "scm": "FAIL",
+ "scm_detail": "<scm> declares no url or connection and the POM declares no <parent>: nothing to inherit from",
+ }
+ return {
+ "scm": "FAIL",
+ "scm_detail": "<scm> declares no url or connection and no parent POM in the staged chain declares one",
+ }
+ return {
+ "scm": "INHERITED-UNVERIFIED",
+ "scm_detail": "<scm> declares no url or connection and the parent chain is not fully staged locally; verify against the effective POM",
+ }
+
+
+def _resolve_chain(pom: dict, by_coordinate: dict) -> tuple[list[dict], str]:
+ """Walk the parent chain through locally staged POMs.
+
+ Returns ``(chain, reason)`` where ``chain`` is ``[pom, parent,
+ grandparent, ...]`` as far as staged POMs resolve it, and ``reason``
+ is ``"complete"`` when the walk ends at a POM with no ``<parent>``
+ (so nothing outside the staged set could supply an inherited
+ element), ``"unstaged"`` when it stops at a parent reference whose
+ POM is not staged, or ``"cycle"`` when a parent coordinate repeats
+ (including a POM naming itself as its own parent).
+ """
+ chain = [pom]
+ visited = {(pom.get("group_id"), pom.get("artifact_id"), pom.get("version"))}
+ current = pom
+ while current.get("parent"):
+ coordinate = tuple(current["parent"])
+ if coordinate in visited:
+ return chain, "cycle"
+ parent = by_coordinate.get(coordinate)
+ if parent is None:
+ return chain, "unstaged"
+ visited.add(coordinate)
+ chain.append(parent)
+ current = parent
+ return chain, "complete"
+
+
+def check_pom_entries(pom: dict, chain: list[dict], chain_reason: str) -> dict:
+ """Check 1 - ALv2 licence, <developers> and <scm> in one POM.
+
+ ``<licenses>`` and ``<developers>`` are judged at element level: an
+ element absent from the POM itself is resolved against the locally
+ staged parent chain, and the first ancestor that declares the
+ element is judged as-is, so a staged parent carrying a non-ALv2
+ licence fails the child too. ``<scm>`` is judged per field (Maven
+ merges ``url`` / ``connection`` / ``tag`` independently) — see
+ ``_check_scm``. When the chain is complete and nothing resolves an
+ element — including a POM with no ``<parent>`` at all, where
+ nothing can be inherited — the result is a hard FAIL; Maven Central
+ rejects such a POM. A cyclic chain is a FAIL too: Maven refuses to
+ build one, so it cannot be a correct POM inheriting from the ASF
+ parent. An unstaged chain — the one case where a correct POM may
+ inherit from the un-staged ASF parent — reports
+ ``INHERITED-UNVERIFIED`` (see issue #1173 boundary conditions).
+ """
+ entries: dict[str, str | None] = {}
+ for key in ("licenses", "developers"):
+ if _element_present(pom, key):
+ ok, fail_detail = _evaluate_element(pom, key)
+ entries[key] = "PASS" if ok else "FAIL"
+ if fail_detail is not None:
+ entries[key + "_detail"] = fail_detail
+ continue
+
+ inherited = next((ancestor for ancestor in chain[1:] if _element_present(ancestor, key)), None)
+ if inherited is not None:
+ ok, fail_detail = _evaluate_element(inherited, key)
+ label = _pom_label(inherited)
+ entries[key] = "PASS" if ok else "FAIL"
+ entries[key + "_detail"] = (
+ f"inherited from locally staged parent POM {label}" if ok else f"{fail_detail} (inherited from locally staged parent POM {label})"
+ )
+ elif chain_reason == "complete":
+ entries[key] = "FAIL"
+ if len(chain) == 1:
+ entries[key + "_detail"] = "element absent and the POM declares no <parent>: nothing to inherit from"
+ else:
+ entries[key + "_detail"] = "element absent and no parent POM in the staged chain declares it"
+ elif chain_reason == "cycle":
+ entries[key] = "FAIL"
+ entries[key + "_detail"] = f"element absent and the parent chain is cyclic ({_pom_label(pom)}); Maven refuses to build a cyclic parent chain"
+ else:
+ entries[key] = "INHERITED-UNVERIFIED"
+ entries[key + "_detail"] = "element absent and the parent chain is not fully staged locally; verify against the effective POM"
+ entries.update(_check_scm(pom, chain, chain_reason))
+ return entries
+
+
+def check_disclaimer(pom: dict, chain: list[dict], chain_reason: str) -> dict:
+ """Check 2 - incubation disclaimer inside <description> (podlings only).
+
+ Resolution mirrors check 1: a ``<description>`` declared by a
+ staged ancestor is judged as-is (an inherited description without
+ the disclaimer stays a FAIL), an element no staged ancestor
+ declares when the chain is complete is a FAIL, a cyclic chain is a
+ FAIL, and an unstaged chain reports ``INHERITED-UNVERIFIED``.
+ """
+ if pom.get("description_present"):
+ description = pom.get("description")
+ if description is None:
+ # <description></description> declared but empty.
+ return {"disclaimer": "FAIL", "disclaimer_detail": "<description> empty for a podling POM"}
+ if all(core in _normalize_ws(description) for core in DISCLAIMER_CORE):
+ return {"disclaimer": "PASS", "disclaimer_detail": None}
+ return {
+ "disclaimer": "FAIL",
+ "disclaimer_detail": ("<description> does not carry the incubation disclaimer (neither the standard text nor the DISCLAIMER-WIP variant)"),
+ }
+
+ # Element absent locally: resolve against the staged parent chain.
+ for ancestor in chain[1:]:
+ if not ancestor.get("description_present"):
+ continue
+ description = ancestor.get("description")
+ label = _pom_label(ancestor)
+ if description is None:
+ return {"disclaimer": "FAIL", "disclaimer_detail": f"inherited <description> is empty (from locally staged parent POM {label})"}
+ if all(core in _normalize_ws(description) for core in DISCLAIMER_CORE):
+ return {"disclaimer": "PASS", "disclaimer_detail": f"inherited from locally staged parent POM {label}"}
+ return {"disclaimer": "FAIL", "disclaimer_detail": f"inherited description lacks the incubation disclaimer (from locally staged parent POM {label})"}
+
+ if chain_reason == "complete":
+ if len(chain) == 1:
+ return {"disclaimer": "FAIL", "disclaimer_detail": "<description> absent for a podling POM with no <parent> to inherit from"}
+ return {"disclaimer": "FAIL", "disclaimer_detail": "<description> absent and no parent POM in the staged chain declares it"}
+ if chain_reason == "cycle":
+ return {
+ "disclaimer": "FAIL",
+ "disclaimer_detail": f"<description> absent and the parent chain is cyclic ({_pom_label(pom)}); Maven refuses to build a cyclic parent chain",
+ }
+ return {
+ "disclaimer": "INHERITED-UNVERIFIED",
+ "disclaimer_detail": "<description> absent and the parent chain is not fully staged locally; verify against the effective POM",
+ }
+
+
+def _checksum_status(companion: Path, digest_file: Path, digest: str) -> str | None:
+ """Compare a checksum file's recorded digest with the companion's bytes.
+
+ Returns ``None`` when the recorded digest matches, an error message
+ when it does not, or ``"unverified"`` when ``digest`` names an
+ algorithm this Python's ``hashlib`` does not provide (the file is
+ still required, but its content cannot be checked offline). The hex
+ digest is extracted leniently, so every layout published in
+ practice is accepted: a bare digest, the GNU coreutils
+ ``<digest> <filename>``, the BSD/tagged ``ALGO (filename) =
+ <digest>`` (``shasum --tag``), and ``gpg --print-md``'s
+ ``filename: <DIGEST>`` — any case.
+ """
+ try:
+ hasher = hashlib.new(digest)
+ except ValueError:
+ return "unverified"
+ hasher.update(companion.read_bytes())
+ actual = hasher.hexdigest()
+ text = digest_file.read_text(encoding="utf-8", errors="replace")
+ hex_length = len(actual)
+ match = re.search(rf"(?<![0-9a-fA-F])[0-9a-fA-F]{{{hex_length}}}(?![0-9a-fA-F])", text)
+ recorded = match.group(0) if match is not None else None
+ if recorded is None:
+ # `gpg --print-md` splits the digest into space-separated groups
+ # and wraps it across lines, so there is no contiguous hex run;
+ # rejoin everything after the last ':' and compare that.
+ tail = re.sub(r"\s+", "", text.rsplit(":", 1)[-1])
+ if re.fullmatch(rf"[0-9a-fA-F]{{{hex_length}}}", tail):
+ recorded = tail
+ if recorded is None or recorded.lower() != actual:
+ return f"checksum mismatch: {digest_file.name} does not match {companion.name}"
+ return None
+
+
+def check_companions(jar: Path, digests: list[str], findings: list[str]) -> dict:
+ """Check 3 - companion jars exist, each signed and checksummed.
+
+ Checksum files are verified against the companion jar's actual
+ bytes (``hashlib``, still offline). ``.asc`` signatures are checked
+ for presence only: verifying a signature needs GPG and the release
+ key, which `release-verify-rc` Step 2 runs against the main
+ artefacts — the Step 6b recipe extends that verification to the
+ companions when it emits the paste-ready commands.
+ """
+ results: list[dict[str, str | None]] = []
+ for classifier in COMPANION_CLASSIFIERS:
+ companion = jar.with_name(jar.name[: -len(".jar")] + f"-{classifier}.jar")
+ if not companion.exists():
+ results.append(
+ {
+ "companion": companion.name,
+ "classification": "FAIL",
+ "detail": "missing companion jar (Maven Central requirement)",
+ }
+ )
+ continue
+ problems: list[str] = []
+ if not (jar.parent / f"{companion.name}.asc").exists():
+ problems.append(f"missing .asc signature file ({companion.name}.asc)")
+ for digest in digests:
+ digest_file = jar.parent / f"{companion.name}.{digest}"
+ if not digest_file.exists():
+ problems.append(f"missing .{digest} checksum file ({digest_file.name})")
+ continue
+ status = _checksum_status(companion, digest_file, digest)
+ if status == "unverified":
+ findings.append(f"{digest_file.name}: '{digest}' is not a digest algorithm this Python provides; checksum content not verified (presence only)")
+ elif status is not None:
+ problems.append(status)
+ if problems:
+ for problem in problems:
+ results.append(
+ {
+ "companion": companion.name,
+ "classification": "FAIL",
+ "detail": problem,
+ }
+ )
+ else:
+ results.append(
+ {
+ "companion": companion.name,
+ "classification": "PASS",
+ "detail": None,
+ }
+ )
+ return {"jar": jar.name, "companions": results}
+
+
+def split_jar_name(name: str) -> tuple[str, str | None, str | None]:
+ """Split ``<artifactId>-<version>[-<classifier>].jar`` into parts.
+
+ Companion classifiers are detected by suffix first: a version's
+ ``-[A-Za-z]+`` qualifier would otherwise greedily absorb
+ ``-sources`` / ``-javadoc`` and hide the classifier.
+ """
+ base = name[: -len(".jar")]
+ classifier: str | None = None
+ for known in COMPANION_CLASSIFIERS:
+ if base.endswith(f"-{known}"):
+ classifier = known
+ base = base[: -len(known) - 1]
+ break
+ m = VERSION_SPLIT_RE.match(base)
+ if m is None:
+ return base, None, classifier
+ return m.group("stem"), m.group("version"), classifier
+
+
+def verify_staged_dir(staged_dir: Path, digests: list[str], podling: bool) -> dict:
+ # rglob, not glob: a staging directory in Maven-repository layout
+ # (org/apache/foo/foo-core/1.0.0/...) is a JVM artefact set too — a
+ # top-level-only scan would misreport it as non-JVM and skip.
+ poms = sorted(staged_dir.rglob("*.pom"))
+ jars = sorted(staged_dir.rglob("*.jar"))
+
+ report: dict = {
+ "tool": "maven-artifact-verify",
+ "status": "SKIP",
+ "artefact_dir": str(staged_dir),
+ "podling": podling,
+ "digests": digests,
+ "poms": [],
+ "jars": [],
+ "unmatched_jars": [],
+ "findings": [],
+ }
+
+ if not poms and not jars:
+ report["findings"].append("no .pom or .jar files staged - non-JVM artefact set, JVM checks skip cleanly")
+ return report
+
+ parsed = {}
+ for pom_path in poms:
+ data = parse_pom(pom_path)
+ parsed[pom_path] = data
+ if "error" in data:
+ report["poms"].append({"pom": pom_path.name, "check1": "FAIL", "detail": data["error"]})
+
+ # Index the staged POMs by (groupId, artifactId, version) so a
+ # <parent> reference can be resolved locally when possible.
+ by_coordinate = {}
+ for data in parsed.values():
+ if "error" in data:
+ continue
+ by_coordinate[(data["group_id"], data["artifact_id"], data["version"])] = data
+
+ # --- checks 1 and 2: per POM ---
+ for pom_path, data in parsed.items():
+ if "error" in data:
+ continue
+ chain, chain_reason = _resolve_chain(data, by_coordinate)
+ check1 = check_pom_entries(data, chain, chain_reason)
+ entry = {"pom": pom_path.name, "packaging": data["packaging"], "check1": check1}
+ if podling:
+ entry["check2"] = check_disclaimer(data, chain, chain_reason)
+ report["poms"].append(entry)
+
+ # --- check 3: per main jar ---
+ main_jars = []
+ for pom_path, data in parsed.items():
+ if "error" in data or data["packaging"] == "pom":
+ continue
+ if data["artifact_id"] and data["version"]:
+ if MAVEN_COORDINATE_RE.fullmatch(data["artifact_id"]) is None or MAVEN_COORDINATE_RE.fullmatch(data["version"]) is None:
+ # The coordinates go straight into a path below; reject
+ # anything Maven itself would not accept (e.g. "../..")
+ # before touching the filesystem.
+ report["findings"].append(
+ f"{pom_path.name}: artifactId {data['artifact_id']!r} / version {data['version']!r} is not a valid Maven coordinate "
+ "([A-Za-z0-9_.-] only); jar and companion checks skipped"
+ )
+ continue
+ # The main jar lives in the POM's own directory (in Maven
+ # repository layout that is the versioned subdirectory, not
+ # the staged root).
+ main = pom_path.parent / f"{data['artifact_id']}-{data['version']}.jar"
+ if main.exists():
+ main_jars.append(main)
+ else:
+ # The jar is published via the Nexus staging repository
+ # in the common ASF workflow and is not staged locally
+ # in dist/dev at all. That is an observation, not a
+ # failure: companion coverage is only checkable for
+ # jars that ARE staged locally. release-verify-rc
+ # classifies an unexpected absence against
+ # release-build.md § JVM artefact checks.
+ report["jars"].append(
+ {
+ "jar": main.name,
+ "companions": [
+ {
+ "companion": main.name,
+ "classification": "ABSENT",
+ "detail": "main jar not staged locally; companion "
+ "checks skipped (verify via the Nexus "
+ "staging repository when it is the vote "
+ "target)",
+ }
+ ],
+ }
+ )
+ report["findings"].append(f"{main.name} is declared by a staged POM but not staged locally")
+
+ matched = set(main_jars)
+ for jar in jars:
+ if jar in matched:
+ continue
+ _stem, _version, classifier = split_jar_name(jar.name)
+ if classifier in COMPANION_CLASSIFIERS:
+ # A companion of some main jar; check 3 runs against the
+ # main jar (declared by its POM) and covers it there.
+ continue
+ # Not a POM-declared main jar and not a known companion
+ # classifier (-tests, -shaded, platform classifiers, ...).
+ # Report as unmatched: neither a main nor a companion, never a
+ # failure in either direction (issue #1173 boundary conditions).
+ report["unmatched_jars"].append(jar.name)
+
+ for main in main_jars:
+ report["jars"].append(check_companions(main, digests, report["findings"]))
+
+ # --- aggregate ---
+ statuses = []
+ for entry in report["poms"]:
+ for key in ("check1", "check2"):
+ check = entry.get(key)
+ if isinstance(check, str):
+ statuses.append(check)
+ elif isinstance(check, dict):
+ statuses.extend(v for k, v in check.items() if not k.endswith("_detail"))
+ for jar_entry in report["jars"]:
+ for comp in jar_entry["companions"]:
+ statuses.append(comp["classification"])
+
+ if not statuses:
+ if jars:
+ # Jars staged but no POM anywhere: checks 1-2 cannot run,
+ # and no main jar was POM-declared for check 3. Never a
+ # silent skip — surface what could not be verified.
+ report["status"] = "WARN"
+ report["findings"].append(
+ "jars staged but no .pom found: POM and companion checks could not run; verify against the Nexus staging repository (issue #1173, check 4)"
+ )
+ else:
+ report["status"] = "SKIP"
+ elif "FAIL" in statuses:
+ report["status"] = "FAIL"
+ elif "INHERITED-UNVERIFIED" in statuses:
+ report["status"] = "WARN"
+ else:
+ report["status"] = "PASS"
+ return report
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="maven-artifact-verify",
+ description="Verify locally staged JVM release-candidate artefacts "
+ "(POM licence set, podling disclaimer, companion jars). "
+ "Stdlib-only and offline; prints one JSON report.",
+ )
+ parser.add_argument("staged_dir", type=Path, help="directory holding the staged .pom / .jar artefacts")
+ parser.add_argument("--digests", default="sha512", help="comma-separated digest types companions must carry (default: sha512)")
+ parser.add_argument("--podling", action="store_true", help="project is incubating: check 2 requires the incubation disclaimer in every <description>")
+ args = parser.parse_args(argv)
+
+ if not args.staged_dir.is_dir():
+ print(
+ json.dumps(
+ {
+ "tool": "maven-artifact-verify",
+ "status": "FAIL",
+ "artefact_dir": str(args.staged_dir),
+ "findings": [f"staged directory does not exist: {args.staged_dir}"],
+ }
+ )
+ )
+ return 2
+
+ digests = [d.strip() for d in args.digests.split(",") if d.strip()]
+ report = verify_staged_dir(args.staged_dir, digests, args.podling)
+ print(json.dumps(report, indent=2))
+ return 0 if report["status"] != "FAIL" else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/maven-artifact-verify/src/maven_artifact_verify/__main__.py b/tools/maven-artifact-verify/src/maven_artifact_verify/__main__.py
new file mode 100644
index 00000000..18625e61
--- /dev/null
+++ b/tools/maven-artifact-verify/src/maven_artifact_verify/__main__.py
@@ -0,0 +1,23 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import sys
+
+from maven_artifact_verify import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py
new file mode 100644
index 00000000..13acd82c
--- /dev/null
+++ b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py
@@ -0,0 +1,740 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for maven-artifact-verify.
+
+Fixtures are synthetic POMs (string templates) and jars (zipfile
+output) built at test time, so the repository carries no binary test
+blobs.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import zipfile
+from pathlib import Path
+
+import maven_artifact_verify as mav
+
+DISCLAIMER = (
+ "Apache Foo is an effort undergoing incubation at The Apache Software "
+ "Foundation (ASF), sponsored by the Apache Incubator. Incubation is "
+ "required of all newly accepted projects until a further review "
+ "indicates that the infrastructure, communications, and decision making "
+ "process have stabilized in a manner consistent with other successful "
+ "ASF projects. While incubation status is not necessarily a reflection "
+ "of the completeness or stability of the code, it does indicate that "
+ "the project has yet to be fully endorsed by the ASF."
+)
+
+DISCLAIMER_WIP = DISCLAIMER + (
+ " Some of the incubating project's releases may not be fully compliant "
+ "with ASF policy. For example, releases may have incomplete or "
+ "un-reviewed licensing conditions."
+)
+
+
+def pom_xml(
+ artifact_id: str = "foo-core",
+ version: str = "1.0.0",
+ packaging: str | None = None,
+ licenses: str | None = None,
+ developers: str | None = None,
+ scm: str | None = None,
+ description: str | None = None,
+ parent: str | None = None,
+ group_id: str = "org.apache.foo",
+) -> str:
+ parts = [
+ '<?xml version="1.0" encoding="UTF-8"?>',
+ '<project xmlns="http://maven.apache.org/POM/4.0.0"'
+ + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
+ + ' xsi:schemaLocation="http://maven.apache.org/POM/4.0.0'
+ + ' http://maven.apache.org/xsd/maven-4.0.0.xsd">',
+ " <modelVersion>4.0.0</modelVersion>",
+ ]
+ if parent:
+ parts.append(parent)
+ parts.append(f" <groupId>{group_id}</groupId>")
+ parts.append(f" <artifactId>{artifact_id}</artifactId>")
+ if version:
+ parts.append(f" <version>{version}</version>")
+ if packaging:
+ parts.append(f" <packaging>{packaging}</packaging>")
+ if licenses:
+ parts.append(licenses)
+ if description:
+ parts.append(f" <description>{description}</description>")
+ if developers:
+ parts.append(developers)
+ if scm:
+ parts.append(scm)
+ parts.append("</project>")
+ return "\n".join(parts)
+
+
+APACHE_LICENSES = (
+ " <licenses>"
+ " <license>"
+ " <name>Apache License, Version 2.0</name>"
+ " <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>"
+ " </license>"
+ " </licenses>"
+)
+MIT_LICENSES = " <licenses> <license><name>MIT License</name><url>https://opensource.org/licenses/MIT</url></license> </licenses>"
+DEVELOPERS = (
+ " <developers>"
+ " <developer><name>Apache Foo developers</name>"
+ " <organization>Apache Software Foundation</organization>"
+ " </developer>"
+ " </developers>"
+)
+SCM = " <scm> <connection>scm:git:https://gitbox.apache.org/repos/asf/foo.git</connection> <url>https://github.com/apache/foo</url> </scm>"
+TAG_ONLY_SCM = " <scm> <tag>v1.0.0</tag> </scm>"
+EMPTY_SCM = " <scm/>"
+ASF_PARENT = " <parent> <groupId>org.apache</groupId> <artifactId>apache</artifactId> <version>33</version> </parent>"
+FOO_PARENT = " <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-parent</artifactId> <version>1.0.0</version> </parent>"
+
+
+def write_pom(directory: Path, name: str, xml: str) -> Path:
+ path = directory / name
+ path.write_text(xml, encoding="utf-8", newline="")
+ return path
+
+
+def write_jar(directory: Path, name: str, entry: str = "org/apache/foo/Main.class") -> Path:
+ path = directory / name
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr(entry, b"class-bytes-here")
+ return path
+
+
+def write_checksum(jar_path: Path, digest: str) -> Path:
+ """Write a checksum file holding the jar's real digest."""
+ hasher = hashlib.new(digest)
+ hasher.update(jar_path.read_bytes())
+ path = jar_path.with_name(f"{jar_path.name}.{digest}")
+ path.write_text(hasher.hexdigest() + "\n", encoding="utf-8")
+ return path
+
+
+def write_staged(
+ directory: Path,
+ artifact: str = "foo-core",
+ version: str = "1.0.0",
+ companions: bool = True,
+ companion_signatures: bool = True,
+ companion_digests: tuple[str, ...] = ("sha512",),
+) -> None:
+ """Write one main jar plus its signed, checksummed companions."""
+ stem = f"{artifact}-{version}"
+ write_jar(directory, f"{stem}.jar")
+ if companions:
+ for classifier in ("sources", "javadoc"):
+ companion = write_jar(directory, f"{stem}-{classifier}.jar")
+ if companion_signatures:
+ (directory / f"{stem}-{classifier}.jar.asc").write_bytes(b"sig")
+ for digest in companion_digests:
+ write_checksum(companion, digest)
+
+
+def mav_json(directory: Path, extra: tuple[str, ...]) -> str:
+ import contextlib
+ import io
+
+ buffer = io.StringIO()
+ with contextlib.redirect_stdout(buffer):
+ mav.main([str(directory), *extra])
+ return buffer.getvalue()
+
+
+# --- full good set -------------------------------------------------------
+
+
+def test_good_podling_set_passes(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description=DISCLAIMER))
+ write_staged(tmp_path)
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "PASS"
+ assert report["poms"][0]["check1"]["licenses"] == "PASS"
+ assert report["poms"][0]["check2"]["disclaimer"] == "PASS"
+ assert report["jars"] and all(c["classification"] == "PASS" for c in report["jars"][0]["companions"])
+
+
+def test_top_level_project_without_podling_flag_passes(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description=None))
+ write_staged(tmp_path)
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ assert "check2" not in report["poms"][0]
+
+
+# --- check 1: POM licence / developers / scm ------------------------------
+
+
+def test_non_alv2_license_fails(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=MIT_LICENSES, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ assert report["poms"][0]["check1"]["licenses"] == "FAIL"
+
+
+def test_licence_name_variants_without_url_pass(tmp_path: Path) -> None:
+ # The SPDX id and the legacy ASF wording identify ALv2 on their own;
+ # without this, <name>Apache-2.0</name> with no <url> failed.
+ for index, name in enumerate(("Apache-2.0", "The Apache Software License, Version 2.0")):
+ directory = tmp_path / str(index)
+ directory.mkdir()
+ licenses = f" <licenses> <license><name>{name}</name></license> </licenses>"
+ write_pom(directory, "foo-core-1.0.0.pom", pom_xml(licenses=licenses, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(directory, ()))
+ assert report["status"] == "PASS", name
+ assert report["poms"][0]["check1"]["licenses"] == "PASS", name
+
+
+def test_inherited_licence_resolved_from_staged_parent(tmp_path: Path) -> None:
+ write_pom(
+ tmp_path,
+ "apache-33.pom",
+ pom_xml(artifact_id="apache", version="33", packaging="pom", group_id="org.apache", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM),
+ )
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=ASF_PARENT, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check1["licenses"] == "PASS"
+ assert "inherited" in check1["licenses_detail"]
+
+
+def test_inherited_licence_without_staged_parent_warns(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=ASF_PARENT, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "WARN"
+ assert report["poms"][0]["check1"]["licenses"] == "INHERITED-UNVERIFIED"
+
+
+def test_staged_parent_with_non_alv2_licence_fails(tmp_path: Path) -> None:
+ # Regression: a staged parent used to report a blind PASS without its
+ # own <licenses> ever being read. A child of a staged parent that
+ # declares MIT must fail the blocking check.
+ write_pom(
+ tmp_path,
+ "foo-parent-1.0.0.pom",
+ pom_xml(artifact_id="foo-parent", packaging="pom", licenses=MIT_LICENSES, developers=DEVELOPERS, scm=SCM),
+ )
+ write_pom(
+ tmp_path,
+ "foo-core-1.0.0.pom",
+ pom_xml(
+ parent=" <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-parent</artifactId> <version>1.0.0</version> </parent>",
+ developers=DEVELOPERS,
+ scm=SCM,
+ ),
+ )
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check1["licenses"] == "FAIL"
+ assert "foo-parent" in check1["licenses_detail"]
+
+
+def test_staged_parent_without_scm_fails(tmp_path: Path) -> None:
+ # The staged parent declares no <scm> and has no parent of its own,
+ # so nothing can supply the element: a hard FAIL, not a warning.
+ write_pom(
+ tmp_path,
+ "foo-parent-1.0.0.pom",
+ pom_xml(artifact_id="foo-parent", packaging="pom", licenses=APACHE_LICENSES, developers=DEVELOPERS),
+ )
+ write_pom(
+ tmp_path,
+ "foo-core-1.0.0.pom",
+ pom_xml(
+ parent=" <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-parent</artifactId> <version>1.0.0</version> </parent>",
+ developers=DEVELOPERS,
+ ),
+ )
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check1["licenses"] == "PASS" # resolved through the parent
+ assert check1["scm"] == "FAIL"
+
+
+def test_parentless_pom_with_missing_licence_fails(tmp_path: Path) -> None:
+ # Regression: a POM with no <parent> at all used to get the
+ # INHERITED-UNVERIFIED warning, but nothing can be inherited —
+ # Maven Central rejects such a POM, so it is a hard FAIL.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ check1 = report["poms"][0]["check1"]
+ assert check1["licenses"] == "FAIL"
+ assert "no <parent>" in check1["licenses_detail"]
+
+
+def test_licence_resolved_from_staged_grandparent(tmp_path: Path) -> None:
+ # The ASF parent chain is two levels deep (project parent -> apache
+ # parent); an element may only be declared at the grandparent.
+ write_pom(
+ tmp_path,
+ "apache-33.pom",
+ pom_xml(artifact_id="apache", version="33", packaging="pom", group_id="org.apache", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM),
+ )
+ write_pom(
+ tmp_path,
+ "foo-parent-1.0.0.pom",
+ pom_xml(
+ artifact_id="foo-parent",
+ packaging="pom",
+ parent=ASF_PARENT,
+ developers=DEVELOPERS,
+ scm=SCM,
+ ),
+ )
+ write_pom(
+ tmp_path,
+ "foo-core-1.0.0.pom",
+ pom_xml(
+ parent=" <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-parent</artifactId> <version>1.0.0</version> </parent>",
+ developers=DEVELOPERS,
+ scm=SCM,
+ ),
+ )
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check1["licenses"] == "PASS"
+ assert "org.apache:apache:33" in check1["licenses_detail"]
+
+
+def test_parent_chain_cycle_fails_without_hanging(tmp_path: Path) -> None:
+ # Two staged POMs naming each other as parent: the walk must stop
+ # and never hang. A cyclic chain is a broken staged set (Maven
+ # refuses to build it), so the elements that cannot be resolved
+ # FAIL instead of warning.
+ foo_parent = " <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-b</artifactId> <version>1.0.0</version> </parent>"
+ bar_parent = " <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-a</artifactId> <version>1.0.0</version> </parent>"
+ write_pom(tmp_path, "foo-a-1.0.0.pom", pom_xml(artifact_id="foo-a", parent=foo_parent, developers=DEVELOPERS, scm=SCM))
+ write_pom(tmp_path, "foo-b-1.0.0.pom", pom_xml(artifact_id="foo-b", parent=bar_parent, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-a-1.0.0.pom")
+ assert check1["licenses"] == "FAIL"
+ assert "cyclic" in check1["licenses_detail"]
+
+
+def test_tag_only_scm_inherits_from_staged_parent(tmp_path: Path) -> None:
+ # Regression: Maven merges <scm> per field, so a child declaring
+ # only <tag> still inherits url/connection from its parent. Judging
+ # the child's <scm> in isolation used to make this a hard FAIL.
+ write_pom(
+ tmp_path,
+ "foo-parent-1.0.0.pom",
+ pom_xml(artifact_id="foo-parent", packaging="pom", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM),
+ )
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=FOO_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check1["scm"] == "PASS"
+ assert "inherited" in check1["scm_detail"]
+
+
+def test_tag_only_scm_with_unstaged_parent_warns(tmp_path: Path) -> None:
+ # An unstaged parent may carry url/connection, so the child's
+ # tag-only <scm> is INHERITED-UNVERIFIED, never a hard FAIL.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=ASF_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "WARN"
+ check1 = report["poms"][0]["check1"]
+ assert check1["scm"] == "INHERITED-UNVERIFIED"
+ assert "not fully staged" in check1["scm_detail"]
+
+
+def test_scm_without_url_anywhere_in_complete_chain_fails(tmp_path: Path) -> None:
+ # The staged parent declares a tag-only <scm> too and has no parent
+ # of its own: nothing in the chain supplies url or connection —
+ # a hard FAIL, the POM Maven Central would reject.
+ write_pom(
+ tmp_path,
+ "foo-parent-1.0.0.pom",
+ pom_xml(artifact_id="foo-parent", packaging="pom", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM),
+ )
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=FOO_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check1["scm"] == "FAIL"
+ assert "no parent POM in the staged chain" in check1["scm_detail"]
+
+
+def test_empty_child_scm_resolves_from_staged_grandparent(tmp_path: Path) -> None:
+ # Empty and tag-only declarations don't win: the per-field walk
+ # continues past them to the grandparent that declares url.
+ write_pom(
+ tmp_path,
+ "apache-33.pom",
+ pom_xml(artifact_id="apache", version="33", packaging="pom", group_id="org.apache", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM),
+ )
+ write_pom(tmp_path, "foo-parent-1.0.0.pom", pom_xml(artifact_id="foo-parent", packaging="pom", parent=ASF_PARENT, developers=DEVELOPERS, scm=TAG_ONLY_SCM))
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=FOO_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=EMPTY_SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check1["scm"] == "PASS"
+ assert "org.apache:apache:33" in check1["scm_detail"]
+
+
+def test_empty_scm_element_fails(tmp_path: Path) -> None:
+ # No <parent> at all, so the empty <scm> cannot inherit url or
+ # connection from anywhere: a hard FAIL.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=EMPTY_SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ assert report["poms"][0]["check1"]["scm"] == "FAIL"
+
+
+def test_malformed_pom_fails(tmp_path: Path) -> None:
+ (tmp_path / "broken-1.0.0.pom").write_text("<project><unclosed>", encoding="utf-8")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+
+
+# --- check 2: podling disclaimer ------------------------------------------
+
+
+def test_podling_without_description_fails(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "FAIL"
+ assert report["poms"][0]["check2"]["disclaimer"] == "FAIL"
+
+
+def test_podling_inherited_description_without_staged_parent_warns(tmp_path: Path) -> None:
+ # <description> may be inherited from the project parent POM; when
+ # that parent is not staged the effective POM cannot be resolved
+ # offline, so the result is a warning, never a failure.
+ write_pom(
+ tmp_path,
+ "foo-core-1.0.0.pom",
+ pom_xml(parent=ASF_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM),
+ )
+ write_staged(tmp_path)
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "WARN"
+ assert report["poms"][0]["check2"]["disclaimer"] == "INHERITED-UNVERIFIED"
+
+
+def test_podling_cycle_fails_disclaimer_without_hanging(tmp_path: Path) -> None:
+ # The same cycle rule under check 2: a cyclic chain is a broken
+ # staged set, so the unresolved <description> is a FAIL, not a
+ # warning.
+ foo_parent = " <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-b</artifactId> <version>1.0.0</version> </parent>"
+ bar_parent = " <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-a</artifactId> <version>1.0.0</version> </parent>"
+ write_pom(tmp_path, "foo-a-1.0.0.pom", pom_xml(artifact_id="foo-a", parent=foo_parent, developers=DEVELOPERS, scm=SCM))
+ write_pom(tmp_path, "foo-b-1.0.0.pom", pom_xml(artifact_id="foo-b", parent=bar_parent, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "FAIL"
+ check2 = next(e["check2"] for e in report["poms"] if e["pom"] == "foo-a-1.0.0.pom")
+ assert check2["disclaimer"] == "FAIL"
+ assert "cyclic" in check2["disclaimer_detail"]
+
+
+def test_podling_inherited_description_without_disclaimer_fails(tmp_path: Path) -> None:
+ # A resolvable parent whose description lacks the disclaimer stays
+ # a FAIL: the effective POM verifiably does not carry it.
+ write_pom(tmp_path, "foo-parent-1.0.0.pom", pom_xml(artifact_id="foo-parent", packaging="pom", description="Just a parent."))
+ write_pom(
+ tmp_path,
+ "foo-core-1.0.0.pom",
+ pom_xml(
+ parent=(" <parent> <groupId>org.apache.foo</groupId> <artifactId>foo-parent</artifactId> <version>1.0.0</version> </parent>"),
+ licenses=APACHE_LICENSES,
+ developers=DEVELOPERS,
+ scm=SCM,
+ ),
+ )
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "FAIL"
+ check2 = next(e["check2"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom")
+ assert check2["disclaimer"] == "FAIL"
+
+
+def test_podling_disclaimer_without_podling_flag_not_checked(tmp_path: Path) -> None:
+ write_pom(
+ tmp_path,
+ "foo-core-1.0.0.pom",
+ pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description="A plain description without the disclaimer."),
+ )
+ write_staged(tmp_path)
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+
+
+def test_whitespace_wrapped_disclaimer_passes(tmp_path: Path) -> None:
+ wrapped = DISCLAIMER.replace(". ", ".\n ")
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description=wrapped))
+ write_staged(tmp_path)
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "PASS"
+
+
+def test_wip_variant_passes(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description=DISCLAIMER_WIP))
+ write_staged(tmp_path)
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "PASS"
+
+
+def test_non_disclaimer_description_fails_for_podling(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description="Foo is a fast thing."))
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "FAIL"
+
+
+# --- check 3: companion jars ----------------------------------------------
+
+
+def test_missing_companion_fails(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path, companions=False)
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ classes = [c["classification"] for c in report["jars"][0]["companions"]]
+ assert classes.count("FAIL") == 2
+
+
+def test_companion_missing_signature_fails(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path, companion_signatures=False)
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ fails = [c for c in report["jars"][0]["companions"] if c["classification"] == "FAIL"]
+ assert len(fails) == 2 # one .asc missing per companion
+ assert all(".asc" in c["detail"] for c in fails)
+ # a companion with a missing file must not also carry a PASS record
+ # (regression: a loop-else used to append PASS unconditionally)
+ companions = {c["companion"] for c in report["jars"][0]["companions"]}
+ for name in companions:
+ classifications = [c["classification"] for c in report["jars"][0]["companions"] if c["companion"] == name]
+ assert classifications == ["FAIL"], f"{name}: {classifications}"
+
+
+def test_companion_missing_digest_fails(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha512").unlink()
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ fails = [c for c in report["jars"][0]["companions"] if c["classification"] == "FAIL"]
+ assert len(fails) == 1 and "sha512" in fails[0]["detail"]
+
+
+def test_sha256_digest_set(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path, companion_digests=("sha512", "sha256"))
+ report = json.loads(mav_json(tmp_path, ("--digests", "sha512,sha256")))
+ assert report["status"] == "PASS"
+
+
+def test_checksum_mismatch_fails(tmp_path: Path) -> None:
+ # Regression: the checksum file used to be an existence check only —
+ # any bytes passed. A recorded digest that does not match the
+ # companion jar's actual bytes must fail.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text("0" * 128 + "\n", encoding="utf-8")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ fails = [c for c in report["jars"][0]["companions"] if c["classification"] == "FAIL"]
+ assert len(fails) == 1 and "checksum mismatch" in fails[0]["detail"]
+
+
+def test_checksum_file_with_gnu_coreutils_line_passes(tmp_path: Path) -> None:
+ # The GNU coreutils layout "<digest> <filename>".
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ companion = tmp_path / "foo-core-1.0.0-sources.jar"
+ hasher = hashlib.sha512()
+ hasher.update(companion.read_bytes())
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(f"{hasher.hexdigest()} {companion.name}\n", encoding="utf-8")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+
+
+def test_checksum_file_with_bsd_tagged_line_passes(tmp_path: Path) -> None:
+ # The real BSD/tagged layout "ALGO (filename) = <digest>" as written
+ # by `shasum --tag` used to report a false checksum mismatch.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ companion = tmp_path / "foo-core-1.0.0-sources.jar"
+ hasher = hashlib.sha512()
+ hasher.update(companion.read_bytes())
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(f"SHA512 ({companion.name}) = {hasher.hexdigest()}\n", encoding="utf-8")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+
+
+def _gpg_print_md(name: str, hexdigest: str) -> str:
+ # The layout `gpg --print-md SHA512 <file>` really prints: upper-case
+ # groups of 8 hex characters separated by spaces, wrapped across lines
+ # with a hanging indent under the first group.
+ groups = [hexdigest.upper()[i : i + 8] for i in range(0, len(hexdigest), 8)]
+ indent = " " * (len(name) + 2)
+ lines = [" ".join(groups[i : i + 6]) for i in range(0, len(groups), 6)]
+ return f"{name}: " + ("\n" + indent).join(lines) + "\n"
+
+
+def test_checksum_file_with_gpg_print_md_layout_passes(tmp_path: Path) -> None:
+ # Some ASF projects still publish `gpg --print-md` output as the
+ # checksum file; a correct one must not be reported as a mismatch.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ companion = tmp_path / "foo-core-1.0.0-sources.jar"
+ hasher = hashlib.sha512()
+ hasher.update(companion.read_bytes())
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(_gpg_print_md(companion.name, hasher.hexdigest()), encoding="utf-8")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+
+
+def test_checksum_file_with_wrong_gpg_print_md_digest_fails(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(_gpg_print_md("foo-core-1.0.0-sources.jar", "ab" * 64), encoding="utf-8")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ assert any("checksum mismatch" in str(c.get("detail")) for e in report["jars"] for c in e["companions"])
+
+
+def test_checksum_file_with_wrong_digest_still_fails(tmp_path: Path) -> None:
+ # Lenient extraction must not start accepting any 128-hex-char
+ # looking string: a recorded digest that does not match the bytes
+ # stays a FAIL.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text("SHA512 (foo-core-1.0.0-sources.jar) = " + "0" * 128 + "\n", encoding="utf-8")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "FAIL"
+ assert any("checksum mismatch" in str(c.get("detail")) for e in report["jars"] for c in e["companions"])
+
+
+def test_unknown_digest_algorithm_is_presence_only(tmp_path: Path) -> None:
+ # An algorithm this Python's hashlib does not provide cannot be
+ # checked offline: the file is still required, but the tool must say
+ # plainly that it verified presence only, never a fake PASS.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ (tmp_path / "foo-core-1.0.0-sources.jar.sha999").write_bytes(b"hash")
+ (tmp_path / "foo-core-1.0.0-javadoc.jar.sha999").write_bytes(b"hash")
+ report = json.loads(mav_json(tmp_path, ("--digests", "sha999")))
+ assert report["status"] == "PASS"
+ assert any("not verified" in f for f in report["findings"])
+
+
+def test_maven_repository_layout_is_verified(tmp_path: Path) -> None:
+ # Regression: a top-level-only scan misreported a staging directory
+ # in Maven-repository layout as a non-JVM artefact set and skipped.
+ nested = tmp_path / "org" / "apache" / "foo" / "foo-core" / "1.0.0"
+ nested.mkdir(parents=True)
+ write_pom(nested, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(nested)
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ assert report["poms"][0]["pom"] == "foo-core-1.0.0.pom"
+ assert all(c["classification"] == "PASS" for c in report["jars"][0]["companions"])
+
+
+def test_main_jar_absent_is_an_observation_not_a_failure(tmp_path: Path) -> None:
+ # The common ASF workflow publishes jars via the Nexus staging
+ # repository and stages only the POM locally — absence of the main
+ # jar must not fail the local check set.
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ assert report["jars"][0]["companions"][0]["classification"] == "ABSENT"
+ assert any("not staged locally" in f for f in report["findings"])
+
+
+def test_invalid_coordinates_are_rejected_before_path_build(tmp_path: Path) -> None:
+ # artifactId/version go straight into a path when the main jar is
+ # located; a POM with <artifactId>../../evil</artifactId> must not
+ # make the tool stat or hash files outside the staging directory.
+ staged = tmp_path / "staged"
+ staged.mkdir()
+ outside = tmp_path / "evil-1.0.0.jar"
+ outside.write_bytes(b"outside the staging directory")
+ write_pom(staged, "foo-core-1.0.0.pom", pom_xml(artifact_id="../../evil", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(staged, ()))
+ assert report["status"] == "PASS" # checks 1-2 still ran; the jar checks are what is skipped
+ assert any("not a valid Maven coordinate" in f for f in report["findings"])
+ assert not any(entry["jar"].startswith("evil") for entry in report["jars"])
+ assert outside.read_bytes() == b"outside the staging directory"
+
+
+def test_pom_packaging_exempt_from_companions(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-parent-1.0.0.pom", pom_xml(artifact_id="foo-parent", packaging="pom", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ assert report["jars"] == []
+
+
+def test_classified_jar_is_unmatched_never_failed(tmp_path: Path) -> None:
+ write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM))
+ write_staged(tmp_path)
+ write_jar(tmp_path, "foo-core-1.0.0-tests.jar")
+ write_jar(tmp_path, "foo-core-1.0.0-linux-x86_64.jar")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "PASS"
+ assert set(report["unmatched_jars"]) == {"foo-core-1.0.0-tests.jar", "foo-core-1.0.0-linux-x86_64.jar"}
+
+
+def test_jars_without_poms_warn(tmp_path: Path) -> None:
+ # Jars present but no POM staged at all: the companions are skipped
+ # (no POM-declared main) and the main jar itself is unmatched — an
+ # observation, not a failure, but the report must say the POM
+ # checks could not run.
+ write_jar(tmp_path, "foo-core-1.0.0.jar")
+ write_jar(tmp_path, "foo-core-1.0.0-sources.jar")
+ report = json.loads(mav_json(tmp_path, ()))
+ assert report["status"] == "WARN"
+ assert "foo-core-1.0.0.jar" in report["unmatched_jars"]
+ assert any("no .pom" in f for f in report["findings"])
+
+
+# --- aggregation -----------------------------------------------------------
+
+
+def test_no_jvm_artefacts_skips(tmp_path: Path) -> None:
+ (tmp_path / "apache-foo-1.0.0-src.tar.gz").write_bytes(b"not a jar")
+ report = json.loads(mav_json(tmp_path, ("--podling",)))
+ assert report["status"] == "SKIP"
+
+
+def test_missing_directory_fails(tmp_path: Path) -> None:
+ import contextlib
+ import io
+
+ buffer = io.StringIO()
+ with contextlib.redirect_stdout(buffer):
+ code = mav.main([str(tmp_path / "nope")])
+ report = json.loads(buffer.getvalue())
+ assert code == 2
+ assert report["status"] == "FAIL"
diff --git a/tools/skill-evals/README.md b/tools/skill-evals/README.md
index 71cf0a02..2594702c 100644
--- a/tools/skill-evals/README.md
+++ b/tools/skill-evals/README.md
@@ -76,7 +76,7 @@ Suites are currently implemented for:
- **release-prepare** — 15 cases across 4 suites (step-0-preflight, step-1-plan, step-14-post, step-2-prep)
- **release-promote** — 9 cases across 2 suites (step-0-preflight, step-2-emit-commands)
- **release-rc-cut** — 14 cases across 4 suites (step-0-preflight, step-2-tag-build-sign, step-2b-reproducibility, step-3-staging)
-- **release-verify-rc** — 18 cases across 7 suites (step-0-preflight, step-2-verify-signatures, step-3-verify-checksums, step-5-notice-license, step-6-binary-exclusion, step-8-version-consistency, step-9-reproducibility)
+- **release-verify-rc** — 22 cases across 8 suites (step-0-preflight, step-2-verify-signatures, step-3-verify-checksums, step-5-notice-license, step-6-binary-exclusion, step-6b-jvm-artefacts, step-8-version-consistency, step-9-reproducibility)
- **release-vote-draft** — 9 cases across 3 suites (step-0-preflight, step-2-vote-draft, step-3-planning-comment)
- **release-vote-tally** — 9 cases across 3 suites (step-0-preflight, step-2-classify, step-3-tally)
- **reviewer-routing** — 7 cases across 2 suites (step-0-preflight, step-score-and-propose)
diff --git a/tools/skill-evals/evals/release-verify-rc/README.md b/tools/skill-evals/evals/release-verify-rc/README.md
index 083b27c7..7036912c 100644
--- a/tools/skill-evals/evals/release-verify-rc/README.md
+++ b/tools/skill-evals/evals/release-verify-rc/README.md
@@ -15,10 +15,11 @@ Behavioural eval suite for the
| `step-3-verify-checksums` | Step 3 — Verify checksums | 2 | Checksum classification (PASS/MISMATCH/MISSING-DIGEST), deprecated md5 detection |
| `step-5-notice-license` | Step 5 — NOTICE/LICENSE presence | 2 | File presence (PASS/WARN/FAIL), diff-lines count, diff summary |
| `step-6-binary-exclusion` | Step 6 — Binary exclusion check | 2 | Prohibited-binary detection (PASS/FAIL), expected-binary classification |
+| `step-6b-jvm-artefacts` | Step 6b — JVM artefact checks | 4 | Tool-report classification (PASS/WARN/FAIL), POM licence/`INHERITED-UNVERIFIED` handling, companion signature detection, `ABSENT` jar vs `jvm_companion_location` |
| `step-8-version-consistency` | Step 8 — Version string consistency | 2 | Exact version match across manifest files (PASS/FAIL) |
| `step-9-reproducibility` | Step 9 — Reproducibility checks | 5 | `repro-archive compare` verdict → status (`identical` PASS, `differs` FAIL, `content-identical` WARN in RM-key mode / FAIL under automated signing), `mandatory` under `automated_release_signing: enabled` with `--skip-repro` ignored, `trusted_hardware_asserted` mirrors the flag, a project-specific convenience artefact whose rebuild differs (source identical, artefact `FAIL`, named in `binaries.differs`) |
-Total: **18 cases** across 7 step suites.
+Total: **22 cases** across 8 step suites.
## Run
@@ -42,8 +43,9 @@ document the allowed schema.
The grader extracts JSON from the model's output and compares the
fields in `expected.json` exactly. Fields not present in `expected.json`
are ignored; fields present in `expected.json` must match exactly
-(including `null` vs omitted). `paste_recipe` fields are graded
-semantically (see each suite's `grading-schema.json`).
+(including `null` vs omitted), except prose fields declared in the step
+fixtures' `grading-schema.json` (e.g. `paste_recipe`, `pom_findings`),
+which are graded semantically.
`PASS` — all fields in `expected.json` match the model output.
`FAIL` — any field mismatch.
@@ -63,6 +65,13 @@ semantically (see each suite's `grading-schema.json`).
`"WARN"` for material diffs; `"PASS"` for version-string-only diffs.
- **Step 6**: `status` must be `"FAIL"` when `prohibited_found` is
non-empty.
+- **Step 6b**: a tool `FAIL` (wrong POM licence, missing companion or
+ companion signature) is always `"FAIL"`; `INHERITED-UNVERIFIED` is
+ `"WARN"` — a correct POM inheriting from the ASF parent must not be
+ failed; an `ABSENT` jar is a `"FAIL"` only when `release-build.md`
+ declares `jvm_companion_location: staged`, and an observation
+ otherwise; classified jars (`-tests`, `-shaded`, …) are never
+ flagged in either direction.
- **Step 8**: `status` must be `"FAIL"` for any `match: false` or
`extracted: null`; dev/snapshot suffixes are always `match: false`.
- **Step 9**: `differs` and `tag-moved` are always `"FAIL"`;
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/expected.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/expected.json
new file mode 100644
index 00000000..f10ac6a8
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/expected.json
@@ -0,0 +1,7 @@
+{
+ "step": "jvm-artefacts",
+ "status": "PASS",
+ "pom_findings": [],
+ "companion_findings": [],
+ "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512 --podling\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar\"\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar\""
+}
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/report.md
new file mode 100644
index 00000000..6d2d06ca
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/report.md
@@ -0,0 +1,39 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+release-build.md § Digest set: sha512. § JVM artefact checks:
+`jvm_companion_location: staged`.
+
+The unpacked source artefact ships a `DISCLAIMER` file at its root —
+the podling signal — so `--podling` is passed.
+
+maven-artifact-verify JSON report (verbatim):
+
+```json
+{
+ "tool": "maven-artifact-verify",
+ "status": "PASS",
+ "artefact_dir": "dist/dev/foo/1.0.0-rc1",
+ "podling": true,
+ "digests": ["sha512"],
+ "poms": [
+ {
+ "pom": "foo-core-1.0.0.pom",
+ "packaging": "jar",
+ "check1": {"licenses": "PASS", "developers": "PASS", "scm": "PASS"},
+ "check2": {"disclaimer": "PASS", "disclaimer_detail": null}
+ }
+ ],
+ "jars": [
+ {"jar": "foo-core-1.0.0.jar", "companions": [
+ {"companion": "foo-core-1.0.0-sources.jar", "classification": "PASS", "detail": null},
+ {"companion": "foo-core-1.0.0-javadoc.jar", "classification": "PASS", "detail": null}
+ ]}
+ ],
+ "unmatched_jars": ["foo-core-1.0.0-tests.jar"],
+ "findings": []
+}
+```
+
+The `-tests` jar is a classified jar: neither a main nor a companion,
+reported as an observation only.
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/expected.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/expected.json
new file mode 100644
index 00000000..6bf81d63
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/expected.json
@@ -0,0 +1,9 @@
+{
+ "step": "jvm-artefacts",
+ "status": "FAIL",
+ "pom_findings": [
+ "foo-core-1.0.0.pom: licences declared but none is ALv2 (expected name matching 'Apache License, Version 2.0' or url containing apache.org/licenses/LICENSE-2.0)"
+ ],
+ "companion_findings": [],
+ "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512 --podling\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar\"\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar\""
+}
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/report.md
new file mode 100644
index 00000000..31eb66c5
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/report.md
@@ -0,0 +1,43 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+release-build.md § Digest set: sha512. § JVM artefact checks:
+`jvm_companion_location: staged`. The source artefact ships a
+`DISCLAIMER` file, so `--podling` is passed.
+
+maven-artifact-verify JSON report (verbatim):
+
+```json
+{
+ "tool": "maven-artifact-verify",
+ "status": "FAIL",
+ "artefact_dir": "dist/dev/foo/1.0.0-rc1",
+ "podling": true,
+ "digests": ["sha512"],
+ "poms": [
+ {
+ "pom": "foo-core-1.0.0.pom",
+ "packaging": "jar",
+ "check1": {
+ "licenses": "FAIL",
+ "licenses_detail": "licences declared but none is ALv2 (expected name matching 'Apache License, Version 2.0' or url containing apache.org/licenses/LICENSE-2.0)",
+ "developers": "PASS",
+ "scm": "PASS"
+ },
+ "check2": {"disclaimer": "PASS", "disclaimer_detail": null}
+ }
+ ],
+ "jars": [
+ {"jar": "foo-core-1.0.0.jar", "companions": [
+ {"companion": "foo-core-1.0.0-sources.jar", "classification": "PASS", "detail": null},
+ {"companion": "foo-core-1.0.0-javadoc.jar", "classification": "PASS", "detail": null}
+ ]}
+ ],
+ "unmatched_jars": [],
+ "findings": []
+}
+```
+
+The POM declares an MIT licence. ASF Incubator distribution policy §
+Maven distribution requires the ALv2 licence in the POM — a hard FAIL;
+the RM must fix the POM and re-cut.
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/expected.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/expected.json
new file mode 100644
index 00000000..078886ce
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/expected.json
@@ -0,0 +1,9 @@
+{
+ "step": "jvm-artefacts",
+ "status": "FAIL",
+ "pom_findings": [],
+ "companion_findings": [
+ "foo-core-1.0.0-sources.jar: missing .asc signature file (foo-core-1.0.0-sources.jar.asc)"
+ ],
+ "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar\""
+}
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/report.md
new file mode 100644
index 00000000..15a080c2
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/report.md
@@ -0,0 +1,37 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+release-build.md § Digest set: sha512. § JVM artefact checks:
+`jvm_companion_location: staged`. No `DISCLAIMER` in the source
+artefact — a top-level project, so no `--podling`.
+
+maven-artifact-verify JSON report (verbatim):
+
+```json
+{
+ "tool": "maven-artifact-verify",
+ "status": "FAIL",
+ "artefact_dir": "dist/dev/foo/1.0.0-rc1",
+ "podling": false,
+ "digests": ["sha512"],
+ "poms": [
+ {
+ "pom": "foo-core-1.0.0.pom",
+ "packaging": "jar",
+ "check1": {"licenses": "PASS", "developers": "PASS", "scm": "PASS"}
+ }
+ ],
+ "jars": [
+ {"jar": "foo-core-1.0.0.jar", "companions": [
+ {"companion": "foo-core-1.0.0-sources.jar", "classification": "FAIL", "detail": "missing .asc signature file (foo-core-1.0.0-sources.jar.asc)"},
+ {"companion": "foo-core-1.0.0-javadoc.jar", "classification": "PASS", "detail": null}
+ ]}
+ ],
+ "unmatched_jars": [],
+ "findings": []
+}
+```
+
+Maven Central requires each companion jar to carry its own `.asc`
+signature; a staged companion without one is a hard FAIL. The RM
+re-signs and re-stages.
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/expected.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/expected.json
new file mode 100644
index 00000000..fd23a551
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/expected.json
@@ -0,0 +1,7 @@
+{
+ "step": "jvm-artefacts",
+ "status": "WARN",
+ "pom_findings": ["foo-core-1.0.0.pom: check1 developers INHERITED-UNVERIFIED — element absent and the parent chain is not fully staged locally; verify against the effective POM"],
+ "companion_findings": ["foo-core-1.0.0.jar is declared by a staged POM but not staged locally (ABSENT); jvm_companion_location: nexus-staging — an observation, not a failure"],
+ "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512"
+}
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/report.md
new file mode 100644
index 00000000..3bc10fd5
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/report.md
@@ -0,0 +1,49 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+release-build.md § Digest set: sha512. § JVM artefact checks:
+`jvm_companion_location: nexus-staging` — the jars publish through the
+Nexus staging repository; only the POM is staged in dist/dev. No
+`DISCLAIMER` in the source artefact, so no `--podling`.
+
+maven-artifact-verify JSON report (verbatim):
+
+```json
+{
+ "tool": "maven-artifact-verify",
+ "status": "WARN",
+ "artefact_dir": "dist/dev/foo/1.0.0-rc1",
+ "podling": false,
+ "digests": ["sha512"],
+ "poms": [
+ {
+ "pom": "foo-core-1.0.0.pom",
+ "packaging": "jar",
+ "check1": {
+ "licenses": "PASS",
+ "developers": "INHERITED-UNVERIFIED",
+ "developers_detail": "element absent and the parent chain is not fully staged locally; verify against the effective POM",
+ "scm": "PASS"
+ }
+ }
+ ],
+ "jars": [
+ {"jar": "foo-core-1.0.0.jar", "companions": [
+ {"companion": "foo-core-1.0.0.jar", "classification": "ABSENT", "detail": "main jar not staged locally; companion checks skipped (verify via the Nexus staging repository when it is the vote target)"}
+ ]}
+ ],
+ "unmatched_jars": [],
+ "findings": ["foo-core-1.0.0.jar is declared by a staged POM but not staged locally"]
+}
+```
+
+Two things to classify here:
+
+- `<developers>` is inherited from `org.apache:apache`, which is not
+ staged in dist/dev — `INHERITED-UNVERIFIED` is a WARN naming what to
+ verify (`mvn help:effective-pom`), never a FAIL: a correct POM that
+ inherits from the ASF parent must not be failed.
+- The main jar is `ABSENT` locally, but `release-build.md` declares
+ `jvm_companion_location: nexus-staging`, so the absence is the
+ expected shape: an observation, not a FAIL. Under
+ `jvm_companion_location: staged` the same report would be FAIL.
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/grading-schema.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/grading-schema.json
new file mode 100644
index 00000000..defd4577
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/grading-schema.json
@@ -0,0 +1,3 @@
+{
+ "prose_fields": ["paste_recipe", "tool_report", "pom_findings", "companion_findings"]
+}
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md
new file mode 100644
index 00000000..f3c3830b
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md
@@ -0,0 +1,38 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# Step 6b output specification
+
+The model must return ONLY valid JSON matching this schema:
+
+```json
+{
+ "step": "jvm-artefacts",
+ "status": "PASS" | "WARN" | "FAIL" | "SKIP",
+ "tool_report": "<the maven-artifact-verify JSON report verbatim>",
+ "pom_findings": ["<one line per POM finding>"],
+ "companion_findings": ["<one line per jar finding>"],
+ "paste_recipe": "<multi-line shell commands>"
+}
+```
+
+Grading rules:
+- `status` is the tool report's `status`, except that an `ABSENT` jar
+ becomes `"FAIL"` when `release-build.md § JVM artefact checks`
+ declares `jvm_companion_location: staged`.
+- A `FAIL` in the tool report (wrong POM licence, missing companion,
+ missing companion `.asc` / checksum) is a hard `"FAIL"` — never a
+ warning.
+- `INHERITED-UNVERIFIED` results (the parent chain is not fully staged
+ locally, so the element cannot be resolved offline) are `"WARN"`,
+ never `"FAIL"`: a correct POM that inherits from the ASF parent must
+ not be failed.
+- `pom_findings` / `companion_findings` name the exact failing artefact
+ and what is wrong; an empty list means no findings for that kind.
+- `paste_recipe` must be a non-empty string invoking
+ `maven-artifact-verify` on the staged directory, with `--digests`
+ set from `jvm_digest_set` when `release-build.md § JVM artefact
+ checks` sets it, otherwise from the § Digest set, and `--podling`
+ passed only when the source artefact ships a `DISCLAIMER` /
+ `DISCLAIMER-WIP`.
+- No extra keys are permitted in the response.
diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/step-config.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/step-config.json
new file mode 100644
index 00000000..1868351f
--- /dev/null
+++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/step-config.json
@@ -0,0 +1,4 @@
+{
+ "skill_md": "skills/release-verify-rc/SKILL.md",
+ "step_heading": "## Step 6b — JVM artefact checks (when the RC stages jars)"
+}
diff --git a/tools/spec-loop/specs/release-management-lifecycle.md b/tools/spec-loop/specs/release-management-lifecycle.md
index 93ba3be3..6f0f15ab 100644
--- a/tools/spec-loop/specs/release-management-lifecycle.md
+++ b/tools/spec-loop/specs/release-management-lifecycle.md
@@ -92,7 +92,10 @@ code lands.
`@apache.org` address reminder, Download Page link constraint, and
no-send / no-auto-merge boundaries; `release-verify-rc` (`mode: Triage`)
runs read-only RC pre-flight (signatures, checksums, RAT headers,
- NOTICE/LICENSE, prohibited binaries, version consistency, Step 6);
+ NOTICE/LICENSE, prohibited binaries, published-JVM-artefact
+ compliance via `tools/maven-artifact-verify` — POM licence set,
+ podling disclaimer, companion jars; version consistency,
+ Step 6);
`release-vote-draft` (`mode: Drafting`) drafts the `[VOTE]` email body
and planning-issue comment after a PASS pre-flight, never sending or
posting (Step 7); `release-vote-tally` (`mode: Triage`) classifies
diff --git a/uv.lock b/uv.lock
index 7da2b674..92b5270e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -36,6 +36,7 @@ members = [
"magpie-skills",
"magpie-sourcehut",
"magpie-vcs",
+ "maven-artifact-verify",
"oauth-draft",
"permission-audit",
"pilot-report-validator",
@@ -1053,6 +1054,21 @@ dev = [
[package.metadata.requires-dev]
dev = [{ name = "magpie-dev", editable = "tools/dev" }]
+[[package]]
+name = "maven-artifact-verify"
+version = "0.1.0"
+source = { editable = "tools/maven-artifact-verify" }
+
+[package.dev-dependencies]
+dev = [
+ { name = "magpie-dev" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [{ name = "magpie-dev", editable = "tools/dev" }]
+
[[package]]
name = "mcp"
version = "2.2.0"