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/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/docs/setup/marketplace.md b/docs/setup/marketplace.md index dea721304..17caa7587 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/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index cf90995a2..38228c0f9 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -595,7 +595,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 16, agnostic = 62. **LLM / agent-integration neutrality** -**Agent harness: 29/29 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness. +**Agent harness: 30/30 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness. | Substrate tool | Substrate | Harness support | Verdict | |---|---|---|---| @@ -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. diff --git a/plugins/magpie-release-management/skills/verify-rc/SKILL.md b/plugins/magpie-release-management/skills/verify-rc/SKILL.md index 0d113e7aa..6ea723283 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:ed944a58facaae21 license: Apache-2.0 -measured_tokens: 10725 +measured_tokens: 12502 --- + + + + +- [`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 + +**Organization:** ASF + +Verifies **locally staged JVM release-candidate artefacts** — the +`.pom` files, the main jars and their companion `-sources.jar` / +`-javadoc.jar` — the way `release-verify-rc` verifies a staged source +artefact today. Implements the blocking checks 1–3 proposed in +[apache/magpie#1173](https://github.com/apache/magpie/issues/1173); +the Nexus staging-repository check (check 4) and the informational +checks (5–7) are later PRs on that issue. + +Until this tool exists, `release-verify-rc` handles a jar in exactly +one direction: as *contraband inside the source tree* (Step 6's +prohibited-binary baseline). The published jar — the artefact Maven +Central actually serves to users — is never opened or validated. This +tool closes that gap for the checks that [ASF Incubator distribution +policy](https://incubator.apache.org/guides/distribution.html) and +[Maven Central's publishing +requirements](https://central.sonatype.org/publish/requirements/) make +blocking. + +## The checks it implements + +1. **POM licence entry** — every staged `.pom` declares ALv2 in its + `` block, plus `` and ``. An element + absent from the POM itself resolves against the chain of locally + staged parent POMs (with cycle protection, up through + grandparents): the first ancestor declaring the element is judged + as-is, so a staged parent carrying a non-ALv2 licence fails the + child too. `` resolves per field, the way Maven merges it: a + child declaring only `` still inherits `url` / + `connection` from the nearest ancestor declaring them, and an + empty or tag-only declaration never fails the child on its own. An + element no staged ancestor declares when the chain + ends at a POM with no `` — 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. A cyclic parent chain is a + `FAIL` instead: Maven refuses to build one, so it cannot be a + correct POM inheriting from the ASF parent. +2. **Incubator disclaimer in ``** — podlings only + (`--podling`). Accepts the standard disclaimer text and the + `DISCLAIMER-WIP` variant, tolerating whitespace and line-wrapping + differences inside the XML element. Matching is deliberately keyed + to the core both texts share ("is an effort undergoing incubation + at The Apache Software Foundation … has yet to be fully endorsed by + the ASF") rather than to one full verbatim text. An inherited + description is judged the same way as a local one. +3. **Companion jars** — for every main jar, both `-sources.jar` and + `-javadoc.jar` exist and each carries its own `.asc` signature and + checksum files (the digest set is configurable, default `sha512`). + Checksum files are verified against the companion jar's actual + bytes (`hashlib`, still offline); `.asc` signatures are checked + for presence only — offline signature verification needs GPG and + the release key, which `release-verify-rc` Step 2 runs against the + main artefacts, and the Step 6b recipe extends to the companions. + +The overall `status` is `FAIL` when any check fails, `WARN` when only +`INHERITED-UNVERIFIED` results remain, `PASS` otherwise, and `SKIP` +when the staged set contains no `.pom` and no `.jar` at all — a +non-JVM project's RC runs the tool and skips cleanly. + +## Prerequisites + +- **Runtime** — Python 3.11+ (stdlib only; can run as + `python3 -m maven_artifact_verify` or via `uv run --project`). +- **CLIs** — None beyond the runtime. +- **Credentials / auth** — None. +- **Network** — fully offline; reads local files only. + +## How to use + +```bash +# From a staged RC directory (e.g. dist/dev///): +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 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 + +```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..f19ff219e --- /dev/null +++ b/tools/maven-artifact-verify/src/maven_artifact_verify/__init__.py @@ -0,0 +1,676 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Verify locally staged JVM release-candidate artefacts. + +Implements the blocking checks 1-3 of apache/magpie issue #1173 +(`release-verify-rc: validate rc jars`), local artefacts only: + +1. **POM licence entry** - every staged ``.pom`` must declare the + ALv2 licence, ```` 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 + the locally staged parent chain the same way: a failure when the + effective description verifiably lacks the disclaimer, a failure + when nothing could supply it, ``INHERITED-UNVERIFIED`` (a + warning) when the chain is not fully staged. +3. **Companion jars** - for every staged main jar, the + ``-sources.jar`` and ``-javadoc.jar`` companions must exist and + each must carry its own ``.asc`` signature and checksum files. + Checksum files are verified against the companion jar's actual + bytes (``hashlib``, still offline); ``.asc`` signatures are + presence-only here - verifying a signature needs GPG and the + release key, which `release-verify-rc` Step 2 does. + ``packaging=pom`` modules are exempt (no jar), classified jars + (``-tests``, ``-shaded``, ...) are neither mains nor companions. + +The tool is stdlib-only and fully offline: it reads the staged +directory, never the network. Nexus staging-repository checks are out +of scope here (issue #1173, PR 2). + +Output is a single JSON document on stdout, in the shape +`release-verify-rc` Step 6b consumes. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +MAVEN_NS = "http://maven.apache.org/POM/4.0.0" + +# The distinctive core shared by the standard incubation disclaimer and +# the DISCLAIMER-WIP variant (https://incubator.apache.org/policy/incubation.html#disclaimers). +# Matching this core (case-insensitively, whitespace-normalised) accepts +# either text and tolerates wrapping inside the XML element. +DISCLAIMER_CORE = [ + "is an effort undergoing incubation at the apache software foundation", + "has yet to be fully endorsed by the asf", +] + +# The licence may carry the canonical ASF wording, the legacy +# "The Apache Software License, Version 2.0" phrasing, or the SPDX id +# "Apache-2.0" — all three identify ALv2. +APACHE_LICENSE_NAME_RE = re.compile(r"apache(?:\s+software)?\s+license.*2\.0|apache\s*-\s*2\.0", re.IGNORECASE) +APACHE_LICENSE_URL_RE = re.compile(r"apache\.org/licenses/LICENSE-2\.0", re.IGNORECASE) + +# Classifiers that mark a jar as a companion of another artefact rather +# than a main artefact. Anything else (-tests, -shaded, -linux-x86_64, +# ...) is neither a main nor a companion: it is reported as unmatched. +COMPANION_CLASSIFIERS = ("sources", "javadoc") + +# A Maven version segment as it appears in filenames: +# -[-].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-]*))?$" +) + +# Maven coordinates as they may appear in /: the +# characters Central accepts. Coordinates go straight into paths when +# the main jar is located, so anything else (e.g. "../..") is rejected +# before the filesystem is touched. +MAVEN_COORDINATE_RE = re.compile(r"[A-Za-z0-9_.-]+") + + +def _local(tag: str) -> str: + """Strip the Maven XML namespace from an ElementTree tag.""" + return tag.rsplit("}", 1)[-1] + + +def _find_child(elem: ET.Element | None, name: str) -> ET.Element | None: + if elem is None: + return None + for child in elem: + if _local(child.tag) == name: + return child + return None + + +def _find_children(elem: ET.Element | None, name: str) -> list[ET.Element]: + if elem is None: + return [] + return [child for child in elem if _local(child.tag) == name] + + +def _text(elem: ET.Element | None) -> str | None: + if elem is None or elem.text is None: + return None + value = elem.text.strip() + return value or None + + +def _normalize_ws(text: str) -> str: + return " ".join(text.split()).lower() + + +def parse_pom(path: Path) -> dict: + """Parse one POM into a plain dict of the fields the checks need.""" + try: + root = ET.parse(path).getroot() + except ET.ParseError as exc: + return {"path": path, "error": f"XML parse error: {exc}"} + + parent = _find_child(root, "parent") + return { + "path": path, + "group_id": _text(_find_child(root, "groupId")) or (_text(_find_child(parent, "groupId")) if parent is not None else None), + "artifact_id": _text(_find_child(root, "artifactId")), + "version": _text(_find_child(root, "version")) or (_text(_find_child(parent, "version")) if parent is not None else None), + "packaging": (_text(_find_child(root, "packaging")) or "jar"), + "parent": ( + parent is not None and (_text(_find_child(parent, "groupId")), _text(_find_child(parent, "artifactId")), _text(_find_child(parent, "version"))) + ) + or None, + "licenses": _find_children(_find_child(root, "licenses"), "license"), + "developers": _find_children(_find_child(root, "developers"), "developer"), + "scm": _find_child(root, "scm"), + "description": _text(_find_child(root, "description")), + "description_present": _find_child(root, "description") is not None, + } + + +def _has_apache_license(licenses: list[ET.Element]) -> bool: + for lic in licenses: + name = _text(_find_child(lic, "name")) or "" + url = _text(_find_child(lic, "url")) or "" + if APACHE_LICENSE_NAME_RE.search(name) or APACHE_LICENSE_URL_RE.search(url): + return True + return False + + +def _pom_label(pom: dict) -> str: + """Human-readable GAV coordinate for report details.""" + return ":".join(str(pom.get(key) or "?") for key in ("group_id", "artifact_id", "version")) + + +def _element_present(pom: dict, key: str) -> bool: + # lists (licenses/developers): present when non-empty. is not + # judged here: Maven merges it per field, see _check_scm. + value = pom.get(key) + return bool(value) if isinstance(value, list) else value is not None + + +def _evaluate_element(pom: dict, key: str) -> tuple[bool, str | None]: + """Judge one POM's own declaration of ``key``. + + The same judgement whether the element is declared by the POM + itself or inherited from a staged ancestor. + """ + if key == "licenses": + if _has_apache_license(pom["licenses"]): + return True, None + return False, ( + "licences declared but none is ALv2 (expected name matching 'Apache License, Version 2.0' or url containing apache.org/licenses/LICENSE-2.0)" + ) + if pom["developers"]: + return True, None + return False, " declared but empty" + + +def _scm_field(pom: dict, field: str) -> str | None: + """One ```` field, or None when this POM declares no non-empty value.""" + scm = pom.get("scm") + if scm is None: + return None + return _text(_find_child(scm, field)) + + +def _check_scm(pom: dict, chain: list[dict], chain_reason: str) -> dict: + """Check 1, the ```` part - resolved per field along the chain. + + Maven merges ```` per field: a child declaring only + ```` still inherits ``url`` / ``connection`` from an + ancestor, so a local ```` is never judged in isolation. For + each field, the nearest POM in the staged chain declaring it + non-emptily wins (empty declarations are skipped, the walk + continues). Either field resolvable — from the POM itself or + inherited — is a PASS; neither field resolvable anywhere in a + complete chain is a hard FAIL, and so is a cyclic chain (Maven + refuses to build one); an unstaged chain reports + ``INHERITED-UNVERIFIED``. + """ + source: dict[str, tuple[int, str]] = {} + for field in ("url", "connection"): + for index, chain_pom in enumerate(chain): + if _scm_field(chain_pom, field) is not None: + source[field] = (index, _pom_label(chain_pom)) + break + + if source: + inherited: dict[str, list[str]] = {} + for field, (index, label) in source.items(): + if index > 0: + inherited.setdefault(label, []).append(field) + if inherited: + detail = "; ".join(f"{', '.join(fields)} inherited from locally staged parent POM {label}" for label, fields in inherited.items()) + return {"scm": "PASS", "scm_detail": detail} + return {"scm": "PASS"} + + if chain_reason == "cycle": + return { + "scm": "FAIL", + "scm_detail": f" declares no url or connection and the parent chain is cyclic ({_pom_label(pom)}); Maven refuses to build a cyclic parent chain", + } + if chain_reason == "complete": + if len(chain) == 1: + return { + "scm": "FAIL", + "scm_detail": " declares no url or connection and the POM declares no : nothing to inherit from", + } + return { + "scm": "FAIL", + "scm_detail": " declares no url or connection and no parent POM in the staged chain declares one", + } + return { + "scm": "INHERITED-UNVERIFIED", + "scm_detail": " declares no url or connection and the parent chain is not fully staged locally; verify against the effective POM", + } + + +def _resolve_chain(pom: dict, by_coordinate: dict) -> tuple[list[dict], str]: + """Walk the parent chain through locally staged POMs. + + Returns ``(chain, reason)`` where ``chain`` is ``[pom, parent, + grandparent, ...]`` as far as staged POMs resolve it, and ``reason`` + is ``"complete"`` when the walk ends at a POM with no ```` + (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. + + ```` and ```` are judged at element level: an + element absent from the POM itself is resolved against the locally + staged parent chain, and the first ancestor that declares the + element is judged as-is, so a staged parent carrying a non-ALv2 + licence fails the child too. ```` is judged per field (Maven + merges ``url`` / ``connection`` / ``tag`` independently) — see + ``_check_scm``. When the chain is complete and nothing resolves an + element — including a POM with no ```` at all, where + nothing can be inherited — the result is a hard FAIL; Maven Central + rejects such a POM. A cyclic chain is a FAIL too: Maven refuses to + build one, so it cannot be a correct POM inheriting from the ASF + parent. An unstaged chain — the one case where a correct POM may + inherit from the un-staged ASF parent — reports + ``INHERITED-UNVERIFIED`` (see issue #1173 boundary conditions). + """ + entries: dict[str, str | None] = {} + for key in ("licenses", "developers"): + if _element_present(pom, key): + ok, fail_detail = _evaluate_element(pom, key) + entries[key] = "PASS" if ok else "FAIL" + if fail_detail is not None: + entries[key + "_detail"] = fail_detail + continue + + inherited = next((ancestor for ancestor in chain[1:] if _element_present(ancestor, key)), None) + if inherited is not None: + ok, fail_detail = _evaluate_element(inherited, key) + label = _pom_label(inherited) + entries[key] = "PASS" if ok else "FAIL" + entries[key + "_detail"] = ( + f"inherited from locally staged parent POM {label}" if ok else f"{fail_detail} (inherited from locally staged parent POM {label})" + ) + elif chain_reason == "complete": + entries[key] = "FAIL" + if len(chain) == 1: + entries[key + "_detail"] = "element absent and the POM declares no : nothing to inherit from" + else: + entries[key + "_detail"] = "element absent and no parent POM in the staged chain declares it" + elif chain_reason == "cycle": + entries[key] = "FAIL" + entries[key + "_detail"] = f"element absent and the parent chain is cyclic ({_pom_label(pom)}); Maven refuses to build a cyclic parent chain" + else: + entries[key] = "INHERITED-UNVERIFIED" + entries[key + "_detail"] = "element absent and the parent chain is not fully staged locally; verify against the effective POM" + entries.update(_check_scm(pom, chain, chain_reason)) + return entries + + +def check_disclaimer(pom: dict, chain: list[dict], chain_reason: str) -> dict: + """Check 2 - incubation disclaimer inside (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, a cyclic chain is a + FAIL, and an unstaged chain reports ``INHERITED-UNVERIFIED``. + """ + if pom.get("description_present"): + description = pom.get("description") + if description is None: + # declared but empty. + return {"disclaimer": "FAIL", "disclaimer_detail": " empty for a podling POM"} + if all(core in _normalize_ws(description) for core in DISCLAIMER_CORE): + return {"disclaimer": "PASS", "disclaimer_detail": None} + return { + "disclaimer": "FAIL", + "disclaimer_detail": (" 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"} + if chain_reason == "cycle": + return { + "disclaimer": "FAIL", + "disclaimer_detail": f" absent and the parent chain is cyclic ({_pom_label(pom)}); Maven refuses to build a cyclic parent chain", + } + return { + "disclaimer": "INHERITED-UNVERIFIED", + "disclaimer_detail": " absent and the parent chain is not fully staged locally; verify against the effective POM", + } + + +def _checksum_status(companion: Path, digest_file: Path, digest: str) -> str | None: + """Compare a checksum file's recorded digest with the companion's bytes. + + Returns ``None`` when the recorded digest matches, an error message + when it does not, or ``"unverified"`` when ``digest`` names an + algorithm this Python's ``hashlib`` does not provide (the file is + still required, but its content cannot be checked offline). The hex + digest is extracted leniently, so every layout published in + practice is accepted: a bare digest, the GNU coreutils + `` ``, the BSD/tagged ``ALGO (filename) = + `` (``shasum --tag``), and ``gpg --print-md``'s + ``filename: `` — any case. + """ + try: + hasher = hashlib.new(digest) + except ValueError: + return "unverified" + hasher.update(companion.read_bytes()) + actual = hasher.hexdigest() + text = digest_file.read_text(encoding="utf-8", errors="replace") + hex_length = len(actual) + match = re.search(rf"(? dict: + """Check 3 - companion jars exist, each signed and checksummed. + + Checksum files are verified against the companion jar's actual + bytes (``hashlib``, still offline). ``.asc`` signatures are checked + for presence only: verifying a signature needs GPG and the release + key, which `release-verify-rc` Step 2 runs against the main + artefacts — the Step 6b recipe extends that verification to the + companions when it emits the paste-ready commands. + """ + results: list[dict[str, str | None]] = [] + for classifier in COMPANION_CLASSIFIERS: + companion = jar.with_name(jar.name[: -len(".jar")] + f"-{classifier}.jar") + if not companion.exists(): + results.append( + { + "companion": companion.name, + "classification": "FAIL", + "detail": "missing companion jar (Maven Central requirement)", + } + ) + continue + problems: list[str] = [] + if not (jar.parent / f"{companion.name}.asc").exists(): + problems.append(f"missing .asc signature file ({companion.name}.asc)") + for digest in digests: + digest_file = jar.parent / f"{companion.name}.{digest}" + if not digest_file.exists(): + problems.append(f"missing .{digest} checksum file ({digest_file.name})") + continue + status = _checksum_status(companion, digest_file, digest) + if status == "unverified": + findings.append(f"{digest_file.name}: '{digest}' is not a digest algorithm this Python provides; checksum content not verified (presence only)") + elif status is not None: + problems.append(status) + if problems: + for problem in problems: + results.append( + { + "companion": companion.name, + "classification": "FAIL", + "detail": problem, + } + ) + else: + results.append( + { + "companion": companion.name, + "classification": "PASS", + "detail": None, + } + ) + return {"jar": jar.name, "companions": results} + + +def split_jar_name(name: str) -> tuple[str, str | None, str | None]: + """Split ``-[-].jar`` into parts. + + Companion classifiers are detected by suffix first: a version's + ``-[A-Za-z]+`` qualifier would otherwise greedily absorb + ``-sources`` / ``-javadoc`` and hide the classifier. + """ + base = name[: -len(".jar")] + classifier: str | None = None + for known in COMPANION_CLASSIFIERS: + if base.endswith(f"-{known}"): + classifier = known + base = base[: -len(known) - 1] + break + m = VERSION_SPLIT_RE.match(base) + if m is None: + return base, None, classifier + return m.group("stem"), m.group("version"), classifier + + +def verify_staged_dir(staged_dir: Path, digests: list[str], podling: bool) -> dict: + # rglob, not glob: a staging directory in Maven-repository layout + # (org/apache/foo/foo-core/1.0.0/...) is a JVM artefact set too — a + # top-level-only scan would misreport it as non-JVM and skip. + poms = sorted(staged_dir.rglob("*.pom")) + jars = sorted(staged_dir.rglob("*.jar")) + + report: dict = { + "tool": "maven-artifact-verify", + "status": "SKIP", + "artefact_dir": str(staged_dir), + "podling": podling, + "digests": digests, + "poms": [], + "jars": [], + "unmatched_jars": [], + "findings": [], + } + + if not poms and not jars: + report["findings"].append("no .pom or .jar files staged - non-JVM artefact set, JVM checks skip cleanly") + return report + + parsed = {} + for pom_path in poms: + data = parse_pom(pom_path) + parsed[pom_path] = data + if "error" in data: + report["poms"].append({"pom": pom_path.name, "check1": "FAIL", "detail": data["error"]}) + + # Index the staged POMs by (groupId, artifactId, version) so a + # reference can be resolved locally when possible. + by_coordinate = {} + for data in parsed.values(): + if "error" in data: + continue + by_coordinate[(data["group_id"], data["artifact_id"], data["version"])] = data + + # --- checks 1 and 2: per POM --- + for pom_path, data in parsed.items(): + if "error" in data: + continue + chain, chain_reason = _resolve_chain(data, by_coordinate) + check1 = check_pom_entries(data, chain, chain_reason) + entry = {"pom": pom_path.name, "packaging": data["packaging"], "check1": check1} + if podling: + entry["check2"] = check_disclaimer(data, chain, chain_reason) + report["poms"].append(entry) + + # --- check 3: per main jar --- + main_jars = [] + for pom_path, data in parsed.items(): + if "error" in data or data["packaging"] == "pom": + continue + if data["artifact_id"] and data["version"]: + if MAVEN_COORDINATE_RE.fullmatch(data["artifact_id"]) is None or MAVEN_COORDINATE_RE.fullmatch(data["version"]) is None: + # The coordinates go straight into a path below; reject + # anything Maven itself would not accept (e.g. "../..") + # before touching the filesystem. + report["findings"].append( + f"{pom_path.name}: artifactId {data['artifact_id']!r} / version {data['version']!r} is not a valid Maven coordinate " + "([A-Za-z0-9_.-] only); jar and companion checks skipped" + ) + continue + # The main jar lives in the POM's own directory (in Maven + # repository layout that is the versioned subdirectory, not + # the staged root). + main = pom_path.parent / f"{data['artifact_id']}-{data['version']}.jar" + if main.exists(): + main_jars.append(main) + else: + # The jar is published via the Nexus staging repository + # in the common ASF workflow and is not staged locally + # in dist/dev at all. That is an observation, not a + # failure: companion coverage is only checkable for + # jars that ARE staged locally. release-verify-rc + # classifies an unexpected absence against + # release-build.md § JVM artefact checks. + report["jars"].append( + { + "jar": main.name, + "companions": [ + { + "companion": main.name, + "classification": "ABSENT", + "detail": "main jar not staged locally; companion " + "checks skipped (verify via the Nexus " + "staging repository when it is the vote " + "target)", + } + ], + } + ) + report["findings"].append(f"{main.name} is declared by a staged POM but not staged locally") + + matched = set(main_jars) + for jar in jars: + if jar in matched: + continue + _stem, _version, classifier = split_jar_name(jar.name) + if classifier in COMPANION_CLASSIFIERS: + # A companion of some main jar; check 3 runs against the + # main jar (declared by its POM) and covers it there. + continue + # Not a POM-declared main jar and not a known companion + # classifier (-tests, -shaded, platform classifiers, ...). + # Report as unmatched: neither a main nor a companion, never a + # failure in either direction (issue #1173 boundary conditions). + report["unmatched_jars"].append(jar.name) + + for main in main_jars: + report["jars"].append(check_companions(main, digests, report["findings"])) + + # --- aggregate --- + statuses = [] + for entry in report["poms"]: + for key in ("check1", "check2"): + check = entry.get(key) + if isinstance(check, str): + statuses.append(check) + elif isinstance(check, dict): + statuses.extend(v for k, v in check.items() if not k.endswith("_detail")) + for jar_entry in report["jars"]: + for comp in jar_entry["companions"]: + statuses.append(comp["classification"]) + + if not statuses: + if jars: + # Jars staged but no POM anywhere: checks 1-2 cannot run, + # and no main jar was POM-declared for check 3. Never a + # silent skip — surface what could not be verified. + report["status"] = "WARN" + report["findings"].append( + "jars staged but no .pom found: POM and companion checks could not run; verify against the Nexus staging repository (issue #1173, check 4)" + ) + else: + report["status"] = "SKIP" + elif "FAIL" in statuses: + report["status"] = "FAIL" + elif "INHERITED-UNVERIFIED" in statuses: + report["status"] = "WARN" + else: + report["status"] = "PASS" + return report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="maven-artifact-verify", + description="Verify locally staged JVM release-candidate artefacts " + "(POM licence set, podling disclaimer, companion jars). " + "Stdlib-only and offline; prints one JSON report.", + ) + parser.add_argument("staged_dir", type=Path, help="directory holding the staged .pom / .jar artefacts") + parser.add_argument("--digests", default="sha512", help="comma-separated digest types companions must carry (default: sha512)") + parser.add_argument("--podling", action="store_true", help="project is incubating: check 2 requires the incubation disclaimer in every ") + 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..13acd82cc --- /dev/null +++ b/tools/maven-artifact-verify/tests/test_maven_artifact_verify.py @@ -0,0 +1,740 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for maven-artifact-verify. + +Fixtures are synthetic POMs (string templates) and jars (zipfile +output) built at test time, so the repository carries no binary test +blobs. +""" + +from __future__ import annotations + +import hashlib +import json +import zipfile +from pathlib import Path + +import maven_artifact_verify as mav + +DISCLAIMER = ( + "Apache Foo is an effort undergoing incubation at The Apache Software " + "Foundation (ASF), sponsored by the Apache Incubator. Incubation is " + "required of all newly accepted projects until a further review " + "indicates that the infrastructure, communications, and decision making " + "process have stabilized in a manner consistent with other successful " + "ASF projects. While incubation status is not necessarily a reflection " + "of the completeness or stability of the code, it does indicate that " + "the project has yet to be fully endorsed by the ASF." +) + +DISCLAIMER_WIP = DISCLAIMER + ( + " Some of the incubating project's releases may not be fully compliant " + "with ASF policy. For example, releases may have incomplete or " + "un-reviewed licensing conditions." +) + + +def pom_xml( + artifact_id: str = "foo-core", + version: str = "1.0.0", + packaging: str | None = None, + licenses: str | None = None, + developers: str | None = None, + scm: str | None = None, + description: str | None = None, + parent: str | None = None, + group_id: str = "org.apache.foo", +) -> str: + parts = [ + '', + '', + " 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 " +TAG_ONLY_SCM = " v1.0.0 " +EMPTY_SCM = " " +ASF_PARENT = " org.apache apache 33 " +FOO_PARENT = " org.apache.foo foo-parent 1.0.0 " + + +def write_pom(directory: Path, name: str, xml: str) -> Path: + path = directory / name + path.write_text(xml, encoding="utf-8", newline="") + return path + + +def write_jar(directory: Path, name: str, entry: str = "org/apache/foo/Main.class") -> Path: + path = directory / name + with zipfile.ZipFile(path, "w") as zf: + zf.writestr(entry, b"class-bytes-here") + return path + + +def write_checksum(jar_path: Path, digest: str) -> Path: + """Write a checksum file holding the jar's real digest.""" + hasher = hashlib.new(digest) + hasher.update(jar_path.read_bytes()) + path = jar_path.with_name(f"{jar_path.name}.{digest}") + path.write_text(hasher.hexdigest() + "\n", encoding="utf-8") + return path + + +def write_staged( + directory: Path, + artifact: str = "foo-core", + version: str = "1.0.0", + companions: bool = True, + companion_signatures: bool = True, + companion_digests: tuple[str, ...] = ("sha512",), +) -> None: + """Write one main jar plus its signed, checksummed companions.""" + stem = f"{artifact}-{version}" + write_jar(directory, f"{stem}.jar") + if companions: + for classifier in ("sources", "javadoc"): + companion = write_jar(directory, f"{stem}-{classifier}.jar") + if companion_signatures: + (directory / f"{stem}-{classifier}.jar.asc").write_bytes(b"sig") + for digest in companion_digests: + write_checksum(companion, digest) + + +def mav_json(directory: Path, extra: tuple[str, ...]) -> str: + import contextlib + import io + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + mav.main([str(directory), *extra]) + return buffer.getvalue() + + +# --- full good set ------------------------------------------------------- + + +def test_good_podling_set_passes(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description=DISCLAIMER)) + write_staged(tmp_path) + report = json.loads(mav_json(tmp_path, ("--podling",))) + assert report["status"] == "PASS" + assert report["poms"][0]["check1"]["licenses"] == "PASS" + assert report["poms"][0]["check2"]["disclaimer"] == "PASS" + assert report["jars"] and all(c["classification"] == "PASS" for c in report["jars"][0]["companions"]) + + +def test_top_level_project_without_podling_flag_passes(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM, description=None)) + write_staged(tmp_path) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + assert "check2" not in report["poms"][0] + + +# --- check 1: POM licence / developers / scm ------------------------------ + + +def test_non_alv2_license_fails(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=MIT_LICENSES, developers=DEVELOPERS, scm=SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "FAIL" + assert report["poms"][0]["check1"]["licenses"] == "FAIL" + + +def test_licence_name_variants_without_url_pass(tmp_path: Path) -> None: + # The SPDX id and the legacy ASF wording identify ALv2 on their own; + # without this, Apache-2.0 with no failed. + for index, name in enumerate(("Apache-2.0", "The Apache Software License, Version 2.0")): + directory = tmp_path / str(index) + directory.mkdir() + licenses = f" {name} " + write_pom(directory, "foo-core-1.0.0.pom", pom_xml(licenses=licenses, developers=DEVELOPERS, scm=SCM)) + report = json.loads(mav_json(directory, ())) + assert report["status"] == "PASS", name + assert report["poms"][0]["check1"]["licenses"] == "PASS", name + + +def test_inherited_licence_resolved_from_staged_parent(tmp_path: Path) -> None: + write_pom( + tmp_path, + "apache-33.pom", + pom_xml(artifact_id="apache", version="33", packaging="pom", group_id="org.apache", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM), + ) + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=ASF_PARENT, developers=DEVELOPERS, scm=SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom") + assert check1["licenses"] == "PASS" + assert "inherited" in check1["licenses_detail"] + + +def test_inherited_licence_without_staged_parent_warns(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=ASF_PARENT, developers=DEVELOPERS, scm=SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "WARN" + assert report["poms"][0]["check1"]["licenses"] == "INHERITED-UNVERIFIED" + + +def test_staged_parent_with_non_alv2_licence_fails(tmp_path: Path) -> None: + # Regression: a staged parent used to report a blind PASS without its + # own 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_fails_without_hanging(tmp_path: Path) -> None: + # Two staged POMs naming each other as parent: the walk must stop + # and never hang. A cyclic chain is a broken staged set (Maven + # refuses to build it), so the elements that cannot be resolved + # FAIL instead of warning. + foo_parent = " 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"] == "FAIL" + check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-a-1.0.0.pom") + assert check1["licenses"] == "FAIL" + assert "cyclic" in check1["licenses_detail"] + + +def test_tag_only_scm_inherits_from_staged_parent(tmp_path: Path) -> None: + # Regression: Maven merges per field, so a child declaring + # only still inherits url/connection from its parent. Judging + # the child's in isolation used to make this a hard FAIL. + write_pom( + tmp_path, + "foo-parent-1.0.0.pom", + pom_xml(artifact_id="foo-parent", packaging="pom", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM), + ) + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=FOO_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom") + assert check1["scm"] == "PASS" + assert "inherited" in check1["scm_detail"] + + +def test_tag_only_scm_with_unstaged_parent_warns(tmp_path: Path) -> None: + # An unstaged parent may carry url/connection, so the child's + # tag-only is INHERITED-UNVERIFIED, never a hard FAIL. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=ASF_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "WARN" + check1 = report["poms"][0]["check1"] + assert check1["scm"] == "INHERITED-UNVERIFIED" + assert "not fully staged" in check1["scm_detail"] + + +def test_scm_without_url_anywhere_in_complete_chain_fails(tmp_path: Path) -> None: + # The staged parent declares a tag-only too and has no parent + # of its own: nothing in the chain supplies url or connection — + # a hard FAIL, the POM Maven Central would reject. + write_pom( + tmp_path, + "foo-parent-1.0.0.pom", + pom_xml(artifact_id="foo-parent", packaging="pom", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM), + ) + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=FOO_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=TAG_ONLY_SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "FAIL" + check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom") + assert check1["scm"] == "FAIL" + assert "no parent POM in the staged chain" in check1["scm_detail"] + + +def test_empty_child_scm_resolves_from_staged_grandparent(tmp_path: Path) -> None: + # Empty and tag-only declarations don't win: the per-field walk + # continues past them to the grandparent that declares url. + write_pom( + tmp_path, + "apache-33.pom", + pom_xml(artifact_id="apache", version="33", packaging="pom", group_id="org.apache", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM), + ) + write_pom(tmp_path, "foo-parent-1.0.0.pom", pom_xml(artifact_id="foo-parent", packaging="pom", parent=ASF_PARENT, developers=DEVELOPERS, scm=TAG_ONLY_SCM)) + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(parent=FOO_PARENT, licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=EMPTY_SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + check1 = next(e["check1"] for e in report["poms"] if e["pom"] == "foo-core-1.0.0.pom") + assert check1["scm"] == "PASS" + assert "org.apache:apache:33" in check1["scm_detail"] + + +def test_empty_scm_element_fails(tmp_path: Path) -> None: + # No at all, so the empty cannot inherit url or + # connection from anywhere: a hard FAIL. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=EMPTY_SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "FAIL" + assert report["poms"][0]["check1"]["scm"] == "FAIL" + + +def test_malformed_pom_fails(tmp_path: Path) -> None: + (tmp_path / "broken-1.0.0.pom").write_text("", 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_cycle_fails_disclaimer_without_hanging(tmp_path: Path) -> None: + # The same cycle rule under check 2: a cyclic chain is a broken + # staged set, so the unresolved is a FAIL, not a + # warning. + 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, ("--podling",))) + assert report["status"] == "FAIL" + check2 = next(e["check2"] for e in report["poms"] if e["pom"] == "foo-a-1.0.0.pom") + assert check2["disclaimer"] == "FAIL" + assert "cyclic" in check2["disclaimer_detail"] + + +def test_podling_inherited_description_without_disclaimer_fails(tmp_path: Path) -> None: + # A resolvable parent whose description lacks the disclaimer stays + # a FAIL: the effective POM verifiably does not carry it. + write_pom(tmp_path, "foo-parent-1.0.0.pom", pom_xml(artifact_id="foo-parent", packaging="pom", description="Just a parent.")) + write_pom( + tmp_path, + "foo-core-1.0.0.pom", + pom_xml( + parent=(" 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) + # a companion with a missing file must not also carry a PASS record + # (regression: a loop-else used to append PASS unconditionally) + companions = {c["companion"] for c in report["jars"][0]["companions"]} + for name in companions: + classifications = [c["classification"] for c in report["jars"][0]["companions"] if c["companion"] == name] + assert classifications == ["FAIL"], f"{name}: {classifications}" + + +def test_companion_missing_digest_fails(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + (tmp_path / "foo-core-1.0.0-sources.jar.sha512").unlink() + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "FAIL" + fails = [c for c in report["jars"][0]["companions"] if c["classification"] == "FAIL"] + assert len(fails) == 1 and "sha512" in fails[0]["detail"] + + +def test_sha256_digest_set(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path, companion_digests=("sha512", "sha256")) + report = json.loads(mav_json(tmp_path, ("--digests", "sha512,sha256"))) + assert report["status"] == "PASS" + + +def test_checksum_mismatch_fails(tmp_path: Path) -> None: + # Regression: the checksum file used to be an existence check only — + # any bytes passed. A recorded digest that does not match the + # companion jar's actual bytes must fail. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text("0" * 128 + "\n", encoding="utf-8") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "FAIL" + fails = [c for c in report["jars"][0]["companions"] if c["classification"] == "FAIL"] + assert len(fails) == 1 and "checksum mismatch" in fails[0]["detail"] + + +def test_checksum_file_with_gnu_coreutils_line_passes(tmp_path: Path) -> None: + # The GNU coreutils layout " ". + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + companion = tmp_path / "foo-core-1.0.0-sources.jar" + hasher = hashlib.sha512() + hasher.update(companion.read_bytes()) + (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(f"{hasher.hexdigest()} {companion.name}\n", encoding="utf-8") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + + +def test_checksum_file_with_bsd_tagged_line_passes(tmp_path: Path) -> None: + # The real BSD/tagged layout "ALGO (filename) = " as written + # by `shasum --tag` used to report a false checksum mismatch. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + companion = tmp_path / "foo-core-1.0.0-sources.jar" + hasher = hashlib.sha512() + hasher.update(companion.read_bytes()) + (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(f"SHA512 ({companion.name}) = {hasher.hexdigest()}\n", encoding="utf-8") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + + +def _gpg_print_md(name: str, hexdigest: str) -> str: + # The layout `gpg --print-md SHA512 ` really prints: upper-case + # groups of 8 hex characters separated by spaces, wrapped across lines + # with a hanging indent under the first group. + groups = [hexdigest.upper()[i : i + 8] for i in range(0, len(hexdigest), 8)] + indent = " " * (len(name) + 2) + lines = [" ".join(groups[i : i + 6]) for i in range(0, len(groups), 6)] + return f"{name}: " + ("\n" + indent).join(lines) + "\n" + + +def test_checksum_file_with_gpg_print_md_layout_passes(tmp_path: Path) -> None: + # Some ASF projects still publish `gpg --print-md` output as the + # checksum file; a correct one must not be reported as a mismatch. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + companion = tmp_path / "foo-core-1.0.0-sources.jar" + hasher = hashlib.sha512() + hasher.update(companion.read_bytes()) + (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(_gpg_print_md(companion.name, hasher.hexdigest()), encoding="utf-8") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + + +def test_checksum_file_with_wrong_gpg_print_md_digest_fails(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text(_gpg_print_md("foo-core-1.0.0-sources.jar", "ab" * 64), encoding="utf-8") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "FAIL" + assert any("checksum mismatch" in str(c.get("detail")) for e in report["jars"] for c in e["companions"]) + + +def test_checksum_file_with_wrong_digest_still_fails(tmp_path: Path) -> None: + # Lenient extraction must not start accepting any 128-hex-char + # looking string: a recorded digest that does not match the bytes + # stays a FAIL. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + (tmp_path / "foo-core-1.0.0-sources.jar.sha512").write_text("SHA512 (foo-core-1.0.0-sources.jar) = " + "0" * 128 + "\n", encoding="utf-8") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "FAIL" + assert any("checksum mismatch" in str(c.get("detail")) for e in report["jars"] for c in e["companions"]) + + +def test_unknown_digest_algorithm_is_presence_only(tmp_path: Path) -> None: + # An algorithm this Python's hashlib does not provide cannot be + # checked offline: the file is still required, but the tool must say + # plainly that it verified presence only, never a fake PASS. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + (tmp_path / "foo-core-1.0.0-sources.jar.sha999").write_bytes(b"hash") + (tmp_path / "foo-core-1.0.0-javadoc.jar.sha999").write_bytes(b"hash") + report = json.loads(mav_json(tmp_path, ("--digests", "sha999"))) + assert report["status"] == "PASS" + assert any("not verified" in f for f in report["findings"]) + + +def test_maven_repository_layout_is_verified(tmp_path: Path) -> None: + # Regression: a top-level-only scan misreported a staging directory + # in Maven-repository layout as a non-JVM artefact set and skipped. + nested = tmp_path / "org" / "apache" / "foo" / "foo-core" / "1.0.0" + nested.mkdir(parents=True) + write_pom(nested, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(nested) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + assert report["poms"][0]["pom"] == "foo-core-1.0.0.pom" + assert all(c["classification"] == "PASS" for c in report["jars"][0]["companions"]) + + +def test_main_jar_absent_is_an_observation_not_a_failure(tmp_path: Path) -> None: + # The common ASF workflow publishes jars via the Nexus staging + # repository and stages only the POM locally — absence of the main + # jar must not fail the local check set. + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + assert report["jars"][0]["companions"][0]["classification"] == "ABSENT" + assert any("not staged locally" in f for f in report["findings"]) + + +def test_invalid_coordinates_are_rejected_before_path_build(tmp_path: Path) -> None: + # artifactId/version go straight into a path when the main jar is + # located; a POM with ../../evil must not + # make the tool stat or hash files outside the staging directory. + staged = tmp_path / "staged" + staged.mkdir() + outside = tmp_path / "evil-1.0.0.jar" + outside.write_bytes(b"outside the staging directory") + write_pom(staged, "foo-core-1.0.0.pom", pom_xml(artifact_id="../../evil", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + report = json.loads(mav_json(staged, ())) + assert report["status"] == "PASS" # checks 1-2 still ran; the jar checks are what is skipped + assert any("not a valid Maven coordinate" in f for f in report["findings"]) + assert not any(entry["jar"].startswith("evil") for entry in report["jars"]) + assert outside.read_bytes() == b"outside the staging directory" + + +def test_pom_packaging_exempt_from_companions(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-parent-1.0.0.pom", pom_xml(artifact_id="foo-parent", packaging="pom", licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + assert report["jars"] == [] + + +def test_classified_jar_is_unmatched_never_failed(tmp_path: Path) -> None: + write_pom(tmp_path, "foo-core-1.0.0.pom", pom_xml(licenses=APACHE_LICENSES, developers=DEVELOPERS, scm=SCM)) + write_staged(tmp_path) + write_jar(tmp_path, "foo-core-1.0.0-tests.jar") + write_jar(tmp_path, "foo-core-1.0.0-linux-x86_64.jar") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "PASS" + assert set(report["unmatched_jars"]) == {"foo-core-1.0.0-tests.jar", "foo-core-1.0.0-linux-x86_64.jar"} + + +def test_jars_without_poms_warn(tmp_path: Path) -> None: + # Jars present but no POM staged at all: the companions are skipped + # (no POM-declared main) and the main jar itself is unmatched — an + # observation, not a failure, but the report must say the POM + # checks could not run. + write_jar(tmp_path, "foo-core-1.0.0.jar") + write_jar(tmp_path, "foo-core-1.0.0-sources.jar") + report = json.loads(mav_json(tmp_path, ())) + assert report["status"] == "WARN" + assert "foo-core-1.0.0.jar" in report["unmatched_jars"] + assert any("no .pom" in f for f in report["findings"]) + + +# --- aggregation ----------------------------------------------------------- + + +def test_no_jvm_artefacts_skips(tmp_path: Path) -> None: + (tmp_path / "apache-foo-1.0.0-src.tar.gz").write_bytes(b"not a jar") + report = json.loads(mav_json(tmp_path, ("--podling",))) + assert report["status"] == "SKIP" + + +def test_missing_directory_fails(tmp_path: Path) -> None: + import contextlib + import io + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + code = mav.main([str(tmp_path / "nope")]) + report = json.loads(buffer.getvalue()) + assert code == 2 + assert report["status"] == "FAIL" diff --git a/tools/skill-evals/README.md b/tools/skill-evals/README.md index e2881e1ba..5ba88a6ae 100644 --- a/tools/skill-evals/README.md +++ b/tools/skill-evals/README.md @@ -76,7 +76,7 @@ Suites are currently implemented for: - **release-prepare** — 15 cases across 4 suites (step-0-preflight, step-1-plan, step-14-post, step-2-prep) - **release-promote** — 9 cases across 2 suites (step-0-preflight, step-2-emit-commands) - **release-rc-cut** — 14 cases across 4 suites (step-0-preflight, step-2-tag-build-sign, step-2b-reproducibility, step-3-staging) -- **release-verify-rc** — 18 cases across 7 suites (step-0-preflight, step-2-verify-signatures, step-3-verify-checksums, step-5-notice-license, step-6-binary-exclusion, step-8-version-consistency, step-9-reproducibility) +- **release-verify-rc** — 22 cases across 8 suites (step-0-preflight, step-2-verify-signatures, step-3-verify-checksums, step-5-notice-license, step-6-binary-exclusion, step-6b-jvm-artefacts, step-8-version-consistency, step-9-reproducibility) - **release-vote-draft** — 9 cases across 3 suites (step-0-preflight, step-2-vote-draft, step-3-planning-comment) - **release-vote-tally** — 9 cases across 3 suites (step-0-preflight, step-2-classify, step-3-tally) - **reviewer-routing** — 7 cases across 2 suites (step-0-preflight, step-score-and-propose) diff --git a/tools/skill-evals/evals/release-verify-rc/README.md b/tools/skill-evals/evals/release-verify-rc/README.md index 083b27c74..7036912c0 100644 --- a/tools/skill-evals/evals/release-verify-rc/README.md +++ b/tools/skill-evals/evals/release-verify-rc/README.md @@ -15,10 +15,11 @@ Behavioural eval suite for the | `step-3-verify-checksums` | Step 3 — Verify checksums | 2 | Checksum classification (PASS/MISMATCH/MISSING-DIGEST), deprecated md5 detection | | `step-5-notice-license` | Step 5 — NOTICE/LICENSE presence | 2 | File presence (PASS/WARN/FAIL), diff-lines count, diff summary | | `step-6-binary-exclusion` | Step 6 — Binary exclusion check | 2 | Prohibited-binary detection (PASS/FAIL), expected-binary classification | +| `step-6b-jvm-artefacts` | Step 6b — JVM artefact checks | 4 | Tool-report classification (PASS/WARN/FAIL), POM licence/`INHERITED-UNVERIFIED` handling, companion signature detection, `ABSENT` jar vs `jvm_companion_location` | | `step-8-version-consistency` | Step 8 — Version string consistency | 2 | Exact version match across manifest files (PASS/FAIL) | | `step-9-reproducibility` | Step 9 — Reproducibility checks | 5 | `repro-archive compare` verdict → status (`identical` PASS, `differs` FAIL, `content-identical` WARN in RM-key mode / FAIL under automated signing), `mandatory` under `automated_release_signing: enabled` with `--skip-repro` ignored, `trusted_hardware_asserted` mirrors the flag, a project-specific convenience artefact whose rebuild differs (source identical, artefact `FAIL`, named in `binaries.differs`) | -Total: **18 cases** across 7 step suites. +Total: **22 cases** across 8 step suites. ## Run @@ -42,8 +43,9 @@ document the allowed schema. The grader extracts JSON from the model's output and compares the fields in `expected.json` exactly. Fields not present in `expected.json` are ignored; fields present in `expected.json` must match exactly -(including `null` vs omitted). `paste_recipe` fields are graded -semantically (see each suite's `grading-schema.json`). +(including `null` vs omitted), except prose fields declared in the step +fixtures' `grading-schema.json` (e.g. `paste_recipe`, `pom_findings`), +which are graded semantically. `PASS` — all fields in `expected.json` match the model output. `FAIL` — any field mismatch. @@ -63,6 +65,13 @@ semantically (see each suite's `grading-schema.json`). `"WARN"` for material diffs; `"PASS"` for version-string-only diffs. - **Step 6**: `status` must be `"FAIL"` when `prohibited_found` is non-empty. +- **Step 6b**: a tool `FAIL` (wrong POM licence, missing companion or + companion signature) is always `"FAIL"`; `INHERITED-UNVERIFIED` is + `"WARN"` — a correct POM inheriting from the ASF parent must not be + failed; an `ABSENT` jar is a `"FAIL"` only when `release-build.md` + declares `jvm_companion_location: staged`, and an observation + otherwise; classified jars (`-tests`, `-shaded`, …) are never + flagged in either direction. - **Step 8**: `status` must be `"FAIL"` for any `match: false` or `extracted: null`; dev/snapshot suffixes are always `match: false`. - **Step 9**: `differs` and `tag-moved` are always `"FAIL"`; diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/expected.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/expected.json new file mode 100644 index 000000000..f10ac6a8f --- /dev/null +++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/expected.json @@ -0,0 +1,7 @@ +{ + "step": "jvm-artefacts", + "status": "PASS", + "pom_findings": [], + "companion_findings": [], + "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512 --podling\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar\"\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar\"" +} diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-1-clean-podling-staged-set/report.md new file mode 100644 index 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..6bf81d631 --- /dev/null +++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/expected.json @@ -0,0 +1,9 @@ +{ + "step": "jvm-artefacts", + "status": "FAIL", + "pom_findings": [ + "foo-core-1.0.0.pom: licences declared but none is ALv2 (expected name matching 'Apache License, Version 2.0' or url containing apache.org/licenses/LICENSE-2.0)" + ], + "companion_findings": [], + "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512 --podling\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-sources.jar\"\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar\"" +} diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-2-wrong-pom-licence/report.md new file mode 100644 index 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..078886ced --- /dev/null +++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/expected.json @@ -0,0 +1,9 @@ +{ + "step": "jvm-artefacts", + "status": "FAIL", + "pom_findings": [], + "companion_findings": [ + "foo-core-1.0.0-sources.jar: missing .asc signature file (foo-core-1.0.0-sources.jar.asc)" + ], + "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512\ngpg --verify \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar.asc\" \"dist/dev/foo/1.0.0-rc1/foo-core-1.0.0-javadoc.jar\"" +} diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-3-missing-companion-signature/report.md new file mode 100644 index 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..fd23a551d --- /dev/null +++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/expected.json @@ -0,0 +1,7 @@ +{ + "step": "jvm-artefacts", + "status": "WARN", + "pom_findings": ["foo-core-1.0.0.pom: check1 developers INHERITED-UNVERIFIED — element absent and the parent chain is not fully staged locally; verify against the effective POM"], + "companion_findings": ["foo-core-1.0.0.jar is declared by a staged POM but not staged locally (ABSENT); jvm_companion_location: nexus-staging — an observation, not a failure"], + "paste_recipe": "uv run --project .apache-magpie/tools/maven-artifact-verify maven-artifact-verify \"dist/dev/foo/1.0.0-rc1\" --digests sha512" +} diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/report.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/case-4-jar-absent-nexus-only/report.md new file mode 100644 index 000000000..3bc10fd57 --- /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 the parent chain is not fully staged locally; verify against the effective POM", + "scm": "PASS" + } + } + ], + "jars": [ + {"jar": "foo-core-1.0.0.jar", "companions": [ + {"companion": "foo-core-1.0.0.jar", "classification": "ABSENT", "detail": "main jar not staged locally; companion checks skipped (verify via the Nexus staging repository when it is the vote target)"} + ]} + ], + "unmatched_jars": [], + "findings": ["foo-core-1.0.0.jar is declared by a staged POM but not staged locally"] +} +``` + +Two things to classify here: + +- `` 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..defd45770 --- /dev/null +++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/grading-schema.json @@ -0,0 +1,3 @@ +{ + "prose_fields": ["paste_recipe", "tool_report", "pom_findings", "companion_findings"] +} diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md new file mode 100644 index 000000000..f3c3830bc --- /dev/null +++ b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/output-spec.md @@ -0,0 +1,38 @@ + + +# 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 (the parent chain is not fully staged + locally, so the element cannot be resolved offline) are `"WARN"`, + never `"FAIL"`: a correct POM that inherits from the ASF parent must + not be failed. +- `pom_findings` / `companion_findings` name the exact failing artefact + and what is wrong; an empty list means no findings for that kind. +- `paste_recipe` must be a non-empty string invoking + `maven-artifact-verify` on the staged directory, with `--digests` + set from `jvm_digest_set` when `release-build.md § JVM artefact + checks` sets it, otherwise from the § Digest set, and `--podling` + passed only when the source artefact ships a `DISCLAIMER` / + `DISCLAIMER-WIP`. +- No extra keys are permitted in the response. diff --git a/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/step-config.json b/tools/skill-evals/evals/release-verify-rc/step-6b-jvm-artefacts/fixtures/step-config.json new file mode 100644 index 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..6f0f15abe 100644 --- a/tools/spec-loop/specs/release-management-lifecycle.md +++ b/tools/spec-loop/specs/release-management-lifecycle.md @@ -92,7 +92,10 @@ code lands. `@apache.org` address reminder, Download Page link constraint, and no-send / no-auto-merge boundaries; `release-verify-rc` (`mode: Triage`) runs read-only RC pre-flight (signatures, checksums, RAT headers, - NOTICE/LICENSE, prohibited binaries, version consistency, Step 6); + NOTICE/LICENSE, prohibited binaries, published-JVM-artefact + compliance via `tools/maven-artifact-verify` — POM licence set, + podling disclaimer, companion jars; version consistency, + Step 6); `release-vote-draft` (`mode: Drafting`) drafts the `[VOTE]` email body and planning-issue comment after a PASS pre-flight, never sending or posting (Step 7); `release-vote-tally` (`mode: Triage`) classifies diff --git a/uv.lock b/uv.lock index 78129778e..d16d16157 100644 --- a/uv.lock +++ b/uv.lock @@ -36,6 +36,7 @@ members = [ "magpie-skills", "magpie-sourcehut", "magpie-vcs", + "maven-artifact-verify", "oauth-draft", "permission-audit", "pilot-report-validator", @@ -1052,6 +1053,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"