From a5e80df9a2e59b174e5e6d93ecbad4f170ff9b3f Mon Sep 17 00:00:00 2001 From: liwenjie200543 <3133746534@qq.com> Date: Sun, 27 Sep 2026 10:49:14 +0800 Subject: [PATCH 01/13] 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, and . 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 , 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) --- docs/labels-and-capabilities.md | 3 +- docs/release-management/spec.md | 7 +- .../skills/verify-rc/SKILL.md | 97 +++- .../magpie-setup/templates/release-build.md | 28 ++ pyproject.toml | 1 + tools/maven-artifact-verify/README.md | 127 +++++ tools/maven-artifact-verify/pyproject.toml | 90 ++++ .../src/maven_artifact_verify/__init__.py | 448 ++++++++++++++++++ .../src/maven_artifact_verify/__main__.py | 23 + .../tests/test_maven_artifact_verify.py | 395 +++++++++++++++ .../evals/release-verify-rc/README.md | 10 +- .../expected.json | 7 + .../case-1-clean-podling-staged-set/report.md | 39 ++ .../case-2-wrong-pom-licence/expected.json | 7 + .../case-2-wrong-pom-licence/report.md | 43 ++ .../expected.json | 7 + .../report.md | 37 ++ .../expected.json | 7 + .../case-4-jar-absent-nexus-only/report.md | 49 ++ .../fixtures/grading-schema.json | 3 + .../fixtures/output-spec.md | 35 ++ .../fixtures/step-config.json | 4 + .../specs/release-management-lifecycle.md | 5 +- 23 files changed, 1463 insertions(+), 9 deletions(-) create mode 100644 tools/maven-artifact-verify/README.md create mode 100644 tools/maven-artifact-verify/pyproject.toml create mode 100644 tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py create mode 100644 tools/maven-artifact-verify/src/maven_artifact_verify/__main__.py create mode 100644 tools/maven-artifact-verify/tests/test_maven_artifact_verify.py create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/expected.json create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/report.md create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/expected.json create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/report.md create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/expected.json create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/report.md create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/expected.json create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/report.md create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/grading-schema.json create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md create mode 100644 tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/step-config.json diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index 6f5ff38c0..8e519c12b 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -146,7 +146,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. | @@ -362,6 +362,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 ``, 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 39c57310b..badbac5bd 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/plugins/magpie-release-management/skills/verify-rc/SKILL.md b/plugins/magpie-release-management/skills/verify-rc/SKILL.md index 0d113e7aa..396d4c4e4 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 ``. 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: "-rcN [--post-to ] [--skip-repro] [--trusted-hardware]" capability: capability:triage -surface_hash: sha256:42f7f872b6dcd0e8 +surface_hash: sha256:cc95c562f91c28ec license: Apache-2.0 -measured_tokens: 10725 +measured_tokens: 12127 --- + + + + +- [`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) + + + + + +# `maven-artifact-verify` + +**Capability:** substrate:release + +**Harness:** agnostic + +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 + `` block, plus `` and ``. An element + that is absent locally but supplied by a parent POM resolves + against the parent **when the parent POM is itself staged**; + otherwise it is `INHERITED-UNVERIFIED` — a warning that names what + to verify, never a failure of a correct POM. +2. **Incubator disclaimer in ``** — 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. +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`). + +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///): +uv run --project tools/maven-artifact-verify maven-artifact-verify \ + --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: ``, ``, `` and + `` are commonly inherited from the ASF parent POM + (`org.apache:apache`). Resolution is attempted against locally + staged parent POMs only — the tool is offline by design and never + fetches a parent from a remote repository — and 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 000000000..a80968b95 --- /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 --project . python -m ` — 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 000000000..ee6b78e26 --- /dev/null +++ b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py @@ -0,0 +1,448 @@ +# 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, ```` and ````. An element that is + absent locally but supplied by a parent POM is resolved against the + parent *when the parent POM is itself staged*; otherwise 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 ````** - 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 + a locally staged parent POM when possible: ``INHERITED-UNVERIFIED`` + (a warning) when the parent is not staged, a failure when the + effective description verifiably lacks the disclaimer. +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. + ``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 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", +] + +APACHE_LICENSE_NAME_RE = re.compile(r"apache\s+license.*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: +# -[-].jar, where the version starts +# with a digit and may carry a qualifier (rc1, alpha, SNAPSHOT, ...). +VERSION_SPLIT_RE = re.compile( + r"^(?P.+?)-(?P\d[\w.]*(?:-[A-Za-z]+)*)" + r"(?:-(?P[a-zA-Z][\w-]*))?$" +) + + +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 check_pom_entries(pom: dict, parent_pom: dict | None) -> dict: + """Check 1 - ALv2 licence, and in one POM.""" + entries: dict[str, str | None] = {} + for key in ("licenses", "developers", "scm"): + value = pom.get(key) + # lists (licenses/developers): present when non-empty; + # Element (scm): present when the element exists at all — an + # empty is a declaration that fails the url check. + present = bool(value) if isinstance(value, list) else value is not None + if present: + if key == "licenses": + ok = _has_apache_license(pom["licenses"]) + entries[key] = "PASS" if ok else "FAIL" + if not ok: + entries[key + "_detail"] = ( + "licences declared but none is ALv2 " + "(expected name matching 'Apache License, Version 2.0' " + "or url containing apache.org/licenses/LICENSE-2.0)" + ) + elif key == "developers": + entries[key] = "PASS" if pom["developers"] else "FAIL" + else: # scm + scm = pom["scm"] + has_url = _text(_find_child(scm, "url")) is not None or _text(_find_child(scm, "connection")) is not None + entries[key] = "PASS" if has_url else "FAIL" + continue + + # Element absent locally: resolve against the parent POM when it + # is staged, otherwise report inherited-unverified (WARN, never + # FAIL - a correct POM inheriting from the ASF parent must not + # fail this check; see issue #1173 boundary conditions). + if parent_pom is not None: + entries[key] = "PASS" + entries[key + "_detail"] = "inherited from locally staged parent POM" + else: + entries[key] = "INHERITED-UNVERIFIED" + entries[key + "_detail"] = "element absent and no locally staged parent POM to resolve against; verify against the effective POM" + return entries + + +def check_disclaimer(pom: dict, parent_pom: dict | None) -> dict: + """Check 2 - incubation disclaimer inside (podlings only).""" + description = pom.get("description") + if description is None and not pom.get("description_present") and parent_pom is not None: + # Element absent locally: resolve against a locally staged + # parent POM when possible. A resolvable parent whose + # description still lacks the disclaimer stays a FAIL. + description = parent_pom.get("description") + if description is not None: + if all(core in _normalize_ws(description) for core in DISCLAIMER_CORE): + return {"disclaimer": "PASS", "disclaimer_detail": "inherited from locally staged parent POM"} + return {"disclaimer": "FAIL", "disclaimer_detail": "inherited description lacks the incubation disclaimer"} + + if description is None: + if pom.get("description_present"): + # declared but empty. + return {"disclaimer": "FAIL", "disclaimer_detail": " empty for a podling POM"} + if pom.get("parent") is not None: + # Inherited from a parent POM that is not staged locally — + # the effective POM may well carry the disclaimer; do not + # fail a correct POM we cannot resolve offline. + return { + "disclaimer": "INHERITED-UNVERIFIED", + "disclaimer_detail": (" absent and no locally staged parent POM to resolve against; verify against the effective POM"), + } + return {"disclaimer": "FAIL", "disclaimer_detail": " absent for a podling POM"} + + normalized = _normalize_ws(description) + if all(core in normalized for core in DISCLAIMER_CORE): + return {"disclaimer": "PASS", "disclaimer_detail": None} + return { + "disclaimer": "FAIL", + "disclaimer_detail": (" does not carry the incubation disclaimer (neither the standard text nor the DISCLAIMER-WIP variant)"), + } + + +def check_companions(jar: Path, digests: list[str]) -> dict: + """Check 3 - companion jars exist, each signed and checksummed.""" + 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 + for suffix, what in ((f"{companion.name}.asc", ".asc signature"), *[(f"{companion.name}.{d}", f".{d} checksum") for d in digests]): + if not (jar.parent / suffix).exists(): + results.append( + { + "companion": companion.name, + "classification": "FAIL", + "detail": f"missing {what} file ({suffix})", + } + ) + 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 ``-[-].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: + poms = sorted(staged_dir.glob("*.pom")) + jars = sorted(staged_dir.glob("*.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 + # 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 + parent_pom = None + if data.get("parent"): + parent_pom = by_coordinate.get(tuple(data["parent"])) + check1 = check_pom_entries(data, parent_pom) + entry = {"pom": pom_path.name, "packaging": data["packaging"], "check1": check1} + if podling: + entry["check2"] = check_disclaimer(data, parent_pom) + report["poms"].append(entry) + + # --- check 3: per main jar --- + main_jars = [] + for data in parsed.values(): + if "error" in data or data["packaging"] == "pom": + continue + if data["artifact_id"] and data["version"]: + main = staged_dir / 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)) + + # --- 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 ") + 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 000000000..18625e618 --- /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 000000000..9882941d0 --- /dev/null +++ b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py @@ -0,0 +1,395 @@ +# 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 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 = [ + '', + '', + " 4.0.0", + ] + if parent: + parts.append(parent) + parts.append(f" {group_id}") + parts.append(f" {artifact_id}") + if version: + parts.append(f" {version}") + if packaging: + parts.append(f" {packaging}") + if licenses: + parts.append(licenses) + if description: + parts.append(f" {description}") + if developers: + parts.append(developers) + if scm: + parts.append(scm) + parts.append("") + return "\n".join(parts) + + +APACHE_LICENSES = ( + " " + " " + " Apache License, Version 2.0" + " https://www.apache.org/licenses/LICENSE-2.0.txt" + " " + " " +) +MIT_LICENSES = " MIT Licensehttps://opensource.org/licenses/MIT " +DEVELOPERS = ( + " " + " Apache Foo developers" + " Apache Software Foundation" + " " + " " +) +SCM = " scm:git:https://gitbox.apache.org/repos/asf/foo.git https://github.com/apache/foo " +EMPTY_SCM = " " +ASF_PARENT = " org.apache apache 33 " + + +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_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"): + 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: + (directory / f"{stem}-{classifier}.jar.{digest}").write_bytes(b"hash") + + +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_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_empty_scm_element_fails(tmp_path: Path) -> None: + 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("", 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: + # 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_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=(" org.apache.foo foo-parent 1.0.0 "), + 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) + + +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_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_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/evals/release-verify-rc/README.md b/tools/skill-evals/evals/release-verify-rc/README.md index 083b27c74..194c8905a 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 @@ -63,6 +64,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 000000000..0915c701b --- /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" +} 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 000000000..6d2d06ca9 --- /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 @@ + + +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 000000000..7e2c1e892 --- /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,7 @@ +{ + "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" +} 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 000000000..31eb66c5a --- /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 @@ + + +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 000000000..38c8f9f0f --- /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,7 @@ +{ + "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" +} 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 000000000..15a080c2e --- /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 @@ + + +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 000000000..6da961843 --- /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 no locally staged parent POM to resolve against; 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 000000000..c32b3371e --- /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 @@ + + +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 no locally staged parent POM to resolve against; 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: + +- `` 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 000000000..e1a14825c --- /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"] +} 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 000000000..5f3aa7341 --- /dev/null +++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md @@ -0,0 +1,35 @@ + + +# 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": "", + "pom_findings": [""], + "companion_findings": [""], + "paste_recipe": "" +} +``` + +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 (element inherited from a parent POM + that is not staged locally) 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 `release-build.md § 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 000000000..1868351fd --- /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 93ba3be35..f8f907925 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, Step 6b; 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 From 6e5da715424f212aac419b39619c4296d5ec3fea Mon Sep 17 00:00:00 2001 From: liwenjie200543 <3133746534@qq.com> Date: Sun, 27 Sep 2026 11:00:02 +0800 Subject: [PATCH 02/13] 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) --- uv.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/uv.lock b/uv.lock index 9f3e4359c..c61f93b5c 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,7 @@ members = [ "magpie-skills", "magpie-sourcehut", "magpie-vcs", + "maven-artifact-verify", "oauth-draft", "permission-audit", "pilot-report-validator", @@ -1036,6 +1037,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" From 6428c3d14cfc39612f11fca043d0a81223cea7d8 Mon Sep 17 00:00:00 2001 From: liwenjie200543 <3133746534@qq.com> Date: Sun, 27 Sep 2026 11:07:49 +0800 Subject: [PATCH 03/13] 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) --- .github/labeler.yml | 1 + docs/vendor-neutrality.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 47eda92a5..36dc6b3e9 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -174,6 +174,7 @@ substrate:release: - any: - changed-files: - any-glob-to-any-file: + - 'tools/maven-artifact-verify/**' - 'tools/reproducible-archive/**' substrate:review: diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index cf90995a2..00785d30c 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -607,6 +607,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 | @@ -637,7 +638,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-validator`, `symlink-lint`, `vendor-neutrality-score`, `vetted-ops` +- **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`, `spec-status-index`, `spec-validator`, `symlink-lint`, `vendor-neutrality-score`, `vetted-ops` **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. From 6f89fee01260d3d4c57c55dc0278afd233899de3 Mon Sep 17 00:00:00 2001 From: liwenjie200543 <3133746534@qq.com> Date: Sun, 27 Sep 2026 11:37:25 +0800 Subject: [PATCH 04/13] 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) --- docs/setup/marketplace.md | 2 +- tools/skill-evals/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/setup/marketplace.md b/docs/setup/marketplace.md index 8c078bdef..44895a471 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.7k | diff --git a/tools/skill-evals/README.md b/tools/skill-evals/README.md index 5b7fc035c..d9600cf7f 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) From ce8bb2ac891cdf2080f6352477f70478b6e3107e Mon Sep 17 00:00:00 2001 From: liwenjie200543 <3133746534@qq.com> Date: Sun, 27 Sep 2026 12:10:15 +0800 Subject: [PATCH 05/13] fix(maven-artifact-verify): drop the loop-else that double-reported companions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/maven_artifact_verify/__init__.py | 9 +++++++-- .../tests/test_maven_artifact_verify.py | 12 +++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py index ee6b78e26..07ee2d3ee 100644 --- a/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py +++ b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py @@ -240,8 +240,13 @@ def check_companions(jar: Path, digests: list[str]) -> dict: } ) continue - for suffix, what in ((f"{companion.name}.asc", ".asc signature"), *[(f"{companion.name}.{d}", f".{d} checksum") for d in digests]): - if not (jar.parent / suffix).exists(): + missing = [ + (suffix, what) + for suffix, what in ((f"{companion.name}.asc", ".asc signature"), *[(f"{companion.name}.{d}", f".{d} checksum") for d in digests]) + if not (jar.parent / suffix).exists() + ] + if missing: + for suffix, what in missing: results.append( { "companion": companion.name, diff --git a/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py index 9882941d0..d5e9c6799 100644 --- a/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py +++ b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py @@ -62,9 +62,9 @@ def pom_xml( parts = [ '', '', + + ' 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">', " 4.0.0", ] if parent: @@ -314,6 +314,12 @@ def test_companion_missing_signature_fails(tmp_path: Path) -> None: 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: From e996558503d2b072c247973f3d17a4ed4326d2e5 Mon Sep 17 00:00:00 2001 From: liwenjie200543 <3133746534@qq.com> Date: Mon, 28 Sep 2026 01:17:00 +0800 Subject: [PATCH 06/13] fix(maven-artifact-verify): resolve inherited POM elements against the staged parent chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- tools/maven-artifact-verify/README.md | 39 ++- .../src/maven_artifact_verify/__init__.py | 312 +++++++++++++----- .../tests/test_maven_artifact_verify.py | 175 +++++++++- 3 files changed, 421 insertions(+), 105 deletions(-) diff --git a/tools/maven-artifact-verify/README.md b/tools/maven-artifact-verify/README.md index 56aced437..8fa441ac8 100644 --- a/tools/maven-artifact-verify/README.md +++ b/tools/maven-artifact-verify/README.md @@ -14,15 +14,14 @@ - - # `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 @@ -45,20 +44,32 @@ blocking. 1. **POM licence entry** — every staged `.pom` declares ALv2 in its `` block, plus `` and ``. An element - that is absent locally but supplied by a parent POM resolves - against the parent **when the parent POM is itself staged**; - otherwise it is `INHERITED-UNVERIFIED` — a warning that names what - to verify, never a failure of a correct POM. + 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. An element no staged ancestor declares when the chain + ends at a POM with no `` — including a POM with no + `` 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. 2. **Incubator disclaimer in ``** — 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. + 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` @@ -111,10 +122,14 @@ not fail correct releases: companions; only POM-declared main jars do. - Inherited POM elements: ``, ``, `` and `` are commonly inherited from the ASF parent POM - (`org.apache:apache`). Resolution is attempted against locally - staged parent POMs only — the tool is offline by design and never - fetches a parent from a remote repository — and falls back to - `INHERITED-UNVERIFIED` rather than guessing. + (`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 `` 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 diff --git a/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py index 07ee2d3ee..c504be843 100644 --- a/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py +++ b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py @@ -21,21 +21,31 @@ (`release-verify-rc: validate rc jars`), local artefacts only: 1. **POM licence entry** - every staged ``.pom`` must declare the - ALv2 licence, ```` and ````. An element that is - absent locally but supplied by a parent POM is resolved against the - parent *when the parent POM is itself staged*; otherwise the result - is ``inherited-unverified`` (a warning, never a failure - the - correct POM must not be failed, per the issue's boundary conditions). + ALv2 licence, ```` and ````. 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 ```` + 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 ````** - 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 - a locally staged parent POM when possible: ``INHERITED-UNVERIFIED`` - (a warning) when the parent is not staged, a failure when the - effective description verifiably lacks the disclaimer. + 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. + ``-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. @@ -50,6 +60,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import sys @@ -150,84 +161,191 @@ def _has_apache_license(licenses: list[ET.Element]) -> bool: return False -def check_pom_entries(pom: dict, parent_pom: dict | None) -> dict: - """Check 1 - ALv2 licence, and in one POM.""" +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; + # Element (scm): present when the element exists at all — an + # empty is a declaration that fails the url check. + 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 key == "developers": + if pom["developers"]: + return True, None + return False, " declared but empty" + scm = pom["scm"] + if _text(_find_child(scm, "url")) is not None or _text(_find_child(scm, "connection")) is not None: + return True, None + return False, " declared without url or connection" + + +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 ```` + (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, and in one POM. + + An element absent from the POM itself is resolved against the + locally staged parent chain: the first ancestor that declares the + element is judged as-is, so a staged parent carrying a non-ALv2 + licence (or an empty ````) fails the child too. When the + chain is complete and no ancestor declares the element — including + a POM with no ```` at all, where nothing can be inherited — + the result is a hard FAIL; Maven Central rejects such a POM. When + the chain cannot be fully resolved offline (unstaged parent, or a + cycle), the result is ``INHERITED-UNVERIFIED`` — a warning, never a + failure of a correct POM inheriting from the ASF parent (see issue + #1173 boundary conditions). + """ entries: dict[str, str | None] = {} for key in ("licenses", "developers", "scm"): - value = pom.get(key) - # lists (licenses/developers): present when non-empty; - # Element (scm): present when the element exists at all — an - # empty is a declaration that fails the url check. - present = bool(value) if isinstance(value, list) else value is not None - if present: - if key == "licenses": - ok = _has_apache_license(pom["licenses"]) - entries[key] = "PASS" if ok else "FAIL" - if not ok: - entries[key + "_detail"] = ( - "licences declared but none is ALv2 " - "(expected name matching 'Apache License, Version 2.0' " - "or url containing apache.org/licenses/LICENSE-2.0)" - ) - elif key == "developers": - entries[key] = "PASS" if pom["developers"] else "FAIL" - else: # scm - scm = pom["scm"] - has_url = _text(_find_child(scm, "url")) is not None or _text(_find_child(scm, "connection")) is not None - entries[key] = "PASS" if has_url else "FAIL" + 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 - # Element absent locally: resolve against the parent POM when it - # is staged, otherwise report inherited-unverified (WARN, never - # FAIL - a correct POM inheriting from the ASF parent must not - # fail this check; see issue #1173 boundary conditions). - if parent_pom is not None: - entries[key] = "PASS" - entries[key + "_detail"] = "inherited from locally staged parent POM" + 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 : nothing to inherit from" + else: + entries[key + "_detail"] = "element absent and no parent POM in the staged chain declares it" else: + detail = "element absent and the parent chain is not fully staged locally; verify against the effective POM" + if chain_reason == "cycle": + detail = f"element absent and the parent chain is cyclic ({_pom_label(pom)}); verify against the effective POM" entries[key] = "INHERITED-UNVERIFIED" - entries[key + "_detail"] = "element absent and no locally staged parent POM to resolve against; verify against the effective POM" + entries[key + "_detail"] = detail return entries -def check_disclaimer(pom: dict, parent_pom: dict | None) -> dict: - """Check 2 - incubation disclaimer inside (podlings only).""" - description = pom.get("description") - if description is None and not pom.get("description_present") and parent_pom is not None: - # Element absent locally: resolve against a locally staged - # parent POM when possible. A resolvable parent whose - # description still lacks the disclaimer stays a FAIL. - description = parent_pom.get("description") - if description is not None: - if all(core in _normalize_ws(description) for core in DISCLAIMER_CORE): - return {"disclaimer": "PASS", "disclaimer_detail": "inherited from locally staged parent POM"} - return {"disclaimer": "FAIL", "disclaimer_detail": "inherited description lacks the incubation disclaimer"} - - if description is None: - if pom.get("description_present"): +def check_disclaimer(pom: dict, chain: list[dict], chain_reason: str) -> dict: + """Check 2 - incubation disclaimer inside (podlings only). + + Resolution mirrors check 1: a ```` 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, and an unresolvable + chain reports ``INHERITED-UNVERIFIED``. + """ + if pom.get("description_present"): + description = pom.get("description") + if description is None: # declared but empty. return {"disclaimer": "FAIL", "disclaimer_detail": " empty for a podling POM"} - if pom.get("parent") is not None: - # Inherited from a parent POM that is not staged locally — - # the effective POM may well carry the disclaimer; do not - # fail a correct POM we cannot resolve offline. - return { - "disclaimer": "INHERITED-UNVERIFIED", - "disclaimer_detail": (" absent and no locally staged parent POM to resolve against; verify against the effective POM"), - } - return {"disclaimer": "FAIL", "disclaimer_detail": " absent for a podling POM"} - - normalized = _normalize_ws(description) - if all(core in normalized for core in DISCLAIMER_CORE): - return {"disclaimer": "PASS", "disclaimer_detail": None} + if all(core in _normalize_ws(description) for core in DISCLAIMER_CORE): + return {"disclaimer": "PASS", "disclaimer_detail": None} + return { + "disclaimer": "FAIL", + "disclaimer_detail": (" 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 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": " absent for a podling POM with no to inherit from"} + return {"disclaimer": "FAIL", "disclaimer_detail": " absent and no parent POM in the staged chain declares it"} return { - "disclaimer": "FAIL", - "disclaimer_detail": (" does not carry the incubation disclaimer (neither the standard text nor the DISCLAIMER-WIP variant)"), + "disclaimer": "INHERITED-UNVERIFIED", + "disclaimer_detail": " absent and the parent chain is not fully staged locally; verify against the effective POM", } -def check_companions(jar: Path, digests: list[str]) -> dict: - """Check 3 - companion jars exist, each signed and checksummed.""" +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 + file is parsed leniently: the first whitespace-separated token is + the recorded digest, tolerating a trailing newline or a BSD-style + `` `` line. + """ + try: + hasher = hashlib.new(digest) + except ValueError: + return "unverified" + hasher.update(companion.read_bytes()) + actual = hasher.hexdigest() + recorded = digest_file.read_text(encoding="utf-8", errors="replace").split() + if not recorded or recorded[0].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") @@ -240,18 +358,26 @@ def check_companions(jar: Path, digests: list[str]) -> dict: } ) continue - missing = [ - (suffix, what) - for suffix, what in ((f"{companion.name}.asc", ".asc signature"), *[(f"{companion.name}.{d}", f".{d} checksum") for d in digests]) - if not (jar.parent / suffix).exists() - ] - if missing: - for suffix, what in missing: + 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": f"missing {what} file ({suffix})", + "detail": problem, } ) else: @@ -286,8 +412,11 @@ def split_jar_name(name: str) -> tuple[str, str | None, str | None]: def verify_staged_dir(staged_dir: Path, digests: list[str], podling: bool) -> dict: - poms = sorted(staged_dir.glob("*.pom")) - jars = sorted(staged_dir.glob("*.jar")) + # 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", @@ -324,22 +453,23 @@ def verify_staged_dir(staged_dir: Path, digests: list[str], podling: bool) -> di for pom_path, data in parsed.items(): if "error" in data: continue - parent_pom = None - if data.get("parent"): - parent_pom = by_coordinate.get(tuple(data["parent"])) - check1 = check_pom_entries(data, parent_pom) + 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, parent_pom) + entry["check2"] = check_disclaimer(data, chain, chain_reason) report["poms"].append(entry) # --- check 3: per main jar --- main_jars = [] - for data in parsed.values(): + for pom_path, data in parsed.items(): if "error" in data or data["packaging"] == "pom": continue if data["artifact_id"] and data["version"]: - main = staged_dir / f"{data['artifact_id']}-{data['version']}.jar" + # 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: @@ -383,7 +513,7 @@ def verify_staged_dir(staged_dir: Path, digests: list[str], podling: bool) -> di report["unmatched_jars"].append(jar.name) for main in main_jars: - report["jars"].append(check_companions(main, digests)) + report["jars"].append(check_companions(main, digests, report["findings"])) # --- aggregate --- statuses = [] diff --git a/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py index d5e9c6799..3a1dfb5fa 100644 --- a/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py +++ b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py @@ -24,6 +24,7 @@ from __future__ import annotations +import hashlib import json import zipfile from pathlib import Path @@ -121,6 +122,15 @@ def write_jar(directory: Path, name: str, entry: str = "org/apache/foo/Main.clas 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", @@ -134,11 +144,11 @@ def write_staged( write_jar(directory, f"{stem}.jar") if companions: for classifier in ("sources", "javadoc"): - write_jar(directory, f"{stem}-{classifier}.jar") + 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: - (directory / f"{stem}-{classifier}.jar.{digest}").write_bytes(b"hash") + write_checksum(companion, digest) def mav_json(directory: Path, extra: tuple[str, ...]) -> str: @@ -203,6 +213,115 @@ def test_inherited_licence_without_staged_parent_warns(tmp_path: Path) -> None: 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 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=" org.apache.foo foo-parent 1.0.0 ", + 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 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=" org.apache.foo foo-parent 1.0.0 ", + 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 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 " 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=" org.apache.foo foo-parent 1.0.0 ", + 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_warns_without_hanging(tmp_path: Path) -> None: + # Two staged POMs naming each other as parent: the walk must stop, + # report the cycle, and never fail a POM it could not resolve. + foo_parent = " org.apache.foo foo-b 1.0.0 " + bar_parent = " org.apache.foo foo-a 1.0.0 " + 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"] == "WARN" + check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-a-1.0.0.pom") + assert check1["licenses"] == "INHERITED-UNVERIFIED" + assert "cyclic" in check1["licenses_detail"] + + def test_empty_scm_element_fails(tmp_path: Path) -> None: 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, ())) @@ -339,6 +458,58 @@ def test_sha256_digest_set(tmp_path: Path) -> None: 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_bsd_style_line_passes(tmp_path: Path) -> None: + # Some tooling writes " "; the recorded digest is + # the first whitespace-separated token. + 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_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 From 2ae777f5ee5c8241fd70a84efec391a69c3d67f6 Mon Sep 17 00:00:00 2001 From: liwenjie200543 <3133746534@qq.com> Date: Mon, 28 Sep 2026 01:17:13 +0800 Subject: [PATCH 07/13] 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) --- .../skills/verify-rc/SKILL.md | 57 ++++++++++++------- .../magpie-setup/templates/release-build.md | 5 +- .../expected.json | 2 +- .../case-4-jar-absent-nexus-only/report.md | 2 +- .../fixtures/output-spec.md | 13 +++-- .../specs/release-management-lifecycle.md | 2 +- 6 files changed, 51 insertions(+), 30 deletions(-) diff --git a/plugins/magpie-release-management/skills/verify-rc/SKILL.md b/plugins/magpie-release-management/skills/verify-rc/SKILL.md index 396d4c4e4..ecfd910f3 100644 --- a/plugins/magpie-release-management/skills/verify-rc/SKILL.md +++ b/plugins/magpie-release-management/skills/verify-rc/SKILL.md @@ -37,7 +37,7 @@ argument-hint: "-rcN [--post-to ] [--skip-repro] [- capability: capability:triage surface_hash: sha256:cc95c562f91c28ec license: Apache-2.0 -measured_tokens: 12127 +measured_tokens: 12460 ---