From 3bd9905197aae50a3d128e26ebb86572b37cce98 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 4 Oct 2026 18:54:28 +0200 Subject: [PATCH] perf(release-management): one tool for config loading and pre-flight checks Every release skill parsed release-management-config.md, release-build.md and release-trains.md by hand in Step 0 and re-derived the same values (signing mode, staging and target URLs, defaults) in Step 1. tools/release-config does it once: `release-config preflight --skill ` returns blockers, warnings and values as JSON, and `load` returns the parsed config with derived values. The skills keep the invocation, how to act on the JSON, and every judgement call (planning-issue discovery, tag and URL reachability, roster comparisons). The skills disagreed on several rules; the maintainer settled each one and the tool now holds one version: - Source release candidates: a dotted version (2+ numeric parts, no .postN) plus -rcN with N >= 1; rc0 now blocks rc-cut. - Convenience artefacts carry an optional version and version_scheme in release-build.md. `python` (today's rule, .postN allowed) is validated; any other scheme is reported as unvalidated. - One approver roster key, release_approver_roster_path, defaulting to /pmc-roster.md (promote, vote-tally, audit-report). - keyserver defaults to keys.openpgp.org everywhere. - non_asf comes from `organization:` in project.md; the --non-asf flag is gone. - ASF identity is `organization: ASF` (is_asf_tlp is dropped), and automated signing is gated on the organization manifest's release_process.automated_signing. - archive.apache.org is the archive default for every ASF backend. Step 0 eval cases embed the tool's real output; a new rc-cut case checks that rc0 blocks. Generated-by: Claude Opus 5 --- .github/labeler.yml | 1 + docs/labels-and-capabilities.md | 3 +- docs/release-management/spec.md | 7 +- docs/vendor-neutrality.md | 5 +- .../skills/announce-draft/SKILL.md | 111 +-- .../skills/archive-sweep/SKILL.md | 45 +- .../skills/audit-report/SKILL.md | 47 +- .../skills/keys-sync/SKILL.md | 37 +- .../skills/prepare/SKILL.md | 77 +- .../skills/promote/SKILL.md | 135 +-- .../skills/rc-cut/SKILL.md | 126 +-- .../skills/verify-rc/SKILL.md | 46 +- .../skills/vote-draft/SKILL.md | 69 +- .../skills/vote-tally/SKILL.md | 61 +- .../magpie-setup/templates/release-build.md | 17 + .../templates/release-management-config.md | 9 +- pyproject.toml | 1 + tools/release-config/README.md | 133 +++ tools/release-config/pyproject.toml | 90 ++ .../src/release_config/__init__.py | 17 + .../release-config/src/release_config/cli.py | 112 +++ .../src/release_config/config.py | 305 +++++++ .../src/release_config/mdconfig.py | 536 ++++++++++++ .../src/release_config/preflight.py | 705 ++++++++++++++++ .../src/release_config/versions.py | 99 +++ tools/release-config/tests/conftest.py | 188 +++++ tools/release-config/tests/test_load.py | 270 ++++++ tools/release-config/tests/test_preflight.py | 775 ++++++++++++++++++ tools/skill-evals/README.md | 2 +- .../fixtures/case-1-clean-pass/report.md | 33 +- .../fixtures/case-2-not-promoted/report.md | 34 +- .../case-3-promote-wait-active/report.md | 34 +- .../case-3-non-asf-github-releases/report.md | 4 +- .../fixtures/case-1-clean-pass/report.md | 30 +- .../case-2-retention-rule-error/report.md | 30 +- .../fixtures/case-3-missing-config/report.md | 29 +- .../fixtures/case-4-atr-backend/report.md | 28 +- .../fixtures/case-1-clean-pass/report.md | 23 +- .../case-2-audit-log-path-missing/report.md | 25 +- .../case-3-planning-issue-not-found/report.md | 23 +- .../fixtures/case-1-clean-pass/report.md | 21 +- .../fixtures/case-2-already-in-keys/report.md | 21 +- .../fixtures/case-3-key-rolled/report.md | 21 +- .../evals/release-prepare/README.md | 3 +- .../fixtures/case-1-clean-pass/report.md | 28 +- .../fixtures/case-2-missing-train/report.md | 29 +- .../case-3-prep-no-planning-issue/report.md | 26 +- .../report.md | 34 +- .../report.md | 30 +- .../case-6-automated-signing-asf/report.md | 33 +- .../fixtures/case-1-clean-pass/report.md | 36 +- .../fixtures/case-2-not-vote-passed/report.md | 34 +- .../fixtures/case-3-non-pmc-rm/report.md | 41 +- .../case-4-target-url-exists/report.md | 38 +- .../step-0-preflight/fixtures/output-spec.md | 3 +- .../fixtures/case-2-github-releases/report.md | 2 +- .../case-5-convenience-artefacts/report.md | 28 + .../evals/release-rc-cut/README.md | 9 +- .../fixtures/case-1-clean-pass/report.md | 31 +- .../case-2-prep-pr-not-merged/report.md | 31 +- .../fixtures/case-3-rc-tag-exists/report.md | 31 +- .../case-4-archive-unreviewed/report.md | 39 +- .../fixtures/case-5-rc0-blocks/expected.json | 6 + .../fixtures/case-5-rc0-blocks/report.md | 35 + .../fixtures/case-1-clean-pass/report.md | 29 +- .../fixtures/case-2-rc-not-staged/report.md | 30 +- .../fixtures/case-3-config-missing/report.md | 27 +- .../fixtures/case-1-clean-pass/report.md | 23 +- .../case-2-verify-rc-not-run/report.md | 23 +- .../case-3-expedited-no-flag/report.md | 25 +- .../evals/release-vote-tally/README.md | 2 +- .../fixtures/case-1-clean-pass/report.md | 33 +- .../case-2-window-not-elapsed/report.md | 35 +- .../expected.json | 2 +- .../report.md | 38 +- uv.lock | 24 +- 76 files changed, 4570 insertions(+), 653 deletions(-) create mode 100644 tools/release-config/README.md create mode 100644 tools/release-config/pyproject.toml create mode 100644 tools/release-config/src/release_config/__init__.py create mode 100644 tools/release-config/src/release_config/cli.py create mode 100644 tools/release-config/src/release_config/config.py create mode 100644 tools/release-config/src/release_config/mdconfig.py create mode 100644 tools/release-config/src/release_config/preflight.py create mode 100644 tools/release-config/src/release_config/versions.py create mode 100644 tools/release-config/tests/conftest.py create mode 100644 tools/release-config/tests/test_load.py create mode 100644 tools/release-config/tests/test_preflight.py create mode 100644 tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/expected.json create mode 100644 tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/report.md diff --git a/.github/labeler.yml b/.github/labeler.yml index e56c1f9d4..b16b35f30 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -181,6 +181,7 @@ substrate:release: - changed-files: - any-glob-to-any-file: - 'tools/maven-artifact-verify/**' + - 'tools/release-config/**' - 'tools/reproducible-archive/**' substrate:review: diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index 88ef785a8..3a4cec6c8 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -147,7 +147,7 @@ framework substrate: | `substrate:action-guard` | substrate | Deterministic pre-tool-use command guards. | | `substrate:privacy` | substrate | PII redaction / approved-LLM gating. | | `substrate:framework-dev` | substrate | Build / validate / eval the framework itself. | -| `substrate:release` | substrate | Release-artefact helpers an adopter's release process runs: reproducible-archive build, lint and comparison; maven-artifact-verify for staged JVM artefacts. | +| `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; release-config loading and pre-flight checks. | | `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/spec-status-index`](../tools/spec-status-index/) | `substrate:framework-dev` + `substrate:analytics` | Index of spec / RFC implementation status — framework-dev substrate that also doubles as a governance/stats view (`analytics`) | | [`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/release-config`](../tools/release-config/) | `substrate:release` | `release-config` — loads the release-management config (`` layered lookup, defaults, derived values) and runs each `release-*` skill's deterministic Step 0 pre-flight checks as JSON; used by every `release-*` skill's Step 0 and the Step 1 metadata of `rc-cut`, `vote-draft`, `announce-draft` and `promote` | | [`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 | diff --git a/docs/release-management/spec.md b/docs/release-management/spec.md index badbac5bd..213a662cd 100644 --- a/docs/release-management/spec.md +++ b/docs/release-management/spec.md @@ -856,14 +856,15 @@ backend table in [`projects/_template/release-management-config.md`](../../plugins/magpie-setup/templates/release-management-config.md)) are required only when the corresponding backend is selected. -ASF TLP releases are pinned to `release_approval_mechanism = +Releases of an ASF project (`project.md` → `organization: ASF`) are +pinned to `release_approval_mechanism = dev-list-vote` (mandatory per [`release-policy.html § release approval`](https://www.apache.org/legal/release-policy.html#release-approval)) and `release_announce_backend = announce-list` (mandatory per [`release-policy.html § announcements`](https://www.apache.org/legal/release-policy.html#release-announcements)). `release-vote-tally` and `release-announce-draft` refuse to run an -ASF TLP release against any other value; non-ASF adopters set the -keys their workflow uses. +ASF release against any other value; non-ASF adopters set the +keys their workflow uses (and may not use `announce-list`). ## Eval diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index 62a40b7f4..c124f9574 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -603,7 +603,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 16, agnostic = 62. **LLM / agent-integration neutrality** -**Agent harness: 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. +**Agent harness: 31/31 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 | |---|---|---|---| @@ -622,6 +622,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 16, agnostic = 62. | `preflight-audit` | analytics | any | ✅ agnostic | | `privacy-llm` | privacy | any | ✅ agnostic | | `probe-templates` | sandbox | any | ✅ agnostic | +| `release-config` | release | any | ✅ agnostic | | `reproducible-archive` | release | any | ✅ agnostic | | `sandbox-lint` | sandbox | Claude Code, Codex, Cursor, Gemini CLI, Kiro, OpenCode | ✅ portable | | `security-tracker-stats-dashboard` | analytics | any | ✅ agnostic | @@ -646,7 +647,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** (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` +- **any harness** (28): `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`, `release-config`, `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/announce-draft/SKILL.md b/plugins/magpie-release-management/skills/announce-draft/SKILL.md index d82f7c8f2..34a675a4d 100644 --- a/plugins/magpie-release-management/skills/announce-draft/SKILL.md +++ b/plugins/magpie-release-management/skills/announce-draft/SKILL.md @@ -25,7 +25,7 @@ argument-hint: " [--planning-issue ]" capability: capability:resolve surface_hash: sha256:edffafcd9d9948ab license: Apache-2.0 -measured_tokens: 6928 +measured_tokens: 6925 --- @@ -152,9 +152,10 @@ not attribute an implicit `+1` to the Release Manager. require 5 binding `+1`). Attempts to weaken it are a hard blocker. **Golden rule 6 — ASF TLP pinning.** For ASF TLP releases -(`is_asf_tlp: true` in `/release-management-config.md`), +(a project whose `project.md` declares `organization: ASF` — the one +ASF-identity rule; there is no separate `is_asf_tlp` key), the `release_approval_mechanism` must be `dev-list-vote`. The skill -refuses to tally any other mechanism for an ASF TLP release. +refuses to tally any other mechanism for an ASF project. --- @@ -182,8 +183,10 @@ override file. Framework changes go via PR to - **Planning issue open** and labelled `vote-open` (or the RM provides the planning issue URL explicitly). - **`/release-management-config.md` readable** — - `release_approval_mechanism`, `release_approver_roster_path`, - `vote_window_hours`, `result_subject_template`. + `release_approval_mechanism`, `vote_window_hours`, + `result_subject_template`, and optionally + `release_approver_roster_path` (default + `/pmc-roster.md`). - **Approver roster readable** at ``. - **Approval signal available** — PonyMail thread, GitHub Discussion, PR reviews, or maintainer-roster file, depending on the configured @@ -195,7 +198,7 @@ override file. Framework changes go via PR to | Selector | Resolves to | |---|---| -| `-rcN` (positional) | RC identifier; must match the planning issue | +| `-rcN` (positional) | RC identifier (a dotted version of two or more numeric parts with no `.postN`, then `-rcN` with N ≥ 1, e.g. `2.11.0-rc2`); must match the planning issue | | `--force-close ` | Proceed even if the window has not elapsed; reason logged in outputs | | `--planning-issue ` | Explicit planning issue URL (auto-detected if omitted) | @@ -203,28 +206,36 @@ override file. Framework changes go via PR to ## Step 0 — Pre-flight check -1. **RC identifier parseable.** `-rcN` matches the expected - pattern (`X.Y.Z-rcN` or `X.Y.Z.post0-rcN`). -2. **Planning issue found.** Either `--planning-issue ` was +Run the deterministic checks with the +[`release-config`](../../../../tools/release-config/README.md) tool, +passing the time the `[VOTE]` thread opened, read from the planning +issue: + +```bash +uv run --project /tools/release-config release-config preflight \ + --skill vote-tally -rcN --vote-opened [--force-close ] +``` + +It covers the RC identifier format (see *Inputs*), the +required config keys, the pinning of an ASF project (`project.md` → +`organization: ASF`) to `dev-list-vote`, the roster at +`release_approver_roster_path` and the approval window, +and prints `{"ok", "blockers", "warnings", "values"}`. +Each `blockers` entry is a hard blocker; surface it as written. +Surface `warnings` and carry on. +Copy `force_close`, `mechanism` and `roster_path` from `values`. + +Then check what the tool cannot see: + +1. **Planning issue found.** Either `--planning-issue ` was passed or the skill finds an open planning issue on `` labelled `vote-open` and matching `` in its title. -3. **`release-management-config.md` readable.** The required keys - (`release_approval_mechanism`, `release_approver_roster_path`, - `result_subject_template`) are present. -4. **ASF TLP pinning.** If `is_asf_tlp: true`, the - `release_approval_mechanism` must be `dev-list-vote`. Any other - value is a hard blocker for ASF TLP releases. -5. **Roster readable.** The file at `` exists - and contains at least one row. -6. **Vote window elapsed.** The configured approval window has elapsed - since the `[VOTE]` thread was opened (read from the planning issue), - **or** `--force-close ` was passed. -7. **No unresolved ambiguous votes from a previous partial run.** If +2. **No unresolved ambiguous votes from a previous partial run.** If the planning issue already has an `AMBIGUOUS` note from a previous `release-vote-tally` run, surface it and ask whether to re-run from scratch or resolve inline. -8. **Drift check** — the generated pre-flight block reports snapshot drift. -9. **Override consultation** — see *Adopter overrides* above. +3. **Drift check** — the generated pre-flight block reports snapshot drift. +4. **Override consultation** — see *Adopter overrides* above. If any check fails (and is not overridden), stop and surface what is missing. @@ -522,7 +533,7 @@ The AI-driven part ends with a hand-back artefact containing: | Symptom | Likely cause | Remediation | |---|---|---| | Pre-flight blocked — window not elapsed | Vote opened recently | Wait, or pass `--force-close` with a reason | -| Pre-flight blocked — ASF TLP + non-list mechanism | `release_approval_mechanism` is not `dev-list-vote` for an ASF TLP | Fix the config or confirm this is not an ASF TLP release | +| Pre-flight blocked — ASF project + non-list mechanism | `release_approval_mechanism` is not `dev-list-vote` while `project.md` declares `organization: ASF` | Fix `release_approval_mechanism`, or correct `organization` in `project.md` if the project is not an ASF one | | Roster member not found for a vote | Email in the thread does not match roster | RM updates the roster or provides a handle mapping | | Ambiguous vote halts tally | Conditional or retracted reply in the thread | RM resolves on the thread, then re-runs; or passes `--force-close` | | Pass rule override weakens baseline | `vote_pass_rule_overrides` sets a lower threshold than ASF baseline | Fix the config (baseline is a floor, not a ceiling) | diff --git a/plugins/magpie-setup/templates/release-build.md b/plugins/magpie-setup/templates/release-build.md index a87f0ab63..9f2a1c47e 100644 --- a/plugins/magpie-setup/templates/release-build.md +++ b/plugins/magpie-setup/templates/release-build.md @@ -119,6 +119,8 @@ One entry per artefact: convenience_artefacts: - name: apache---bin.tar.gz # filename as staged; is rendered kind: binary-tarball # binary-tarball | wheel | sdist | jar | container-image | helm-chart | npm-package | other + version: null # optional: this artefact's own version (default: the release version; `` is rendered), e.g. `.post1` + version_scheme: null # optional: how this artefact's ecosystem versions it, e.g. `python`; see "Versioning" below build_command: | # run at the release tag, SOURCE_DATE_EPOCH exported; must be deterministic mvn -Papache-release -Dproject.build.outputTimestamp="${SOURCE_DATE_EPOCH}" -DskipTests clean package staging: dist-dev # dist-dev (alongside the source, default) | atr | registry-staging @@ -141,6 +143,21 @@ How each skill uses the list: | `release-promote` | After the source promotion and the final tag, emits each `publish_command` — only for artefacts whose verify-rc rebuild reproduced | | `release-announce-draft` | Names the channels the artefacts were published to | +**Versioning.** The source release has one version format in every +`release-*` skill: a dotted version of two or more numeric parts with +no `.postN` (`2.11.0`), and an RC suffix `rcN` with N ≥ 1, tagged +`-rcN`. A convenience artefact may carry its own version +(`version`, default the release version) — e.g. a Python wheel +re-released as `2.10.5.post1` against source release `2.10.5` — and +that version follows its ecosystem's rules, which `version_scheme` +names: + +| `version_scheme` | Validated by the skills' `release-config` pre-flight | +|---|---| +| `python` | the artefact's `version` is a dotted version with an optional `.postN` (`2.11.0`, `2.11.0.post1`), RC `rcN` with N ≥ 1; anything else blocks | +| any other name (`maven`, `npm`, …) | accepted but not validated: the pre-flight reports it as unvalidated and the Release Manager confirms the version | +| absent | not validated; the pre-flight warns and suggests setting the field | + Keep `expected_artefacts` (below) in sync: it is the flat list of filenames the RC stages (source + every convenience artefact whose `staging` is `dist-dev` or `atr`). diff --git a/plugins/magpie-setup/templates/release-management-config.md b/plugins/magpie-setup/templates/release-management-config.md index 89d9337e6..9b0a49290 100644 --- a/plugins/magpie-setup/templates/release-management-config.md +++ b/plugins/magpie-setup/templates/release-management-config.md @@ -174,8 +174,11 @@ See ### Automated release signing (🪶 ASF-specific, optional) -Offered only when `project.md` declares `organization: ASF`; the -skills do not mention it to other adopters. Under +Offered only when the project's organization offers it: the +organization manifest key `release_process.automated_signing`, +resolved `project.md` → organization manifest → framework default +(not offered). Of the shipped organizations only `organizations/ASF/` +sets it; the skills do not mention it to other adopters. Under [release-signing § Automated release signing](https://infra.apache.org/release-signing.html#automated-release-signing) an ASF project may let CI (GitHub Actions) sign the artefacts it builds with an **Infra-provisioned key** (4096-bit RSA, signing-only, @@ -215,7 +218,7 @@ variants* below). | `vote_pass_rule_overrides` | *(none, uses ASF baseline: 3 binding +1 minimum, more binding +1 than -1)* | | `vote_subject_template` | `[VOTE] Release from -rcN` | | `result_subject_template` | `[RESULT] [VOTE] Release from -rcN` | -| `release_approver_roster_path` | `/pmc-roster.md` *(ASF default); non-ASF: e.g. `/release-approvers.md`)* | +| `release_approver_roster_path` | `/pmc-roster.md` *(the default when unset; read by `release-vote-tally`, `release-promote` and `release-audit-report`; non-ASF: e.g. `/release-approvers.md`)* | | `vote_verification_doc_url` | `https://github.com//blob/-rcN/docs/verifying-a-release-candidate.md` — the human-readable "how to verify this RC" page; `-rcN` is rendered so voters read the page at the tree under vote | | `reproducibility_doc_url` | `https://github.com//blob/-rcN/.apache-magpie/docs/release-management/reproducibility.md` — or the framework copy on `apache/magpie` | | `vote_verification_skill` | `magpie-release-management:verify-rc` — the agentic one-liner the `[VOTE]` body offers voters | diff --git a/pyproject.toml b/pyproject.toml index b09425d0c..d95a11ea0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,7 @@ members = [ "tools/preflight-audit", "tools/privacy-llm/checker", "tools/privacy-llm/redactor", + "tools/release-config", "tools/reproducible-archive", "tools/sandbox-lint", "tools/security-tracker-stats-dashboard", diff --git a/tools/release-config/README.md b/tools/release-config/README.md new file mode 100644 index 000000000..98f9a30ff --- /dev/null +++ b/tools/release-config/README.md @@ -0,0 +1,133 @@ + + + + + +- [`release-config`](#release-config) + - [Prerequisites](#prerequisites) + - [How to use](#how-to-use) + - [`preflight` — a skill's deterministic Step 0 checks](#preflight--a-skills-deterministic-step-0-checks) + - [`load` — the parsed config and a skill's Step 1 metadata](#load--the-parsed-config-and-a-skills-step-1-metadata) + - [What it reads](#what-it-reads) + - [Shared rules](#shared-rules) + - [Per-skill rules](#per-skill-rules) + - [Wiring](#wiring) + - [Tests](#tests) + + + +# `release-config` + +**Capability:** substrate:release + +**Harness:** agnostic + +Loads the release-management configuration an adopter keeps in `` and runs the **deterministic** part of each `release-*` skill's `Step 0 — Pre-flight check`, printing JSON. +Required keys, argument formats, the digest-set rule, the source-archive review gate, signing-mode consistency, the vote-window floor, the promote-wait hour, the approver-roster lookup and the dist-URL rendering are all fixed rules; running them as code keeps them out of the model's context and makes them give the same answer every time. +Checks that need GitHub, the network or judgement — finding the planning issue, reading its labels, whether the RC tag exists, whether the staging URL answers, what the `KEYS` file holds — stay in the skill. + +## Prerequisites + +- **Runtime:** Python 3.11+, stdlib only; run via `uv run --project /tools/release-config`. +- **CLIs:** None beyond the runtime. +- **Credentials / auth:** None. It reads the RM's `user.md` only for `apache_id` and `release_manager.gpg_fingerprint`. +- **Network:** None — reads local markdown files only. + +## How to use + +Run it from the adopter repo root (or pass `--project-root`). +`` resolves per file, local first: `.apache-magpie-local/`, then `.apache-magpie-overrides/`; `--config-dir ` reads every file from one directory instead. +The skill's positional arguments and flags are passed through as the RM typed them; facts the skill read from the planning issue are passed as flags (`--promote-timestamp`, `--download-page`, `--vote-opened`, `--verify-binary`). +The command always exits `0` once it has printed JSON; `2` is a usage error. + +### `preflight` — a skill's deterministic Step 0 checks + +```bash +uv run --project /tools/release-config release-config preflight --skill rc-cut 2.12.0 rc1 +``` + +```json +{ + "ok": false, + "skill": "rc-cut", + "blockers": ["export_ignore_reviewed is unset in release-build.md § Source archive (archive_reviewed: false): run `release-prepare prep 2.12.0` — …"], + "warnings": [], + "values": {"version": "2.12.0", "rc_number": "rc1", "archive_reviewed": false, "signing_mode": "rm-key", "staging_url": "…", "rc_tag": "2.12.0-rc1"} +} +``` + +`blockers` are hard stops, worded for the RM; `warnings` are surfaced and do not stop the skill; `values` carry what the skill's Step 0 JSON reports (`archive_reviewed`, `non_asf`, `dist_backend`, `rm_is_pmc`, `staging_url`, `promote_clear_after_utc`, …), plus `convenience_versions` (each convenience artefact's `version`, `version_scheme` and `valid` / `invalid` / `unvalidated`) for the skills that handle artefacts. + +### `load` — the parsed config and a skill's Step 1 metadata + +```bash +uv run --project /tools/release-config release-config load --skill promote 2.11.0-rc1 \ + --verify-binary apache-foo-2.11.0-bin.tar.gz=identical +``` + +Prints `sources` (which file each config came from), `config` (the `release-management-config.md` keys with defaults applied), `build` (`release-build.md`: build command, expected artefacts, digest set, source-archive and reproducibility keys, convenience artefacts), `derived` (`signing_mode`, `is_asf`, `non_asf`, `automated_signing_offered` and the file that decided it, `approver_roster_path`, `release_lines`) and, with `--skill`, `metadata` — the config-derived fields of that skill's Step 1 table under the names the table uses. +For `promote`, `metadata.convenience` splits the convenience artefacts into `publish` (verify-rc reported `identical` or `warn`) and `held` (`differs`, or no result). + +## What it reads + +| File | Parsed as | +|---|---| +| `release-management-config.md` | every `` | `key` | value | `` row; `*(…)*` notes mean unset; two or more backticked values make a list | +| `release-build.md` | the same tables, plus § Build invocation (first unquoted fence), § Expected artefact list and § Digest set (first prose or bullet block; a `TODO:` block is unset), § Convenience artefacts (the unquoted `yaml` fence, including each entry's optional `version` and `version_scheme`; the template's `` example entry is ignored) | +| `release-trains.md` | the bullets of § Release branches currently in flight | +| `pmc-roster.md` / `release_approver_roster_path` | the first table with an Apache ID, email or handle column; `TODO` rows do not count | +| `project.md` | `organization` (default `independent`); a `release_process.automated_signing` YAML key, if the project overrides its organization | +| `organizations//organization.md` | `release_process.automated_signing` — the framework's in-tree manifest, then the adopter-local `/organizations//organization.md` | +| `user.md` | `apache_id`, `release_manager.gpg_fingerprint` | + +Blockquoted examples and `Template guidance` / `Example shape` lead-ins are never read as values. + +## Shared rules + +Rules more than one skill applies have one implementation, so every skill gives the same answer: + +- **Source version and RC.** The source release candidate every skill takes (`` plus `rcN`, tagged `-rcN`): `` is a dotted version of two or more numeric parts with no `.postN` (`2.11`, `2.11.0`, `2.11.0.1`); the RC is `rcN` with N ≥ 1 (`rc0` and `rc01` block). + `rc-cut`, `verify-rc`, `vote-draft`, `vote-tally` and `promote` check the RC; `announce-draft`, `audit-report` and `prepare` check the version alone. +- **Convenience-artefact versions.** Each § Convenience artefacts entry may carry its own `version` (default the release version; `` in it is rendered, e.g. `.post1`), validated by the entry's optional `version_scheme`: a wheel `2.10.5.post1` passes against source release `2.10.5`. + `promote` renders the artefact's name and `publish_command` with that version. + `versions.SCHEMES` is the registry of validated schemes: `python` (a dotted version with an optional `.postN`, RC `rcN` with N ≥ 1). + An invalid version under a validated scheme is a blocker; any other scheme name is accepted and reported as `unvalidated` with a warning, so the RM decides; an absent field is `unvalidated` with a warning suggesting the field. + Add a scheme by adding a `Scheme` to `SCHEMES`. + `rc-cut`, `verify-rc` and `promote` run the check. +- **Approver roster.** One key, `release_approver_roster_path`, default `/pmc-roster.md`, read by `promote`, `vote-tally` and `audit-report`. + `vote-tally` and `audit-report` block when it is unreadable or has no rows; `promote` treats an unreadable roster as a hand-off. +- **`keyserver`.** Defaults to `keys.openpgp.org` everywhere; no skill requires it. +- **ASF identity and `non_asf`.** `project.md` → `organization: ASF` is the only ASF test; `non_asf` is its negation in every skill (there is no `--non-asf` flag and no `is_asf_tlp` key). + An ASF project must use `release_approval_mechanism: dev-list-vote` and `release_announce_backend: announce-list`; a non-ASF project may not use `announce-list`. +- **Automated signing.** Offered only when the organization manifest key `release_process.automated_signing` resolves to a value: `project.md` → `organizations//organization.md` (in-tree, then adopter-local) → framework default (not offered). + `prepare automated-signing` blocks otherwise, and `signing_mode` is `ci-automated` only for `automated_release_signing: enabled` where it is offered. +- **Archive destination.** `archive_url_template`, defaulting to `https://archive.apache.org/dist//` (from `project_dist_name`) for both ASF backends, `svnpubsub` and `atr`; other backends need `archive_url_template`. + +## Per-skill rules + +| Skill | Deterministic checks | +|---|---| +| `rc-cut` | source `` and `rcN`; `release-build.md` declares a build command and non-empty expected artefacts and digest set; `release_dist_backend`, `release_dist_url_template` present; digest set has `sha512`, no `md5` / `sha1`; `export_ignore_reviewed` set unless `source_archive_method: custom` or `--allow-unreviewed-archive`; with `signing_mode: ci-automated`, `reproducibility_source: on` and every convenience binary `byte-identical`; convenience-artefact versions | +| `vote-draft` | source `-rcN`; `vote_window_hours`, `vote_dev_list` present; window ≥ 72 unless `--expedited` | +| `vote-tally` | source `-rcN`; `release_approval_mechanism`, `result_subject_template` present; ASF pinned to `dev-list-vote`; the approver roster has a row; window (`vote_window_hours`, or `approval_window_hours` for other mechanisms) elapsed since `--vote-opened` unless `--force-close` | +| `announce-draft` | source ``; `announce_list`, `announce_subject_template` present; `announce-list` if and only if ASF; promote timestamp given; one hour since it unless `--skip-promote-wait`; a Download Page URL (`--download-page` or `download_page_url`) | +| `promote` | source `-rcN`; `release_dist_backend`, `release_dist_url_template` present; for an ASF project the RM on the approver roster (not on it → `rm_is_pmc: false`, a hand-off, not a blocker); reports whether the trusted-hardware attestation is required; convenience-artefact versions | +| `verify-rc` | source `-rcN`; `keys_file_url`, `release_dist_url_template`, `version_manifest_files` present; the four `release-build.md` sections exist; the staging URL renders well-formed; reports `keyserver`; convenience-artefact versions | +| `archive-sweep` | `archive_retention_rule`, `release_dist_backend`, `release_dist_url_template` present; `release-trains.md` lists a release line; backend is a known one; archive destination derivable | +| `audit-report` | source ``; `audit_log_path` present; the approver roster has a row | +| `keys-sync` | fingerprint from `--fingerprint`, then `rm_key_fingerprint`, then `user.md`; `keys_file_url` from `--keys-url` or config; `keyserver` from `--keyserver` or config | +| `prepare` | sub-command parsed; `automated-signing` only where the organization offers it; source ``; `release_branch_base`, `version_manifest_files` present; `release-trains.md` readable | + +## Wiring + +Every `release-*` skill's `Step 0 — Pre-flight check` runs `release-config preflight --skill …`; `rc-cut`, `vote-draft`, `announce-draft` and `promote` read their Step 1 config fields from `release-config load --skill …`. +The workspace hooks (`ruff`, `mypy`, `pytest`) pick the project up from the root `pyproject.toml` `[tool.uv.workspace] members` list. + +## Tests + +```bash +uv run --project tools/release-config python -m pytest +``` + +Every skill has a passing configuration and one case per blocker, and every shared rule has cases across the skills that apply it; `load` is covered for the layered file lookup, defaults, URL rendering, the convenience-artefact split, and the shipped templates parsing as unset rather than as values. diff --git a/tools/release-config/pyproject.toml b/tools/release-config/pyproject.toml new file mode 100644 index 000000000..72ac7c32b --- /dev/null +++ b/tools/release-config/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 = "release-config" +version = "0.1.0" +description = "Load the release-management configuration and run each release-* skill's deterministic Step 0 pre-flight checks, emitting JSON." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# stdlib-only on purpose: it parses the adopter's markdown config files and +# applies fixed rules, nothing more. +dependencies = [] + +[project.scripts] +release-config = "release_config.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/release_config"] + +[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.*", "conftest", "test_load", "test_preflight"] +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/release-config/src/release_config/__init__.py b/tools/release-config/src/release_config/__init__.py new file mode 100644 index 000000000..6720c4242 --- /dev/null +++ b/tools/release-config/src/release_config/__init__.py @@ -0,0 +1,17 @@ +# 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. +"""Load the release-management config and run the release-* skills' Step 0 checks.""" diff --git a/tools/release-config/src/release_config/cli.py b/tools/release-config/src/release_config/cli.py new file mode 100644 index 000000000..43eb45d41 --- /dev/null +++ b/tools/release-config/src/release_config/cli.py @@ -0,0 +1,112 @@ +# 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. +"""`release-config load|preflight` — JSON on stdout, exit 0 whenever JSON was written.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from release_config.config import load +from release_config.preflight import SKILLS, metadata, normalise_skill, preflight + + +def _parser() -> argparse.ArgumentParser: + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--project-root", default=".", help="adopter repo root holding .apache-magpie-local/ and .apache-magpie-overrides/ (default: cwd)") + common.add_argument("--config-dir", help="read every config file from this one directory instead of the layered lookup") + common.add_argument( + "--user-config", help="user.md to read (default: $APACHE_MAGPIE_USER_CONFIG, ~/.config/apache-magpie/user.md, /user.md)" + ) + common.add_argument("--skill", help=f"one of: {', '.join(SKILLS)} (a release- / magpie- prefix is accepted)") + common.add_argument("args", nargs="*", help="the skill's positional arguments, as the RM typed them") + # The skills' own flags. Planning-issue facts the skill read are passed as flags too. + for flag in ("--planning-issue", "--release-branch", "--remote", "--previous-tag", "--result-vote-url", "--post-to"): + common.add_argument(flag) + for flag in ( + "--allow-unreviewed-archive", + "--skip-repro-check", + "--skip-repro", + "--trusted-hardware", + "--skip-empty-check", + "--review-archive", + ): + common.add_argument(flag, action="store_true") + common.add_argument("--skip-verify-check", metavar="REASON") + common.add_argument("--expedited", metavar="REASON") + common.add_argument("--skip-promote-wait", metavar="REASON") + common.add_argument("--force-close", metavar="REASON") + common.add_argument("--promote-timestamp", metavar="ISO-8601", help="announce-draft: promote commit time from the planning issue") + common.add_argument("--download-page", metavar="URL") + common.add_argument("--vote-opened", metavar="ISO-8601", help="vote-tally: when the [VOTE] thread opened, from the planning issue") + common.add_argument("--now", metavar="ISO-8601", help="override the current UTC time (tests, evals)") + common.add_argument("--rm", metavar="ID", help="promote: the RM's Apache ID, email or GitHub handle for the PMC gate") + common.add_argument("--verify-binary", action="append", metavar="NAME=STATUS", help="promote: verify-rc Step 9 result per convenience artefact") + common.add_argument("--fingerprint") + common.add_argument("--keys-url") + common.add_argument("--keyserver") + + parser = argparse.ArgumentParser(prog="release-config", description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("load", parents=[common], help="parsed config, defaults applied, derived values; with --skill, that skill's Step 1 metadata") + sub.add_parser("preflight", parents=[common], help="the skill's deterministic Step 0 checks: {ok, blockers, warnings, values}") + return parser + + +def run(argv: list[str] | None = None) -> dict[str, Any]: + parser = _parser() + args = parser.parse_args(argv) + try: + skill = normalise_skill(args.skill) if args.skill else None + except ValueError as exc: + parser.error(str(exc)) + if args.command == "preflight" and skill is None: + parser.error("preflight needs --skill") + cfg = load(Path(args.project_root).resolve(), Path(args.config_dir).resolve() if args.config_dir else None, args.user_config) + if args.command == "preflight": + assert skill is not None + return preflight(skill, cfg, args) + return { + "skill": skill, + "sources": cfg.sources, + "organization": cfg.organization, + "config": cfg.config_with_defaults(), + "build": cfg.build.as_dict(), + "derived": { + "signing_mode": cfg.signing_mode, + "is_asf": cfg.is_asf, + "non_asf": cfg.non_asf, + "automated_signing_offered": cfg.automated_signing_offered, + "automated_signing_source": cfg.automated_signing_source, + "approver_roster_path": cfg.approver_roster_path, + "release_lines": cfg.release_lines(), + }, + "metadata": metadata(skill, cfg, args), + } + + +def main(argv: list[str] | None = None) -> int: + json.dump(run(argv), sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/release-config/src/release_config/config.py b/tools/release-config/src/release_config/config.py new file mode 100644 index 000000000..0d813d850 --- /dev/null +++ b/tools/release-config/src/release_config/config.py @@ -0,0 +1,305 @@ +# 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. +"""The loaded release configuration: raw values, defaults, derived values.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from release_config import mdconfig as md + +RMC = "release-management-config.md" +BUILD = "release-build.md" +TRAINS = "release-trains.md" +PMC_ROSTER = "pmc-roster.md" +PROJECT = "project.md" + +# Defaults the templates and skills state for optional keys. Required keys +# (checked per skill) never get a default. +CONFIG_DEFAULTS: dict[str, Any] = { + "git_upstream_remote": "origin", + "release_vote_backend": "manual", + "keyserver": "keys.openpgp.org", + "automated_release_signing": "off", + "vote_verification_skill": "magpie-release-management:verify-rc", + "release_approver_roster_path": "/pmc-roster.md", +} +DIST_BACKENDS = ("svnpubsub", "atr", "github-releases", "s3", "self-hosted") +# The ASF distribution backends; both mirror dist/release to archive.apache.org. +ASF_DIST_BACKENDS = ("svnpubsub", "atr") +ASF_ARCHIVE_DEFAULT = "https://archive.apache.org/dist//" +ORG_MANIFEST = "organization.md" + +_DIGEST = re.compile(r"^(sha\d+|sha3-\d+|sha-\d+|md5|sha1|blake2\w*)$", re.IGNORECASE) + + +@dataclass +class BuildConfig: + found: bool + table: dict[str, Any] = field(default_factory=dict) + sections: dict[str, bool] = field(default_factory=dict) + build_command: str | None = None + build_command_declared: bool = False + expected_artefacts: list[dict[str, str]] = field(default_factory=list) + digest_set: list[str] = field(default_factory=list) + convenience_artefacts: list[dict[str, Any]] = field(default_factory=list) + template_example_skipped: bool = False + + def value(self, key: str, default: Any = None) -> Any: + value = self.table.get(key) + return default if value in (None, "", []) else value + + @property + def source_archive_method(self) -> str: + return str(self.value("source_archive_method", "git-archive")) + + @property + def reproducibility_source(self) -> str: + default = "on" if self.source_archive_method == "git-archive" else "off" + return str(self.value("reproducibility_source", default)) + + @property + def reproducibility_binaries(self) -> str: + return str(self.value("reproducibility_binaries", "off")) + + def as_dict(self) -> dict[str, Any]: + return { + "found": self.found, + "sections": self.sections, + "build_command": self.build_command, + "expected_artefacts": self.expected_artefacts, + "digest_set": self.digest_set, + "source_archive_method": self.source_archive_method, + "source_archive_format": str(self.value("source_archive_format", "tar.gz")), + "source_archive_prefix": self.value("source_archive_prefix"), + "export_ignore_reviewed": self.value("export_ignore_reviewed"), + "reproducibility_source": self.reproducibility_source, + "reproducibility_binaries": self.reproducibility_binaries, + "convenience_artefacts": self.convenience_artefacts, + } + + +def _expected_artefacts(block: str | None) -> list[dict[str, str]]: + """Filenames in backticks (no spaces, has a dot), kind from the words after each.""" + if not block: + return [] + spans = [(m.start(), m.end(), m.group(1).strip()) for m in re.finditer(r"`([^`]+)`", block)] + spans = [s for s in spans if " " not in s[2] and "." in s[2] and not s[2].startswith(("http", "-"))] + out: list[dict[str, str]] = [] + for index, (_, end, name) in enumerate(spans): + tail = block[end : spans[index + 1][0] if index + 1 < len(spans) else len(block)] + convenience = re.search(r"\bconvenience\b", tail, re.IGNORECASE) and not re.search(r"\bno convenience\b", tail, re.IGNORECASE) + out.append({"name": name, "kind": "convenience" if convenience else "source"}) + return out + + +def load_build(text: str | None) -> BuildConfig: + if text is None: + return BuildConfig(found=False) + build = BuildConfig(found=True, table=md.table_values(text)) + names = { + "source_archive": "Source archive", + "build_invocation": "Build invocation", + "convenience_artefacts": "Convenience artefacts", + "expected_artefacts": "Expected artefact", + "digest_set": "Digest set", + "reproducibility_checks": "Reproducibility checks", + "binary_exclude": "Binary-exclude", + "rat_configuration": "RAT configuration", + } + sections = {key: md.section_lines(text, title) for key, title in names.items()} + build.sections = {key: lines is not None for key, lines in sections.items()} + + # build_command: a table key, else the section's first unquoted fence. + if "build_command" in build.table: + build.build_command_declared = True + build.build_command = build.table["build_command"] or None + elif sections["build_invocation"] is not None: + build.build_command_declared = True + build.build_command = md.first_fence(sections["build_invocation"]) + + if build.table.get("expected_artefacts"): + build.expected_artefacts = [{"name": n, "kind": "source" if i == 0 else "convenience"} for i, n in enumerate(build.table["expected_artefacts"])] + else: + build.expected_artefacts = _expected_artefacts(md.first_text_block(sections["expected_artefacts"])) + + if build.table.get("digest_set"): + build.digest_set = [d.lower() for d in build.table["digest_set"]] + else: + tokens = md.backtick_tokens(md.first_text_block(sections["digest_set"])) + build.digest_set = [t.lower() for t in tokens if _DIGEST.match(t)] + + fence = md.first_fence(sections["convenience_artefacts"], "yaml") + entries = md.parse_yaml_list(fence, "convenience_artefacts") if fence else None + for entry in entries or []: + if "" in str(entry.get("name", "")): + build.template_example_skipped = True + continue + if "reproducibility" not in entry or entry["reproducibility"] in (None, ""): + entry["reproducibility"] = build.reproducibility_binaries + build.convenience_artefacts.append(entry) + return build + + +def parse_time(value: str) -> datetime: + """`2026-06-11 09:45 UTC`, `2026-06-11T09:45:00Z`, `…+00:00` → aware UTC datetime.""" + text = value.strip().replace(" UTC", "").replace("UTC", "").strip() + text = text.replace("Z", "+00:00") + if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?", text): + text = text.replace(" ", "T") + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def iso(moment: datetime) -> str: + return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def plus_one_hour(value: str) -> str: + return iso(parse_time(value) + timedelta(hours=1)) + + +def render_dist_url(template: str, version: str, rc: str | None, bucket: str) -> str: + """Render `release_dist_url_template` for staging (`rc` set) or the final release (`rc` None). + + `` → `dev` / `release`. A template that spells the RC itself + (`-`) gets the RC substituted there, or the `-` segment + stripped for the release URL; otherwise `` becomes `-rcN` + for staging and plain `` for the release. + """ + url = template.replace("", bucket) + rc_marker = re.search(r"-?|-?|-?rc", url) + if rc_marker: + marker = rc_marker.group(0) + replacement = ("-" + rc if marker.startswith("-") else rc) if rc else "" + return url.replace(marker, replacement).replace("", version) + return url.replace("", f"{version}-{rc}" if rc else version) + + +@dataclass +class Loaded: + resolver: md.Resolver + sources: dict[str, str | None] + raw: dict[str, Any] + build: BuildConfig + organization: str + trains_text: str | None + user_text: str | None + automated_signing_offered: bool = False + automated_signing_source: str = "framework default" + + def present(self, key: str) -> bool: + value = self.raw.get(key) + return value not in (None, "", []) + + def get(self, key: str) -> Any: + if self.present(key): + return self.raw[key] + return CONFIG_DEFAULTS.get(key) + + @property + def is_asf(self) -> bool: + """`project.md` → `organization: ASF` — the one ASF-identity rule.""" + return self.organization.upper() == "ASF" + + @property + def non_asf(self) -> bool: + return not self.is_asf + + @property + def approver_roster_path(self) -> str: + """`release_approver_roster_path`, default `/pmc-roster.md`.""" + return str(self.get("release_approver_roster_path")) + + @property + def signing_mode(self) -> str: + enabled = str(self.get("automated_release_signing")) == "enabled" + return "ci-automated" if self.automated_signing_offered and enabled else "rm-key" + + def release_lines(self) -> list[str]: + block = md.first_text_block(md.section_lines(self.trains_text, "Release branches currently in flight")) + if block is None and self.trains_text is not None and md.section_lines(self.trains_text, "Release branches") is None: + block = md.first_text_block(self.trains_text.splitlines()) + if not block: + return [] + lines = [part.strip() for part in re.split(r"(?:^|\s)[-*]\s+(?=\S)", block) if part.strip()] + return lines + + def config_with_defaults(self) -> dict[str, Any]: + merged = dict(CONFIG_DEFAULTS) + merged.update({k: v for k, v in self.raw.items() if v not in (None, "", [])}) + return merged + + +def _framework_roots(project_root: Path) -> list[Path]: + """Where the framework's `organizations/` may live: this checkout, the adopter's snapshot, the repo itself.""" + candidates = [Path(__file__).resolve().parents[4], project_root / ".apache-magpie", project_root] + return [root for root in candidates if (root / "organizations").is_dir()] + + +def org_manifest_chain(resolver: md.Resolver, organization: str) -> list[tuple[Path, str]]: + """The organization manifests in resolution order, each with a display label: in-tree, then adopter-local.""" + chain: list[tuple[Path, str]] = [] + rel = f"organizations/{organization}/{ORG_MANIFEST}" + for root in _framework_roots(resolver.project_root)[:1]: + path = root / rel + if path.is_file(): + chain.append((path, f"/{rel}")) + local = resolver.find(rel) + if local is not None: + chain.append((local, resolver.display(local) or str(local))) + return chain + + +def automated_signing(project_text: str | None, resolver: md.Resolver, organization: str) -> tuple[bool, str]: + """`release_process.automated_signing`: project.md → organization manifest → framework default (not offered).""" + declared = md.nested_key_declared(project_text, "release_process", "automated_signing") + if declared is not None: + return declared, "project.md" + for path, label in org_manifest_chain(resolver, organization): + declared = md.nested_key_declared(md.read_text(path), "release_process", "automated_signing") + if declared is not None: + return declared, label + return False, "framework default" + + +def load(project_root: Path, config_dir: Path | None, user_config: str | None) -> Loaded: + resolver = md.Resolver(project_root, config_dir) + paths = {name: resolver.find(name) for name in (RMC, BUILD, TRAINS, PMC_ROSTER, PROJECT)} + user_path = md.user_config_path(user_config, resolver) + texts = {name: md.read_text(path) for name, path in paths.items()} + sources = {name: resolver.display(path) for name, path in paths.items()} + sources["user.md"] = str(user_path) if user_path else None + organization = md.organization(texts[PROJECT]) + offered, offered_source = automated_signing(texts[PROJECT], resolver, organization) + return Loaded( + resolver=resolver, + sources=sources, + raw=md.table_values(texts[RMC]) if texts[RMC] is not None else {}, + build=load_build(texts[BUILD]), + organization=organization, + trains_text=texts[TRAINS], + user_text=md.read_text(user_path), + automated_signing_offered=offered, + automated_signing_source=offered_source, + ) diff --git a/tools/release-config/src/release_config/mdconfig.py b/tools/release-config/src/release_config/mdconfig.py new file mode 100644 index 000000000..e19929057 --- /dev/null +++ b/tools/release-config/src/release_config/mdconfig.py @@ -0,0 +1,536 @@ +# 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. +"""Locate and parse the adopter's release-management markdown config. + +The config files are prose documents with `| key | value |` tables, a few +free-form sections (`release-build.md § Digest set`, `§ Expected artefact +list`, `§ Build invocation`), one fenced YAML list +(`§ Convenience artefacts`) and a roster table (`pmc-roster.md`). Example +content is always blockquoted (`> …`) or follows a `Template guidance` / +`Example shape:` lead-in in the templates, so it is skipped. + +`` resolves **per file, local first**: +`.apache-magpie-local/`, then `.apache-magpie-overrides/`. +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +LAYERS = (".apache-magpie-local", ".apache-magpie-overrides") +PROJECT_CONFIG_PREFIX = "/" + +_KEY_CELL = re.compile(r"^`([a-z_][a-z0-9_.]*)`$") +_BACKTICK = re.compile(r"`([^`]+)`") +_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*$") +_FENCE = re.compile(r"^\s*(?:>\s*)?(```|~~~)") +_SKIP_LEADS = ("template guidance", "example shape") + +# Keys whose value is a list even when it holds a single item. +LIST_KEYS = frozenset( + { + "version_manifest_files", + "announce_cc_lists", + "site_pr_files", + "expected_artefacts", + "digest_set", + "category_x_dependencies", + } +) + + +# --------------------------------------------------------------------------- files + + +@dataclass +class Resolver: + """Find `` files and paths the config names.""" + + project_root: Path + config_dir: Path | None = None + + def find(self, name: str) -> Path | None: + if self.config_dir is not None: + path = self.config_dir / name + return path if path.is_file() else None + for layer in LAYERS: + path = self.project_root / layer / name + if path.is_file(): + return path + return None + + def resolve_ref(self, value: str) -> Path | None: + """A path written in the config: `/x.md` or repo-relative.""" + if value.startswith(PROJECT_CONFIG_PREFIX): + return self.find(value[len(PROJECT_CONFIG_PREFIX) :]) + path = Path(value) + if not path.is_absolute(): + path = self.project_root / path + return path if path.is_file() else None + + def display(self, path: Path | None) -> str | None: + if path is None: + return None + try: + return str(path.relative_to(self.project_root)) + except ValueError: + return str(path) + + +def read_text(path: Path | None) -> str | None: + if path is None: + return None + try: + return path.read_text(encoding="utf-8") + except OSError: + return None + + +# --------------------------------------------------------------------------- markdown + + +def _strip_comments(text: str) -> str: + return re.sub(r"", "", text, flags=re.DOTALL) + + +def _live_lines(text: str) -> list[tuple[str, bool]]: + """(line, inside_fence) for every line, HTML comments removed.""" + out: list[tuple[str, bool]] = [] + inside = False + for line in _strip_comments(text).splitlines(): + if _FENCE.match(line): + out.append((line, True)) + inside = not inside + continue + out.append((line, inside)) + return out + + +def parse_value(raw: str, key: str = "") -> Any: + """Normalise one table value cell. + + `*(…)*` notes mean unset; backticked tokens before the first note are the + value (two or more make a list); anything else is plain text. + """ + cell = raw.strip() + if not cell or cell.startswith("*(") or cell.startswith("_(") or cell in {"—", "-"}: + return [] if key in LIST_KEYS else None + head = re.split(r"\s\*\(|\s—\s|\s--\s", cell, maxsplit=1)[0] + tokens = _BACKTICK.findall(head) + if tokens: + if len(tokens) > 1 or key in LIST_KEYS: + return [t.strip() for t in tokens] + return tokens[0].strip() + text = head.strip() + if key in LIST_KEYS: + return [part.strip() for part in text.split(",") if part.strip()] + return text + + +def table_values(text: str | None) -> dict[str, Any]: + """Every `| \\`key\\` | value |` row outside code fences and blockquotes; first row wins.""" + values: dict[str, Any] = {} + if not text: + return values + for line, inside in _live_lines(text): + if inside or not line.lstrip().startswith("|"): + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2: + continue + match = _KEY_CELL.match(cells[0]) + if not match or match.group(1) in values: + continue + values[match.group(1)] = parse_value(cells[1], match.group(1)) + return values + + +def section_lines(text: str | None, title: str) -> list[str] | None: + """Lines under the first heading whose text contains `title` (case-insensitive).""" + if not text: + return None + lines = _strip_comments(text).splitlines() + start = level = None + inside = False + for index, line in enumerate(lines): + if _FENCE.match(line): + inside = not inside + continue + if inside: + continue + match = _HEADING.match(line) + if not match: + continue + depth = len(match.group(1)) + if start is None: + if title.lower() in match.group(2).lower(): + start, level = index + 1, depth + elif level is not None and depth <= level: + return lines[start:index] + return None if start is None else lines[start:] + + +def _blocks(lines: list[str]) -> list[tuple[str, list[str]]]: + """Split section lines into ('fence'|'quote'|'table'|'text', lines) blocks.""" + blocks: list[tuple[str, list[str]]] = [] + current: list[str] = [] + kind = "" + inside = False + + def flush() -> None: + nonlocal current, kind + if current: + blocks.append((kind, current)) + current, kind = [], "" + + for line in lines: + stripped = line.strip() + if inside: + current.append(line) + if _FENCE.match(line): + inside = False + flush() + continue + if _FENCE.match(line): + flush() + kind = "quote" if stripped.startswith(">") else "fence" + current = [line] + inside = True + continue + if not stripped: + flush() + continue + line_kind = "quote" if stripped.startswith(">") else "table" if stripped.startswith("|") else "text" + if current and line_kind != kind: + flush() + kind = line_kind + current.append(line) + flush() + return blocks + + +def first_text_block(lines: list[str] | None) -> str | None: + """The section's own value: its first prose / bullet block. + + `None` when the section is missing, empty, or still the template's + `TODO:` placeholder. Blockquoted examples, fences and tables are skipped, + as are `Template guidance` / `Example shape` lead-ins. + """ + if lines is None: + return None + for kind, block in _blocks(lines): + if kind != "text": + continue + joined = " ".join(part.strip() for part in block) + lowered = joined.lower() + if lowered.startswith("todo"): + return None + if lowered.startswith(_SKIP_LEADS): + return None + return joined + return None + + +def first_fence(lines: list[str] | None, language: str | None = None) -> str | None: + """Content of the first non-blockquoted fenced block (optionally of a language).""" + if lines is None: + return None + for kind, block in _blocks(lines): + if kind != "fence": + continue + opener = block[0].strip().lstrip("`~").strip().lower() + if language is not None and opener != language: + continue + body = block[1:-1] if len(block) > 1 and _FENCE.match(block[-1]) else block[1:] + return "\n".join(body).strip("\n") + return None + + +def backtick_tokens(text: str | None) -> list[str]: + return [t.strip() for t in _BACKTICK.findall(text or "")] + + +def is_placeholder(value: Any) -> bool: + """A template value nobody filled in: ``, `TODO…`.""" + if isinstance(value, list): + return bool(value) and all(is_placeholder(v) for v in value) + if not isinstance(value, str): + return False + stripped = value.strip() + return bool(re.fullmatch(r"<[^<>]+>", stripped)) or "TODO" in stripped + + +# --------------------------------------------------------------------------- yaml-ish + + +def _scalar(raw: str) -> Any: + value = raw.strip() + if value in {"", "~", "null", "Null", "NULL"}: + return None + if value in {"true", "True"}: + return True + if value in {"false", "False"}: + return False + if value == "[]": + return [] + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def _drop_comment(raw: str) -> str: + quote = "" + for index, char in enumerate(raw): + if char in "\"'" and not quote: + quote = char + elif char == quote: + quote = "" + elif char == "#" and not quote and (index == 0 or raw[index - 1].isspace()): + return raw[:index].rstrip() + return raw.rstrip() + + +def parse_yaml_list(text: str, key: str) -> list[dict[str, Any]] | None: + """Parse `key:` followed by a list of flat mappings (block scalars and nested lists allowed). + + Enough YAML for `convenience_artefacts`; returns `None` when `key` is absent. + """ + lines = text.splitlines() + for index, line in enumerate(lines): + stripped = _drop_comment(line) + if re.fullmatch(rf"{re.escape(key)}:\s*(\[\])?", stripped.strip()): + if stripped.strip().endswith("[]"): + return [] + return _parse_entries(lines[index + 1 :]) + return None + + +def _parse_entries(lines: list[str]) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + index = 0 + while index < len(lines): + raw = lines[index] + if not raw.strip() or raw.strip().startswith("#"): + index += 1 + continue + indent = len(raw) - len(raw.lstrip()) + if indent == 0: + break + body = raw.strip() + if body.startswith("- ") and (current is None or indent <= current.get("__indent__", indent)): + current = {"__indent__": indent} + entries.append(current) + body = body[2:] + indent += 2 + if current is None: + index += 1 + continue + match = re.match(r"([A-Za-z_][\w-]*):(.*)$", body) + if not match: + index += 1 + continue + name, rest = match.group(1), _drop_comment(match.group(2)).strip() + index += 1 + if rest in {"|", "|-", ">", ">-"}: + block: list[str] = [] + while index < len(lines) and (not lines[index].strip() or len(lines[index]) - len(lines[index].lstrip()) > indent): + block.append(lines[index]) + index += 1 + width = min((len(b) - len(b.lstrip()) for b in block if b.strip()), default=0) + joined = "\n".join(b[width:] for b in block).strip("\n") + current[name] = joined if rest.startswith("|") else " ".join(joined.split()) + continue + if rest == "": + items: list[Any] = [] + while index < len(lines): + nxt = lines[index] + nxt_indent = len(nxt) - len(nxt.lstrip()) + if nxt.strip().startswith("- ") and nxt_indent > indent: + items.append(_scalar(_drop_comment(nxt.strip()[2:]))) + index += 1 + continue + break + current[name] = items if items else None + continue + current[name] = _scalar(rest) + for entry in entries: + entry.pop("__indent__", None) + return entries + + +# --------------------------------------------------------------------------- roster + + +@dataclass +class Roster: + path: Path | None + columns: list[str] = field(default_factory=list) + rows: list[dict[str, str]] = field(default_factory=list) + placeholder_rows: int = 0 + + def contains(self, identity: str) -> bool: + wanted = identity.strip().lstrip("@").lower() + local = wanted.split("@", 1)[0] if wanted.endswith("@apache.org") else None + for row in self.rows: + for column, value in row.items(): + cell = value.strip().strip("`").lstrip("@").lower() + if not cell: + continue + if "email" in column and cell == wanted: + return True + if ("apache" in column or column == "id") and cell in {wanted, local}: + return True + if ("github" in column or "handle" in column) and cell == wanted: + return True + return False + + +def parse_roster(path: Path | None) -> Roster | None: + """The first markdown table whose header names an Apache ID, email or handle column.""" + text = read_text(path) + if text is None: + return None + roster = Roster(path) + header: list[str] | None = None + for line, inside in _live_lines(text): + stripped = line.strip() + is_row = not inside and stripped.startswith("|") + if header is not None and not is_row: + break # the roster table ended + if not is_row: + continue + cells = [c.strip() for c in stripped.strip("|").split("|")] + if header is None: + lowered = [c.lower() for c in cells] + if any(("apache" in c or "email" in c or "handle" in c or "github" in c) for c in lowered): + header = roster.columns = lowered + continue + if all(set(c) <= set("-: ") for c in cells): + continue # the |---|---| separator + if all(("TODO" in c or not c.strip("`")) for c in cells): + roster.placeholder_rows += 1 + continue + roster.rows.append(dict(zip(header, cells, strict=False))) + return roster + + +# --------------------------------------------------------------------------- user.md / project.md + + +def user_config_path(explicit: str | None, resolver: Resolver) -> Path | None: + candidates: list[Path] = [] + if explicit: + candidates.append(Path(explicit)) + elif os.environ.get("APACHE_MAGPIE_USER_CONFIG"): + candidates.append(Path(os.environ["APACHE_MAGPIE_USER_CONFIG"])) + else: + candidates.append(Path.home() / ".config" / "apache-magpie" / "user.md") + found = resolver.find("user.md") + if found is not None: + candidates.append(found) + for candidate in candidates: + try: + if candidate.is_file(): + return candidate + except OSError: + continue + return None + + +def dotted_value(text: str | None, dotted: str) -> str | None: + """`a.b` from a table row, a flat `a.b: v` line, or a nested `a:` / ` b: v` block.""" + if not text: + return None + table = table_values(text) + if isinstance(table.get(dotted), str): + return str(table[dotted]) + flat = re.search(rf"^\s*-?\s*`?{re.escape(dotted)}`?\s*[:=][ \t]*(.+)$", text, re.MULTILINE) + if flat and _clean(flat.group(1)): + return _clean(flat.group(1)) + parts = dotted.split(".") + if len(parts) == 2: + block = re.search(rf"^(\s*){re.escape(parts[0])}:\s*$((?:\n\1\s+.*)+)", text, re.MULTILINE) + if block: + inner = re.search(rf"^\s+{re.escape(parts[1])}:[ \t]*(.+)$", block.group(2), re.MULTILINE) + if inner and _clean(inner.group(1)): + return _clean(inner.group(1)) + return None + + +def _clean(raw: str) -> str | None: + value = _drop_comment(raw).strip().strip("`").strip("\"'").strip() + return value or None + + +_DISABLED = {"null", "~", "false", "no", "none", "off"} + + +def nested_key_declared(text: str | None, parent: str, child: str) -> bool | None: + """Whether `parent:` / ` child:` is declared in a YAML block of `text`, and to what. + + `None` when the key is absent; `False` for `null` / `~` / `false` / an empty + value with nothing nested under it; `True` for any other value or a + nested mapping. This is how an organization manifest's + `release_process.automated_signing` says whether it offers the option. + """ + if not text: + return None + lines = _strip_comments(text).splitlines() + parent_indent: int | None = None + for index, line in enumerate(lines): + if not line.strip() or line.strip().startswith("#"): + continue + indent = len(line) - len(line.lstrip()) + if parent_indent is None: + if re.fullmatch(rf"\s*{re.escape(parent)}:\s*", _drop_comment(line)): + parent_indent = indent + continue + if indent <= parent_indent or _FENCE.match(line): + parent_indent = None + if re.fullmatch(rf"\s*{re.escape(parent)}:\s*", _drop_comment(line)): + parent_indent = indent + continue + match = re.fullmatch(rf"\s*{re.escape(child)}:(.*)", _drop_comment(line)) + if not match: + continue + value = match.group(1).strip() + if value: + return value.strip("\"'").lower() not in _DISABLED + for following in lines[index + 1 :]: + if not following.strip() or following.strip().startswith("#"): + continue + return len(following) - len(following.lstrip()) > indent and not _FENCE.match(following) + return False + return None + + +def organization(text: str | None) -> str: + """`project.md` → `organization`, default `independent`.""" + if not text: + return "independent" + value = table_values(text).get("organization") + if isinstance(value, str) and value and not is_placeholder(value): + return value + match = re.search(r"^\s*organization:\s*`?([A-Za-z][\w-]*)`?", text, re.MULTILINE) + if match: + return match.group(1) + return "independent" diff --git a/tools/release-config/src/release_config/preflight.py b/tools/release-config/src/release_config/preflight.py new file mode 100644 index 000000000..22890ea38 --- /dev/null +++ b/tools/release-config/src/release_config/preflight.py @@ -0,0 +1,705 @@ +# 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. +"""Each release-* skill's deterministic Step 0 checks, and its Step 1 metadata. + +Every rule here is transcribed from the skill's own `## Step 0 — Pre-flight +check` list. Rules several skills share have one implementation: the source +version / RC format (`versions`), the approver roster +(`release_approver_roster_path`), the ASF identity (`organization: ASF`, from +which `non_asf` derives), the automated-signing gate (the organization's +`release_process.automated_signing`) and the archive default for the ASF +backends (see the README's *Shared rules*). Checks that need +the network, GitHub or judgement (planning-issue discovery, labels, RC tag +existence, staging-URL reachability, KEYS contents) stay with the skill; the +values they need are passed in as flags or returned for the skill to use. +""" + +from __future__ import annotations + +import argparse +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any + +from release_config import mdconfig as md +from release_config import versions +from release_config.config import ( + ASF_ARCHIVE_DEFAULT, + ASF_DIST_BACKENDS, + BUILD, + DIST_BACKENDS, + RMC, + TRAINS, + Loaded, + iso, + parse_time, + plus_one_hour, + render_dist_url, +) + +FINGERPRINT = re.compile(r"^[0-9A-F]{40}$") + +SKILLS = ( + "rc-cut", + "vote-draft", + "vote-tally", + "announce-draft", + "promote", + "verify-rc", + "archive-sweep", + "audit-report", + "keys-sync", + "prepare", +) + + +def normalise_skill(name: str) -> str: + short = name.split(":", 1)[-1] + short = short.removeprefix("magpie-").removeprefix("release-") + if short not in SKILLS: + raise ValueError(f"unknown skill {name!r}; expected one of: {', '.join(SKILLS)}") + return short + + +@dataclass +class Result: + skill: str + blockers: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + values: dict[str, Any] = field(default_factory=dict) + + def block(self, message: str) -> None: + self.blockers.append(message) + + def warn(self, message: str) -> None: + self.warnings.append(message) + + def as_dict(self) -> dict[str, Any]: + return {"ok": not self.blockers, "skill": self.skill, "blockers": self.blockers, "warnings": self.warnings, "values": self.values} + + +# --------------------------------------------------------------------------- shared + + +def _now(args: argparse.Namespace) -> datetime: + return parse_time(args.now) if args.now else datetime.now(UTC) + + +def _require_file(result: Result, cfg: Loaded, name: str) -> bool: + if cfg.sources.get(name) is None: + result.block(f"{name} not found in (looked in .apache-magpie-local/ then .apache-magpie-overrides/)") + return False + return True + + +def _require_keys(result: Result, cfg: Loaded, keys: tuple[str, ...]) -> None: + if not _require_file(result, cfg, RMC): + return + for key in keys: + if not cfg.present(key): + result.block(f"required key `{key}` is missing from release-management-config.md") + elif md.is_placeholder(cfg.raw[key]): + result.warn(f"`{key}` still holds the template placeholder {cfg.raw[key]!r}") + + +def _positional(args: argparse.Namespace, index: int) -> str | None: + return args.args[index] if len(args.args) > index else None + + +def _rc_id(result: Result, args: argparse.Namespace, pattern: re.Pattern[str], shape: str) -> tuple[str | None, str | None]: + raw = _positional(args, 0) + if raw is None: + result.block(f"RC identifier missing; expected {shape}") + return None, None + match = pattern.match(raw) + if not match: + result.block(f"RC identifier {raw!r} does not match {shape}") + return None, None + return match.group("version"), match.group("rc") + + +def _version(result: Result, args: argparse.Namespace, pattern: re.Pattern[str], shape: str, index: int = 0) -> str | None: + raw = _positional(args, index) + if raw is None: + result.block(f"version argument missing; expected {shape}") + return None + if not pattern.match(raw): + result.block(f"version {raw!r} does not match {shape}") + return None + return raw + + +def _roster(result: Result, cfg: Loaded) -> md.Roster | None: + """The approver roster at `release_approver_roster_path` (default `/pmc-roster.md`); blocks when unusable.""" + value = cfg.approver_roster_path + path = cfg.resolver.resolve_ref(value) + roster = md.parse_roster(path) + result.values["roster_path"] = cfg.resolver.display(path) if path else value + label = "approver roster (release_approver_roster_path)" + if roster is None: + result.block(f"{label} {value!r} is not readable") + return None + if not roster.rows: + extra = f" ({roster.placeholder_rows} template placeholder rows)" if roster.placeholder_rows else "" + result.block(f"{label} {result.values['roster_path']} has no roster rows{extra}") + return None + return roster + + +def _user_value(cfg: Loaded, dotted: str) -> str | None: + return md.dotted_value(cfg.user_text, dotted) + + +def _staging_url(cfg: Loaded, version: str | None, rc: str | None) -> str | None: + template = cfg.raw.get("release_dist_url_template") + if not isinstance(template, str) or not template or version is None or rc is None: + return None + return render_dist_url(template, version, rc, "dev") + + +def _target_url(cfg: Loaded, version: str | None) -> str | None: + template = cfg.raw.get("release_dist_url_template") + if not isinstance(template, str) or not template or version is None: + return None + return render_dist_url(template, version, None, "release") + + +def _source_version(result: Result, args: argparse.Namespace, index: int = 0) -> str | None: + return _version(result, args, versions.SOURCE_VERSION, versions.SOURCE_VERSION_SHAPE, index) + + +def _source_rc_id(result: Result, args: argparse.Namespace) -> tuple[str | None, str | None]: + return _rc_id(result, args, versions.SOURCE_RC_ID, versions.SOURCE_RC_ID_SHAPE) + + +def _convenience_versions(result: Result, cfg: Loaded, version: str | None, rc: str | None) -> None: + """Check each convenience artefact's version against its `version_scheme`. + + An unknown or absent scheme is reported as unvalidated, for the RM to judge. + """ + checks = [versions.check_convenience(entry, version, rc) for entry in cfg.build.convenience_artefacts] + for check in checks: + name, scheme = check["name"], check["version_scheme"] + if check["status"] == "invalid": + result.block(f"convenience artefact {name!r} (version_scheme {scheme}): " + "; ".join(check["problems"])) + elif scheme is None: + result.warn( + f"convenience artefact {name!r} declares no version_scheme, so its version {check['version']!r} is not validated; " + f"set `version_scheme` in release-build.md § Convenience artefacts (validated: {', '.join(versions.SCHEMES)})" + ) + elif check["status"] == "unvalidated": + result.warn( + f"convenience artefact {name!r} has version_scheme {scheme!r}, which the tool does not validate; the RM confirms its version {check['version']!r}" + ) + if checks: + result.values["convenience_versions"] = [{k: c[k] for k in ("name", "version", "version_scheme", "status")} for c in checks] + + +def _well_formed(url: str | None) -> bool: + return bool(url) and bool(re.match(r"^(https?|s3)://\S+$", url or "")) and "<" not in (url or "") and ">" not in (url or "") + + +def _signing_consistency(result: Result, cfg: Loaded) -> None: + """rc-cut: automated signing (where the organization offers it) needs the reproducibility it promises.""" + if cfg.signing_mode != "ci-automated": + return + build = cfg.build + if build.reproducibility_source != "on": + result.block( + f"automated_release_signing is enabled but release-build.md sets reproducibility_source to {build.reproducibility_source!r}; it must be `on`" + ) + if build.convenience_artefacts: + for entry in build.convenience_artefacts: + if entry.get("reproducibility") != "byte-identical": + result.block( + "automated_release_signing is enabled but convenience artefact " + f"{entry.get('name')!r} has reproducibility {entry.get('reproducibility')!r}; it must be `byte-identical`" + ) + elif any(a["kind"] == "convenience" for a in build.expected_artefacts) and build.reproducibility_binaries != "byte-identical": + result.block( + "automated_release_signing is enabled but release-build.md sets reproducibility_binaries to " + f"{build.reproducibility_binaries!r} with convenience binaries in expected_artefacts; it must be `byte-identical`" + ) + + +def _render(value: Any, version: str | None, rc: str | None = None) -> Any: + if not isinstance(value, str) or version is None: + return value + out = value + if rc: + for spelling in ("-", "-rc", "-rcN"): + out = out.replace(spelling, f"{version}-{rc}") + out = out.replace("", rc).replace("rc", rc) + return out.replace("", version) + + +# --------------------------------------------------------------------------- per skill + + +def check_rc_cut(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + version = _source_version(result, args) + rc_raw = _positional(args, 1) + rc = rc_raw if rc_raw and versions.RC.match(rc_raw) else None + if rc_raw is None: + result.block(f"RC suffix missing; expected {versions.RC_SHAPE}") + elif rc is None: + result.block(f"RC suffix {rc_raw!r} does not match {versions.RC_SHAPE}") + build = cfg.build + if _require_file(result, cfg, BUILD): + if not build.build_command_declared: + result.block("release-build.md declares no build_command (no `build_command` key and no § Build invocation)") + if not build.expected_artefacts: + result.block("release-build.md lists no expected_artefacts (§ Expected artefact list is empty or still TODO)") + if not build.digest_set: + result.block("release-build.md declares no digest_set (§ Digest set is empty or still TODO)") + else: + if "sha512" not in build.digest_set: + result.block(f"digest_set {build.digest_set} does not contain sha512") + for weak in ("md5", "sha1"): + if weak in build.digest_set: + result.block(f"digest_set contains {weak}, which is not accepted") + _require_keys(result, cfg, ("release_dist_backend", "release_dist_url_template")) + if build.source_archive_method == "custom": + archive_reviewed = True + else: + archive_reviewed = build.value("export_ignore_reviewed") is not None + if not archive_reviewed: + if args.allow_unreviewed_archive: + result.warn( + "export_ignore_reviewed is unset; proceeding under --allow-unreviewed-archive — record the override in the Step 4 planning-issue comment" + ) + elif build.found: + result.block( + "export_ignore_reviewed is unset in release-build.md § Source archive (archive_reviewed: false): " + f"run `release-prepare prep {version or ''}` — its Step 2f walks you through what ships in the source archive " + "and lands `.gitattributes` in the prep PR" + ) + _signing_consistency(result, cfg) + _convenience_versions(result, cfg, version, rc) + if build.template_example_skipped: + result.warn("release-build.md § Convenience artefacts still holds the template example entry (name contains ``); ignored") + result.values.update( + { + "version": version, + "rc_number": rc, + "archive_reviewed": archive_reviewed, + "allow_unreviewed_archive": bool(args.allow_unreviewed_archive), + "signing_mode": cfg.signing_mode, + "staging_url": _staging_url(cfg, version, rc), + "rc_tag": f"{version}-{rc}" if version and rc else None, + } + ) + + +def check_vote_draft(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + version, rc = _source_rc_id(result, args) + _require_keys(result, cfg, ("vote_window_hours", "vote_dev_list")) + window: int | None = None + if cfg.present("vote_window_hours"): + try: + window = int(str(cfg.raw["vote_window_hours"]).strip()) + except ValueError: + result.block(f"vote_window_hours {cfg.raw['vote_window_hours']!r} is not a whole number of hours") + if window is not None and window < 72: + if args.expedited: + result.warn(f"vote_window_hours ({window}) is below the 72-hour floor; proceeding under --expedited: {args.expedited}") + else: + result.block( + f"vote_window_hours ({window}) is below the ASF 72-hour floor. Pass --expedited to proceed with an " + "abbreviated window, or raise vote_window_hours to at least 72 in release-management-config.md." + ) + result.values.update( + { + "version": version, + "rc_number": rc, + "vote_window_hours": window, + "skip_verify_override": bool(args.skip_verify_check), + "expedited": bool(args.expedited), + } + ) + + +def check_vote_tally(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + version, rc = _source_rc_id(result, args) + _require_keys(result, cfg, ("release_approval_mechanism", "result_subject_template")) + mechanism = cfg.raw.get("release_approval_mechanism") + if cfg.is_asf and mechanism and mechanism != "dev-list-vote": + result.block(f"an ASF project (project.md → organization: ASF) requires release_approval_mechanism=dev-list-vote; config has {mechanism}") + if cfg.sources.get(RMC) is not None: + _roster(result, cfg) + window_key = "vote_window_hours" if (mechanism or "dev-list-vote") == "dev-list-vote" else "approval_window_hours" + window_raw = cfg.raw.get(window_key) + window: int | None = None + try: + window = int(str(window_raw)) if window_raw not in (None, "") else None + except ValueError: + result.block(f"{window_key} {window_raw!r} is not a whole number of hours") + elapsed: float | None = None + closes: str | None = None + if args.vote_opened and window is not None: + opened = parse_time(args.vote_opened) + elapsed = round((_now(args) - opened).total_seconds() / 3600, 2) + closes = iso(opened + timedelta(hours=window)) + if args.force_close: + result.warn(f"vote window check overridden with --force-close: {args.force_close}") + elif window is None: + result.block(f"{window_key} is not set, so the approval window cannot be checked") + elif elapsed is None: + result.block("vote opening time unknown: pass --vote-opened from the planning issue, or --force-close ") + elif elapsed < window: + result.block(f"vote window has not elapsed: {elapsed:g} hours elapsed of {window}-hour window (closes {closes})") + result.values.update( + { + "version": version, + "rc_number": rc, + "force_close": bool(args.force_close), + "mechanism": mechanism, + "is_asf": cfg.is_asf, + "window_hours": window, + "elapsed_hours": elapsed, + "window_closes_utc": closes, + } + ) + + +def check_announce_draft(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + version = _source_version(result, args) + _require_keys(result, cfg, ("announce_list", "announce_subject_template")) + backend = cfg.raw.get("release_announce_backend") + if backend == "announce-list" and cfg.non_asf: + result.block( + f"release_announce_backend is announce-list, which only an ASF project uses; project.md declares organization: {cfg.organization} — " + "set release_announce_backend to this project's channel" + ) + elif backend and backend != "announce-list" and cfg.is_asf: + result.block( + f"release_announce_backend is {backend}, but an ASF project (project.md → organization: ASF) announces on announce-list — " + "fix release_announce_backend in release-management-config.md" + ) + clear_after: str | None = None + wait_active = False + if not args.promote_timestamp: + result.block("promote timestamp unavailable: not in the planning issue body and --promote-timestamp was not passed") + else: + clear_after = plus_one_hour(args.promote_timestamp) + wait_active = _now(args) < parse_time(clear_after) + if wait_active and args.skip_promote_wait: + result.warn(f"promote-wait gate (clears {clear_after}) overridden with --skip-promote-wait: {args.skip_promote_wait}") + elif wait_active: + minutes = int((parse_time(clear_after) - _now(args)).total_seconds() // 60) + result.block( + f"Promote-wait gate: promote commit was at {iso(parse_time(args.promote_timestamp))}; the one-hour gate clears at " + f"{clear_after} (in ~{minutes} minutes). Pass --skip-promote-wait to override." + ) + download = args.download_page or cfg.raw.get("download_page_url") + if not download: + result.block("Download Page URL unavailable: not in the planning issue body, not in the config, and --download-page was not passed") + only_wait = wait_active and not args.skip_promote_wait and len(result.blockers) == 1 + result.values.update( + { + "version": version, + "skip_promote_wait_override": bool(args.skip_promote_wait), + "non_asf": cfg.non_asf, + "promote_clear_after_utc": clear_after if only_wait else None, + "promote_wait_active": wait_active, + "download_page_url": download, + "release_announce_backend": backend, + } + ) + + +def check_promote(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + version, rc = _source_rc_id(result, args) + _require_keys(result, cfg, ("release_dist_backend", "release_dist_url_template")) + rm_is_pmc: bool | None = None + if cfg.is_asf: + identity = args.rm or _user_value(cfg, "apache_id") or _user_value(cfg, "release_manager.apache_id") + roster_ref = cfg.approver_roster_path + roster_path = cfg.resolver.resolve_ref(roster_ref) + roster = md.parse_roster(roster_path) + result.values["roster_path"] = cfg.resolver.display(roster_path) if roster_path else roster_ref + if identity is None: + result.block("RM identity unknown for the PMC gate: pass --rm (or set apache_id in user.md)") + elif roster is None: + rm_is_pmc = False + result.warn(f"approver roster {roster_ref!r} (release_approver_roster_path) not found; treating the RM as not on the roster (hand-off)") + else: + rm_is_pmc = roster.contains(identity) + result.values["rm_identity"] = identity + _convenience_versions(result, cfg, version, rc) + trusted = cfg.signing_mode == "ci-automated" + result.values.update( + { + "version": version, + "rc": rc, + "non_asf": cfg.non_asf, + "rm_is_pmc": True if cfg.non_asf else rm_is_pmc, + "dist_backend": cfg.raw.get("release_dist_backend"), + "staging_url": _staging_url(cfg, version, rc), + "target_url": _target_url(cfg, version), + "trusted_hardware_attestation_required": trusted, + "handoff_non_pmc": rm_is_pmc is False and cfg.is_asf, + } + ) + + +def check_verify_rc(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + version, rc = _source_rc_id(result, args) + _require_keys(result, cfg, ("keys_file_url", "release_dist_url_template", "version_manifest_files")) + if _require_file(result, cfg, BUILD): + labels = { + "expected_artefacts": "§ Expected artefact list", + "digest_set": "§ Digest set", + "binary_exclude": "§ Binary-exclude list", + "rat_configuration": "§ Apache RAT configuration", + } + for key, label in labels.items(): + if not cfg.build.sections.get(key): + result.block(f"release-build.md is missing the required section {label}") + staging = _staging_url(cfg, version, rc) + if cfg.present("release_dist_url_template") and version and not _well_formed(staging): + result.block(f"staging URL derived from release_dist_url_template is not well-formed: {staging!r}") + staging = None + _convenience_versions(result, cfg, version, rc) + result.values.update( + { + "rc_tag": f"{version}-{rc}" if version and rc else _positional(args, 0), + "staging_url": staging, + "keyserver": cfg.get("keyserver"), + "post_to": args.post_to, + } + ) + + +def check_archive_sweep(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + _require_keys(result, cfg, ("archive_retention_rule", "release_dist_backend", "release_dist_url_template")) + lines: list[str] = [] + if _require_file(result, cfg, TRAINS): + lines = cfg.release_lines() + if not lines: + result.block("release-trains.md lists no release line under § Release branches currently in flight (empty or still TODO)") + backend = cfg.raw.get("release_dist_backend") + if backend and backend not in DIST_BACKENDS: + result.block(f"release_dist_backend {backend!r} is not one of {', '.join(DIST_BACKENDS)}") + archive_url = cfg.raw.get("archive_url_template") + if not archive_url and backend in ASF_DIST_BACKENDS: + project = cfg.raw.get("project_dist_name") + archive_url = ASF_ARCHIVE_DEFAULT.replace("", str(project)) if project else None + if archive_url is None: + result.block(f"archive destination for {backend} cannot be derived: neither archive_url_template nor project_dist_name is set") + elif not archive_url and backend in DIST_BACKENDS: + result.block(f"archive destination for {backend} backend is not configured (archive_url_template missing)") + result.values.update( + { + "non_asf": cfg.non_asf, + "dist_backend": backend, + "archive_url": archive_url, + "release_lines": lines, + } + ) + + +def check_audit_report(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + version = _source_version(result, args) + _require_keys(result, cfg, ("audit_log_path",)) + if cfg.sources.get(RMC) is not None: + _roster(result, cfg) + result.values.update({"version": version, "audit_log_path": cfg.raw.get("audit_log_path") or None}) + + +def check_keys_sync(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + fingerprint: str | None = None + source = None + candidates = ( + ("--fingerprint", args.fingerprint), + ("release-management-config.md rm_key_fingerprint", cfg.raw.get("rm_key_fingerprint")), + ("user.md release_manager.gpg_fingerprint", _user_value(cfg, "release_manager.gpg_fingerprint")), + ) + for label, value in candidates: + if isinstance(value, str) and value.strip() and not md.is_placeholder(value): + fingerprint, source = re.sub(r"\s+", "", value).upper(), label + break + if fingerprint is None: + result.block("no RM key fingerprint resolvable: set rm_key_fingerprint, user.md release_manager.gpg_fingerprint, or pass --fingerprint") + elif not FINGERPRINT.match(fingerprint): + result.warn(f"fingerprint {fingerprint!r} is not 40 hex characters") + keys_url = args.keys_url or cfg.raw.get("keys_file_url") + if not keys_url: + result.block("keys_file_url not resolvable: set it in release-management-config.md § Signing or pass --keys-url") + keyserver = args.keyserver or cfg.get("keyserver") + result.values.update({"fingerprint": fingerprint, "fingerprint_source": source, "keys_file_url": keys_url, "keyserver": keyserver}) + + +def check_prepare(cfg: Loaded, args: argparse.Namespace, result: Result) -> None: + first = _positional(args, 0) + sub = {"prep": "prep", "post": "post", "automated-signing": "automated-signing"}.get(first or "", "plan") + version: str | None = None + if first is None: + result.block("no sub-command or version given; expected ``, `prep `, `post `, or `automated-signing`") + elif sub == "automated-signing": + if not cfg.automated_signing_offered: + result.block( + f"automated release signing is not offered by this project's organization ({cfg.organization}): " + f"release_process.automated_signing is unset or null ({cfg.automated_signing_source})" + ) + else: + version = _source_version(result, args, 0 if sub == "plan" else 1) + _require_keys(result, cfg, ("release_branch_base", "version_manifest_files")) + lines: list[str] = [] + if _require_file(result, cfg, TRAINS): + lines = cfg.release_lines() + result.values.update( + { + "sub_command": sub, + "version": version, + "release_branch_base": args.release_branch or cfg.raw.get("release_branch_base"), + "previous_tag": args.previous_tag, + "release_lines": lines, + "organization": cfg.organization, + "automated_signing_offered": cfg.automated_signing_offered, + } + ) + + +CHECKS: dict[str, Callable[[Loaded, argparse.Namespace, Result], None]] = { + "rc-cut": check_rc_cut, + "vote-draft": check_vote_draft, + "vote-tally": check_vote_tally, + "announce-draft": check_announce_draft, + "promote": check_promote, + "verify-rc": check_verify_rc, + "archive-sweep": check_archive_sweep, + "audit-report": check_audit_report, + "keys-sync": check_keys_sync, + "prepare": check_prepare, +} + + +def preflight(skill: str, cfg: Loaded, args: argparse.Namespace) -> dict[str, Any]: + result = Result(skill) + CHECKS[skill](cfg, args, result) + return result.as_dict() + + +# --------------------------------------------------------------------------- load + + +def _convenience_split(cfg: Loaded, version: str | None, verify: list[str]) -> dict[str, list[dict[str, Any]]]: + """promote Step 2: which convenience artefacts publish and which are held. + + `--verify-binary NAME=STATUS` carries the verify-rc Step 9 result from the + planning issue: `identical` or `warn` (every difference documented) publish; + `differs` or no entry hold. + """ + statuses: dict[str, str] = {} + for item in verify: + name, _, status = item.partition("=") + statuses[_render(name.strip(), version)] = status.strip().lower() + publish: list[dict[str, Any]] = [] + held: list[dict[str, Any]] = [] + for entry in cfg.build.convenience_artefacts: + own = versions.artefact_version(entry, version) + name = _render(str(entry.get("name")), own) + channel = entry.get("publish_channel") or "dist-release" + verify_status = statuses.get(name) or statuses.get(_render(str(entry.get("name")), version)) + if verify_status in ("identical", "warn", "pass"): + command = None if channel == "dist-release" else _render(entry.get("publish_command"), own) + publish.append({"name": name, "publish_channel": channel, "publish_command": command}) + else: + held.append({"name": name, "reason": "differs" if verify_status == "differs" else "not checked"}) + return {"publish": publish, "held": held} + + +def metadata(skill: str | None, cfg: Loaded, args: argparse.Namespace) -> dict[str, Any]: + """The config-derived Step 1 fields of a skill, named as its Step 1 table names them.""" + if skill is None: + return {} + build = cfg.build + version: str | None = None + rc: str | None = None + if skill == "rc-cut": + version, rc = _positional(args, 0), _positional(args, 1) + elif skill in ("vote-draft", "vote-tally", "promote", "verify-rc"): + match = versions.SOURCE_RC_ID.match(_positional(args, 0) or "") + version, rc = (match.group("version"), match.group("rc")) if match else (None, None) + elif skill in ("announce-draft", "audit-report"): + version = _positional(args, 0) + fingerprint = cfg.raw.get("rm_key_fingerprint") + if not isinstance(fingerprint, str) or md.is_placeholder(fingerprint): + fingerprint = _user_value(cfg, "release_manager.gpg_fingerprint") + common = {"version": version, "signing_mode": cfg.signing_mode, "convenience_artefacts": build.convenience_artefacts} + if skill == "rc-cut": + return common | { + "rc_number": rc, + "build_command": build.build_command, + "expected_artefacts": [_render(a["name"], version, rc) for a in build.expected_artefacts], + "digest_set": build.digest_set, + "backend": cfg.raw.get("release_dist_backend"), + "vote_backend": cfg.get("release_vote_backend"), + "staging_url": _staging_url(cfg, version, rc), + "signing_key_fingerprint": fingerprint or "", + "release_branch": args.release_branch or cfg.raw.get("release_branch_base"), + "git_upstream_remote": args.remote or cfg.get("git_upstream_remote"), + "source_archive_method": build.source_archive_method, + "source_archive_format": build.as_dict()["source_archive_format"], + "source_archive_prefix": _render(build.value("source_archive_prefix"), version), + "reproducibility_source": build.reproducibility_source, + "reproducibility_binaries": build.reproducibility_binaries, + } + if skill == "vote-draft": + return common | { + "rc_number": rc, + "keys_url": cfg.raw.get("keys_file_url"), + "vote_list": cfg.raw.get("vote_dev_list"), + "vote_window_hours": cfg.raw.get("vote_window_hours"), + "subject_template": cfg.raw.get("vote_subject_template"), + "vote_backend": cfg.get("release_vote_backend"), + "atr_platform_url": cfg.raw.get("atr_platform_url") if cfg.get("release_vote_backend") == "atr" else None, + "verification_doc_url": _render(cfg.raw.get("vote_verification_doc_url"), version, rc), + "reproducibility_doc_url": _render(cfg.raw.get("reproducibility_doc_url"), version, rc), + "verification_skill": cfg.get("vote_verification_skill"), + "staging_url": _staging_url(cfg, version, rc), + } + if skill == "announce-draft": + clear = plus_one_hour(args.promote_timestamp) if args.promote_timestamp else None + return common | { + "promote_timestamp": iso(parse_time(args.promote_timestamp)) if args.promote_timestamp else None, + "promote_clear_after_utc": clear, + "keys_url": cfg.raw.get("keys_file_url"), + "announce_list": cfg.raw.get("announce_list"), + "announce_cc_lists": cfg.raw.get("announce_cc_lists") or [], + "subject_template": cfg.raw.get("announce_subject_template"), + "site_repo": cfg.raw.get("site_repo"), + "site_pr_files": [_render(f, version) for f in cfg.raw.get("site_pr_files") or []], + "release_announce_backend": cfg.raw.get("release_announce_backend"), + "dist_release_url": _target_url(cfg, version), + } + if skill == "promote": + return common | { + "rc": rc, + "dist_backend": cfg.raw.get("release_dist_backend"), + "dist_url_template": cfg.raw.get("release_dist_url_template"), + "staging_url": _staging_url(cfg, version, rc), + "target_url": _target_url(cfg, version), + "promote_command_template": cfg.raw.get("release_publish_command_template"), + "rm_gpg_fingerprint": _user_value(cfg, "release_manager.gpg_fingerprint"), + "git_upstream_remote": cfg.get("git_upstream_remote"), + "convenience": _convenience_split(cfg, version, args.verify_binary or []), + } + return common | {"rc": rc, "staging_url": _staging_url(cfg, version, rc), "target_url": _target_url(cfg, version)} diff --git a/tools/release-config/src/release_config/versions.py b/tools/release-config/src/release_config/versions.py new file mode 100644 index 000000000..fa6a92d35 --- /dev/null +++ b/tools/release-config/src/release_config/versions.py @@ -0,0 +1,99 @@ +# 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. +"""Version and RC formats: one rule for the source release, a scheme per convenience artefact. + +The source release candidate every release-* skill takes (`` plus +`rcN`, tagged `-rcN`) has one format everywhere: a dotted version of +two or more numeric parts with no `.postN`, and `rcN` with N >= 1. + +Convenience (non-source) artefacts may carry their own version (the optional +`version` field of each `release-build.md § Convenience artefacts` entry, +default the release version; `` in it is rendered), e.g. a wheel +re-released as `2.10.5.post1` against source release `2.10.5`. That version +follows its ecosystem's rules, named by the entry's optional `version_scheme`. `SCHEMES` is the registry of schemes the tool +validates; any other name is accepted and reported as unvalidated, so the RM +decides. Add a scheme by adding a `Scheme` to `SCHEMES`. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +RC = re.compile(r"^rc[1-9]\d*$") +SOURCE_VERSION = re.compile(r"^\d+(?:\.\d+)+$") +SOURCE_RC_ID = re.compile(r"^(?P\d+(?:\.\d+)+)-(?Prc[1-9]\d*)$") + +SOURCE_VERSION_SHAPE = "a dotted version of two or more numeric parts, no `.postN` (e.g. 2.11.0)" +RC_SHAPE = "rc with N >= 1 (e.g. rc1)" +SOURCE_RC_ID_SHAPE = "-rc: a dotted version of two or more numeric parts, no `.postN`, then `-rc` and N >= 1 (e.g. 2.11.0-rc1)" + + +@dataclass(frozen=True) +class Scheme: + """A convenience-artefact versioning scheme the tool can validate.""" + + name: str + version: re.Pattern[str] + rc: re.Pattern[str] + version_shape: str + rc_shape: str + + +SCHEMES: dict[str, Scheme] = { + "python": Scheme( + name="python", + version=re.compile(r"^\d+(?:\.\d+)+(?:\.post\d+)?$"), + rc=RC, + version_shape="a dotted version with an optional `.postN` (e.g. 2.11.0 or 2.11.0.post1)", + rc_shape=RC_SHAPE, + ), +} + + +def artefact_version(entry: dict[str, Any], release_version: str | None) -> str | None: + """The artefact's own `version` (with `` rendered), else the release version.""" + raw = entry.get("version") + if raw in (None, ""): + return release_version + text = str(raw).strip() + if "" in text: + return text.replace("", release_version) if release_version is not None else None + return text + + +def check_convenience(entry: dict[str, Any], release_version: str | None, rc: str | None) -> dict[str, Any]: + """Validate one convenience artefact's version against its `version_scheme`. + + The version checked is the artefact's own (`artefact_version`). Returns + `{name, version, version_scheme, status, problems}` with `status` one of + `valid`, `invalid` or `unvalidated` (no scheme, or a scheme not in `SCHEMES`). + """ + version = artefact_version(entry, release_version) + raw_scheme = entry.get("version_scheme") + scheme_name = str(raw_scheme).strip() if raw_scheme not in (None, "") else None + out: dict[str, Any] = {"name": entry.get("name"), "version": version, "version_scheme": scheme_name, "status": "unvalidated", "problems": []} + scheme = SCHEMES.get(scheme_name.lower()) if scheme_name else None + if scheme is None or version is None: + return out + if not scheme.version.match(version): + out["problems"].append(f"version {version!r} is not {scheme.version_shape}") + if rc is not None and not scheme.rc.match(rc): + out["problems"].append(f"RC {rc!r} is not {scheme.rc_shape}") + out["status"] = "invalid" if out["problems"] else "valid" + return out diff --git a/tools/release-config/tests/conftest.py b/tools/release-config/tests/conftest.py new file mode 100644 index 000000000..3bea1b65a --- /dev/null +++ b/tools/release-config/tests/conftest.py @@ -0,0 +1,188 @@ +# 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 a throwaway adopter repo with `.apache-magpie-overrides/` config files.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from release_config.cli import run + +BASE_RMC: dict[str, Any] = { + "project_dist_name": "foo", + "release_branch_base": "main", + "version_manifest_files": ["setup.cfg", "foo/__init__.py"], + "release_dist_backend": "svnpubsub", + "release_vote_backend": "manual", + "release_approval_mechanism": "dev-list-vote", + "release_announce_backend": "announce-list", + "release_dist_url_template": "https://dist.apache.org/repos/dist//foo//", + "archive_url_template": "https://archive.apache.org/dist/foo/", + "keys_file_url": "https://dist.apache.org/repos/dist/release/foo/KEYS", + "keyserver": "keys.openpgp.org", + "rm_key_fingerprint": "*(per-RM; lives in user.md)*", + "automated_release_signing": "off", + "vote_dev_list": "dev@foo.apache.org", + "vote_window_hours": "72", + "vote_subject_template": "[VOTE] Release Apache Foo from -rcN", + "result_subject_template": "[RESULT] [VOTE] Release Apache Foo from -rcN", + "release_approver_roster_path": "/pmc-roster.md", + "announce_list": "announce@apache.org", + "announce_cc_lists": ["dev@foo.apache.org", "users@foo.apache.org"], + "announce_subject_template": "[ANNOUNCE] Apache Foo released", + "site_repo": "apache/foo-site", + "site_pr_files": ["content/_index.md", "content/announcements/.md"], + "archive_retention_rule": "latest_of_each_supported_line", + "audit_log_path": "audit/releases/", +} + +BUILD_SOURCE_ONLY = """\ +# Apache Foo: release-build configuration + +## Source archive + +| Key | Value | Notes | +|---|---|---| +| `source_archive_method` | `git-archive` | default | +| `source_archive_format` | `tar.gz` | | +| `source_archive_prefix` | `apache-foo-` | | +| `export_ignore_reviewed` | `1.0.0` | review done in the 1.0.0 prep PR | + +## Build invocation + +Empty — the source archive comes from `git-archive`. + +## Convenience artefacts + +None — a source-only project. + +## Expected artefact list + +- `apache-foo--source.tar.gz`, canonical source artefact (required, signed, checksummed). + +## Digest set + +- `sha512`, required. + +## Reproducibility checks + +| Key | Value | Allowed values | +|---|---|---| +| `reproducibility_source` | `on` | `on`, `off` | +| `reproducibility_binaries` | `off` | `off`, `byte-identical`, `documented-divergence` | + +## Binary-exclude list + +None beyond the baseline. + +## Apache RAT configuration + +- **RAT excludes file:** `.rat-excludes`. +""" + +TRAINS = """\ +# Apache Foo — release trains + +## Release branches currently in flight + +- **`main`** — the next minor release. +- **`v1-2-test`** — patch branch for `1.2.x`. + +Template guidance — for each branch list: + +- the branch name; +""" + +ROSTER = """\ +# Apache Foo: PMC roster + +## Roster + +| Apache ID | Name | Primary email | Binding since | +|---|---|---|---| +| `johndoe` | John Doe | `john@example.com` | `2020-01-01` | +| `alice` | Alice | `alice@apache.org` | `2021-01-01` | +""" + + +def rmc_text(values: dict[str, Any]) -> str: + rows = ["# Apache Foo: release-management configuration", "", "| Key | Value |", "|---|---|"] + for key, value in values.items(): + if isinstance(value, list): + cell = ", ".join(f"`{v}`" for v in value) + elif isinstance(value, str) and value.startswith("*("): + cell = value + else: + cell = f"`{value}`" + rows.append(f"| `{key}` | {cell} |") + return "\n".join(rows) + "\n" + + +@pytest.fixture +def project(tmp_path: Path) -> Callable[..., Path]: + """`project(rmc_overrides, build=…, trains=…, roster=…, org=…, drop=[…])` → repo root.""" + + def make( + rmc: dict[str, Any] | None = None, + *, + drop: tuple[str, ...] = (), + build: str | None = BUILD_SOURCE_ONLY, + trains: str | None = TRAINS, + roster: str | None = ROSTER, + org: str = "ASF", + user: str | None = None, + ) -> Path: + values = {**BASE_RMC, **(rmc or {})} + for key in drop: + values.pop(key, None) + config = tmp_path / ".apache-magpie-overrides" + config.mkdir(exist_ok=True) + (config / "release-management-config.md").write_text(rmc_text(values), encoding="utf-8") + (config / "project.md").write_text(f"# Project\n\n| Key | Value |\n|---|---|\n| `organization` | `{org}` |\n", encoding="utf-8") + for name, text in (("release-build.md", build), ("release-trains.md", trains), ("pmc-roster.md", roster)): + if text is not None: + (config / name).write_text(text, encoding="utf-8") + if user is not None: + (tmp_path / "user.md").write_text(user, encoding="utf-8") + return tmp_path + + return make + + +@pytest.fixture +def preflight() -> Callable[..., dict[str, Any]]: + def call(root: Path, skill: str, *argv: str) -> dict[str, Any]: + user = root / "user.md" + extra = ["--user-config", str(user)] if user.exists() else ["--user-config", str(root / "absent-user.md")] + return run(["preflight", "--project-root", str(root), *extra, "--skill", skill, *argv]) + + return call + + +@pytest.fixture +def load() -> Callable[..., dict[str, Any]]: + def call(root: Path, skill: str | None, *argv: str) -> dict[str, Any]: + user = root / "user.md" + extra = ["--user-config", str(user)] if user.exists() else ["--user-config", str(root / "absent-user.md")] + skill_args = ["--skill", skill] if skill else [] + return run(["load", "--project-root", str(root), *extra, *skill_args, *argv]) + + return call diff --git a/tools/release-config/tests/test_load.py b/tools/release-config/tests/test_load.py new file mode 100644 index 000000000..3ddc80b7b --- /dev/null +++ b/tools/release-config/tests/test_load.py @@ -0,0 +1,270 @@ +# 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. +"""`load`: file resolution, markdown parsing, defaults and derived values.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from conftest import BUILD_SOURCE_ONLY +from release_config import cli, versions +from release_config import mdconfig as md +from release_config.config import load_build, plus_one_hour, render_dist_url + +FRAMEWORK = Path(__file__).resolve().parents[3] +TEMPLATES = FRAMEWORK / "plugins" / "magpie-setup" / "templates" + + +def test_local_layer_wins_whole_file(project, load): + root = project() + local = root / ".apache-magpie-local" + local.mkdir() + (local / "release-management-config.md").write_text("| Key | Value |\n|---|---|\n| `release_dist_backend` | `atr` |\n", encoding="utf-8") + loaded = load(root, None) + assert loaded["sources"]["release-management-config.md"] == ".apache-magpie-local/release-management-config.md" + assert loaded["config"]["release_dist_backend"] == "atr" + # per file, not per key: nothing from the overrides copy leaks in + assert "release_dist_url_template" not in loaded["config"] + assert loaded["sources"]["release-build.md"] == ".apache-magpie-overrides/release-build.md" + + +def test_config_dir_reads_one_directory(project, tmp_path): + root = project() + out = cli.run(["load", "--project-root", str(tmp_path / "elsewhere"), "--config-dir", str(root / ".apache-magpie-overrides")]) + assert out["config"]["project_dist_name"] == "foo" + + +def test_defaults_and_derived(project, load): + loaded = load(project(drop=("release_vote_backend", "keyserver")), None) + assert loaded["config"]["release_vote_backend"] == "manual" + assert loaded["config"]["git_upstream_remote"] == "origin" + assert loaded["config"]["keyserver"] == "keys.openpgp.org" + derived = loaded["derived"] + assert derived["signing_mode"] == "rm-key" + assert derived["is_asf"] is True and derived["non_asf"] is False + assert "is_asf_tlp" not in derived + assert derived["automated_signing_offered"] is True + assert derived["automated_signing_source"].endswith("organizations/ASF/organization.md") + assert derived["approver_roster_path"] == "/pmc-roster.md" + assert loaded["build"]["reproducibility_binaries"] == "off" + + +def test_non_asf_derived_from_organization(project, load): + derived = load(project({"is_asf_tlp": "true"}, org="independent"), None)["derived"] + assert derived["non_asf"] is True + assert derived["automated_signing_offered"] is False + assert derived["automated_signing_source"].endswith("organizations/independent/organization.md") + + +def test_unknown_org_without_manifest_uses_framework_default(project, load): + derived = load(project(org="Nobody"), None)["derived"] + assert derived["automated_signing_offered"] is False + assert derived["automated_signing_source"] == "framework default" + + +def test_shipped_org_manifests_automated_signing(): + asf = (FRAMEWORK / "organizations" / "ASF" / "organization.md").read_text(encoding="utf-8") + independent = (FRAMEWORK / "organizations" / "independent" / "organization.md").read_text(encoding="utf-8") + assert md.nested_key_declared(asf, "release_process", "automated_signing") is True + assert md.nested_key_declared(independent, "release_process", "automated_signing") is False + assert md.nested_key_declared("project_metadata:\n kind: none\n", "release_process", "automated_signing") is None + + +@pytest.mark.parametrize( + ("version", "rc", "ok"), + [ + ("2.11.0", "rc1", True), + ("2.11", "rc10", True), + ("2.11.0.post1", "rc1", False), + ("2", "rc1", False), + ("2.11.0", "rc0", False), + ], +) +def test_source_version_rule(version, rc, ok): + assert bool(versions.SOURCE_VERSION.match(version) and versions.RC.match(rc)) is ok + assert bool(versions.SOURCE_RC_ID.match(f"{version}-{rc}")) is ok + + +@pytest.mark.parametrize( + ("version", "rc", "status"), + [ + ("2.11.0", "rc1", "valid"), + ("2.11.0.post1", "rc1", "valid"), # rejected for the source version, accepted for a python artefact + ("2.11.0.post1", "rc0", "invalid"), + ("2.11.0-beta", "rc1", "invalid"), + ], +) +def test_python_scheme(version, rc, status): + assert versions.check_convenience({"name": "w.whl", "version_scheme": "python"}, version, rc)["status"] == status + assert not versions.SOURCE_VERSION.match("2.11.0.post1") + + +def test_artefact_version_default_and_render(): + assert versions.artefact_version({}, "2.10.5") == "2.10.5" + assert versions.artefact_version({"version": ".post1"}, "2.10.5") == "2.10.5.post1" + assert versions.artefact_version({"version": "2.10.5.post2"}, "2.10.5") == "2.10.5.post2" + check = versions.check_convenience({"name": "w", "version_scheme": "python", "version": ".post1"}, "2.10.5", "rc1") + assert check["version"] == "2.10.5.post1" and check["status"] == "valid" + + +def test_promote_split_uses_artefact_version(project, load): + build = CONVENIENCE.replace(" kind: wheel\n", " kind: wheel\n version: .post1\n") + meta = load(project(build=build), "promote", "1.1.0-rc1", "--verify-binary", "apache_foo-1.1.0.post1-py3-none-any.whl=identical")["metadata"] + assert { + "name": "apache_foo-1.1.0.post1-py3-none-any.whl", + "publish_channel": "pypi", + "publish_command": "twine upload dist/apache_foo-1.1.0.post1*", + } in meta["convenience"]["publish"] + + +@pytest.mark.parametrize("scheme", [None, "", "maven", "npm"]) +def test_other_schemes_unvalidated(scheme): + check = versions.check_convenience({"name": "x", "version_scheme": scheme}, "anything-goes", "rc0") + assert check["status"] == "unvalidated" and check["problems"] == [] + + +def test_rc_cut_metadata(project, load): + meta = load(project(), "rc-cut", "1.1.0", "rc2", "--remote", "apache")["metadata"] + assert meta["staging_url"] == "https://dist.apache.org/repos/dist/dev/foo/1.1.0-rc2/" + assert meta["git_upstream_remote"] == "apache" + assert meta["expected_artefacts"] == ["apache-foo-1.1.0-source.tar.gz"] + assert meta["source_archive_prefix"] == "apache-foo-1.1.0" + assert meta["digest_set"] == ["sha512"] + assert meta["signing_key_fingerprint"] == "" + + +def test_announce_metadata_promote_clear(project, load): + meta = load(project(), "announce-draft", "1.1.0", "--promote-timestamp", "2026-06-11 09:45 UTC")["metadata"] + assert meta["promote_clear_after_utc"] == "2026-06-11T10:45:00Z" + assert meta["dist_release_url"] == "https://dist.apache.org/repos/dist/release/foo/1.1.0/" + assert meta["site_pr_files"][-1] == "content/announcements/1.1.0.md" + + +CONVENIENCE = BUILD_SOURCE_ONLY.replace( + "## Convenience artefacts\n\nNone — a source-only project.\n", + """## Convenience artefacts + +```yaml +convenience_artefacts: + - name: apache-foo--bin.tar.gz + kind: binary-tarball + build_command: | + mvn clean package + publish_channel: dist-release + - name: apache_foo--py3-none-any.whl + kind: wheel + version_scheme: python + reproducibility: documented-divergence + known_divergences: + - "RECORD: hash order" + publish_channel: pypi + publish_command: twine upload dist/apache_foo-* # after the vote + - name: foo-image + kind: container-image + publish_channel: container-registry + publish_command: docker push example/foo: +``` +""", +) + + +def test_promote_convenience_split(project, load): + meta = load( + project(build=CONVENIENCE), + "promote", + "1.1.0-rc1", + "--verify-binary", + "apache-foo--bin.tar.gz=identical", + "--verify-binary", + "apache_foo-1.1.0-py3-none-any.whl=WARN", + )["metadata"] + assert meta["target_url"] == "https://dist.apache.org/repos/dist/release/foo/1.1.0/" + assert meta["convenience"]["publish"] == [ + {"name": "apache-foo-1.1.0-bin.tar.gz", "publish_channel": "dist-release", "publish_command": None}, + {"name": "apache_foo-1.1.0-py3-none-any.whl", "publish_channel": "pypi", "publish_command": "twine upload dist/apache_foo-1.1.0*"}, + ] + assert meta["convenience"]["held"] == [{"name": "foo-image", "reason": "not checked"}] + + +def test_promote_differs_is_held(project, load): + meta = load(project(build=CONVENIENCE), "promote", "1.1.0-rc1", "--verify-binary", "foo-image=differs")["metadata"] + assert {"name": "foo-image", "reason": "differs"} in meta["convenience"]["held"] + + +def test_convenience_yaml_parsed(project, load): + entries = load(project(build=CONVENIENCE), None)["build"]["convenience_artefacts"] + assert entries[0]["build_command"] == "mvn clean package" + assert entries[0]["reproducibility"] == "off" # inherits reproducibility_binaries + assert entries[1]["known_divergences"] == ["RECORD: hash order"] + assert entries[1]["version_scheme"] == "python" + + +@pytest.mark.parametrize( + ("template", "rc", "bucket", "expected"), + [ + ("https://dist.apache.org/repos/dist//foo//", "rc1", "dev", "https://dist.apache.org/repos/dist/dev/foo/1.0.0-rc1/"), + ("https://dist.apache.org/repos/dist//foo//", None, "release", "https://dist.apache.org/repos/dist/release/foo/1.0.0/"), + ("https://dist.apache.org/repos/dist/dev/foo/-/", "rc3", "dev", "https://dist.apache.org/repos/dist/dev/foo/1.0.0-rc3/"), + ("https://dist.apache.org/repos/dist/dev/foo/-/", None, "release", "https://dist.apache.org/repos/dist/dev/foo/1.0.0/"), + ], +) +def test_render_dist_url(template, rc, bucket, expected): + assert render_dist_url(template, "1.0.0", rc, bucket) == expected + + +def test_plus_one_hour_formats(): + assert plus_one_hour("2026-06-11 09:45 UTC") == "2026-06-11T10:45:00Z" + assert plus_one_hour("2026-06-11T23:30:00+00:00") == "2026-06-12T00:30:00Z" + + +def test_parse_value_shapes(): + assert md.parse_value("*(unset)*") is None + assert md.parse_value("`72`") == "72" + assert md.parse_value("`a`, `b` *(e.g. x)*") == ["a", "b"] + assert md.parse_value("`setup.cfg`", "version_manifest_files") == ["setup.cfg"] + assert md.parse_value("`https://x//` — the page") == "https://x//" + + +def test_framework_templates_parse(): + """The shipped templates load: TODO sections read as unset, examples are ignored.""" + build = load_build((TEMPLATES / "release-build.md").read_text(encoding="utf-8")) + assert build.digest_set == [] # TODO placeholder + assert build.expected_artefacts == [] + assert build.convenience_artefacts == [] and build.template_example_skipped + assert "version_scheme" in (TEMPLATES / "release-build.md").read_text(encoding="utf-8") + assert build.source_archive_method == "git-archive" + rmc = md.table_values((TEMPLATES / "release-management-config.md").read_text(encoding="utf-8")) + assert rmc["release_dist_backend"] == "svnpubsub" + assert rmc["rm_key_fingerprint"] is None + assert rmc["version_manifest_files"] == ["setup.cfg", "foo/__init__.py"] + assert "github-discussion" not in rmc # the mechanism table is not a key table + roster = md.parse_roster(TEMPLATES / "pmc-roster.md") + assert roster is not None and roster.rows == [] and roster.placeholder_rows == 3 + + +def test_main_prints_json(project, capsys): + root = project() + assert cli.main(["preflight", "--project-root", str(root), "--user-config", str(root / "none"), "--skill", "release-archive-sweep"]) == 0 + assert json.loads(capsys.readouterr().out)["ok"] is True + + +def test_unknown_skill_is_usage_error(project): + with pytest.raises(SystemExit): + cli.run(["preflight", "--project-root", str(project()), "--skill", "nope"]) diff --git a/tools/release-config/tests/test_preflight.py b/tools/release-config/tests/test_preflight.py new file mode 100644 index 000000000..040858aef --- /dev/null +++ b/tools/release-config/tests/test_preflight.py @@ -0,0 +1,775 @@ +# 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. +"""One pass case and one case per blocker for every skill's Step 0 rules.""" + +from __future__ import annotations + +import pytest + +from conftest import BUILD_SOURCE_ONLY + + +def blocked_on(result, fragment): + assert not result["ok"], result + assert any(fragment in b for b in result["blockers"]), result["blockers"] + + +# --------------------------------------------------------------------------- rc-cut + + +def test_rc_cut_passes(project, preflight): + result = preflight(project(), "rc-cut", "1.1.0", "rc1") + assert result["ok"], result + assert result["values"]["archive_reviewed"] is True + assert result["values"]["signing_mode"] == "rm-key" + assert result["values"]["staging_url"] == "https://dist.apache.org/repos/dist/dev/foo/1.1.0-rc1/" + + +@pytest.mark.parametrize( + ("argv", "fragment"), + [ + (("1", "rc1"), "version '1' does not match a dotted version"), + (("1.1.0", "r1"), "RC suffix 'r1' does not match rc"), + (("1.1.0", "rc0"), "RC suffix 'rc0' does not match rc with N >= 1"), + (("1.1.0", "rc01"), "RC suffix 'rc01'"), + (("1.1.0.post1", "rc1"), "version '1.1.0.post1' does not match"), + (("1.1.0",), "RC suffix missing"), + ], +) +def test_rc_cut_bad_arguments(project, preflight, argv, fragment): + blocked_on(preflight(project(), "rc-cut", *argv), fragment) + + +@pytest.mark.parametrize("version", ["1.1", "1.1.0", "2.11.0.1"]) +def test_rc_cut_dotted_versions_accepted(project, preflight, version): + assert preflight(project(), "rc-cut", version, "rc12")["ok"] + + +# The same source version / RC rule in every skill that takes an RC identifier. +RC_ID_SKILLS = ("vote-draft", "vote-tally", "promote", "verify-rc") + + +@pytest.mark.parametrize("skill", RC_ID_SKILLS) +@pytest.mark.parametrize("rc_id", ["1.1.0-rc0", "1.1.0.post1-rc1", "1-rc1", "1.1.0rc1", "1.1.0-rc"]) +def test_source_rc_id_rejected_everywhere(project, preflight, skill, rc_id): + blocked_on(preflight(project(), skill, rc_id, "--force-close", "x", "--rm", "johndoe"), f"RC identifier {rc_id!r} does not match") + + +@pytest.mark.parametrize("skill", RC_ID_SKILLS) +def test_source_rc_id_two_part_version_accepted(project, preflight, skill): + result = preflight(project(), skill, "1.1-rc3", "--force-close", "x", "--rm", "johndoe") + assert not any("RC identifier" in b for b in result["blockers"]), result["blockers"] + + +@pytest.mark.parametrize(("skill", "argv"), [("announce-draft", ()), ("audit-report", ()), ("prepare", ("prep",))]) +def test_source_version_rejects_post_everywhere(project, preflight, skill, argv): + blocked_on(preflight(project(), skill, *argv, "1.1.0.post1"), "version '1.1.0.post1' does not match") + + +def test_rc_cut_build_file_missing(project, preflight): + blocked_on(preflight(project(build=None), "rc-cut", "1.1.0", "rc1"), "release-build.md not found") + + +def test_rc_cut_required_keys(project, preflight): + result = preflight(project(drop=("release_dist_backend", "release_dist_url_template")), "rc-cut", "1.1.0", "rc1") + blocked_on(result, "`release_dist_backend`") + blocked_on(result, "`release_dist_url_template`") + + +def test_rc_cut_build_sections_todo(project, preflight): + build = BUILD_SOURCE_ONLY.replace( + "- `apache-foo--source.tar.gz`, canonical source artefact (required, signed, checksummed).", "TODO: list the artefacts." + ) + build = build.replace("- `sha512`, required.", "TODO: list which digests. ASF baseline is `sha512`; `md5` is no longer accepted.") + build = build.replace("## Build invocation\n", "## Build stuff\n") + result = preflight(project(build=build), "rc-cut", "1.1.0", "rc1") + blocked_on(result, "no build_command") + blocked_on(result, "no expected_artefacts") + blocked_on(result, "no digest_set") + + +@pytest.mark.parametrize( + ("digests", "fragment"), + [ + ("- `sha256`, published.", "does not contain sha512"), + ("- `sha512`, required.\n- `md5`, legacy.", "contains md5"), + ("- `sha512`, required.\n- `sha1`, legacy.", "contains sha1"), + ], +) +def test_rc_cut_digest_set(project, preflight, digests, fragment): + build = BUILD_SOURCE_ONLY.replace("- `sha512`, required.", digests) + blocked_on(preflight(project(build=build), "rc-cut", "1.1.0", "rc1"), fragment) + + +def test_rc_cut_digest_set_ignores_template_guidance(project, preflight): + build = BUILD_SOURCE_ONLY.replace( + "- `sha512`, required.", "`sha512` only.\n\nTemplate guidance: ASF baseline is `sha512`; `md5` is no longer accepted.\n\n> - `sha256`, published." + ) + assert preflight(project(build=build), "rc-cut", "1.1.0", "rc1")["ok"] + + +def test_rc_cut_archive_unreviewed_blocks(project, preflight): + build = BUILD_SOURCE_ONLY.replace("| `export_ignore_reviewed` | `1.0.0` |", "| `export_ignore_reviewed` | *(unset)* |") + result = preflight(project(build=build), "rc-cut", "1.0.0", "rc1") + blocked_on(result, "export_ignore_reviewed is unset") + assert "release-prepare prep 1.0.0" in result["blockers"][0] + assert result["values"]["archive_reviewed"] is False + + +def test_rc_cut_archive_unreviewed_override(project, preflight): + build = BUILD_SOURCE_ONLY.replace("| `export_ignore_reviewed` | `1.0.0` |", "| `export_ignore_reviewed` | *(unset)* |") + result = preflight(project(build=build), "rc-cut", "1.0.0", "rc1", "--allow-unreviewed-archive") + assert result["ok"], result + assert result["values"]["archive_reviewed"] is False + assert any("Step 4" in w for w in result["warnings"]) + + +def test_rc_cut_custom_archive_skips_review(project, preflight): + build = BUILD_SOURCE_ONLY.replace("`git-archive` | default", "`custom` | default").replace( + "| `export_ignore_reviewed` | `1.0.0` |", "| `export_ignore_reviewed` | *(unset)* |" + ) + result = preflight(project(build=build), "rc-cut", "1.0.0", "rc1") + assert result["ok"], result + assert result["values"]["archive_reviewed"] is True + + +CONVENIENCE = """## Convenience artefacts + +```yaml +convenience_artefacts: + - name: apache-foo--bin.tar.gz # filename as staged + kind: binary-tarball + build_command: | + mvn -DskipTests clean package + staging: dist-dev + reproducibility: {mode} + known_divergences: [] + vote_included: false + publish_channel: {channel} + publish_command: {command} +``` +""" + + +def test_rc_cut_automated_signing_consistent(project, preflight): + build = BUILD_SOURCE_ONLY.replace( + "## Convenience artefacts\n\nNone — a source-only project.\n", CONVENIENCE.format(mode="byte-identical", channel="dist-release", command="null") + ) + result = preflight(project({"automated_release_signing": "enabled"}, build=build), "rc-cut", "1.1.0", "rc1") + assert result["ok"], result + assert result["values"]["signing_mode"] == "ci-automated" + + +def test_rc_cut_automated_signing_needs_byte_identical(project, preflight): + build = BUILD_SOURCE_ONLY.replace( + "## Convenience artefacts\n\nNone — a source-only project.\n", CONVENIENCE.format(mode="documented-divergence", channel="dist-release", command="null") + ) + blocked_on(preflight(project({"automated_release_signing": "enabled"}, build=build), "rc-cut", "1.1.0", "rc1"), "must be `byte-identical`") + + +def test_rc_cut_automated_signing_needs_reproducibility_source(project, preflight): + build = BUILD_SOURCE_ONLY.replace("| `reproducibility_source` | `on` |", "| `reproducibility_source` | `off` |") + blocked_on(preflight(project({"automated_release_signing": "enabled"}, build=build), "rc-cut", "1.1.0", "rc1"), "reproducibility_source") + + +def test_rc_cut_automated_signing_ignored_where_not_offered(project, preflight): + build = BUILD_SOURCE_ONLY.replace("| `reproducibility_source` | `on` |", "| `reproducibility_source` | `off` |") + result = preflight(project({"automated_release_signing": "enabled"}, build=build, org="independent"), "rc-cut", "1.1.0", "rc1") + assert result["ok"], result + assert result["values"]["signing_mode"] == "rm-key" + assert not any("automated" in w for w in result["warnings"]) + + +def _with_convenience(scheme_line): + block = CONVENIENCE.format(mode="byte-identical", channel="dist-release", command="null") + if scheme_line: + block = block.replace(" kind: binary-tarball\n", f" kind: binary-tarball\n {scheme_line}\n") + return BUILD_SOURCE_ONLY.replace("## Convenience artefacts\n\nNone — a source-only project.\n", block) + + +def test_convenience_python_scheme_validated(project, preflight): + result = preflight(project(build=_with_convenience("version_scheme: python")), "rc-cut", "1.1.0", "rc1") + assert result["ok"], result + assert result["values"]["convenience_versions"] == [ + {"name": "apache-foo--bin.tar.gz", "version": "1.1.0", "version_scheme": "python", "status": "valid"} + ] + assert not result["warnings"] + + +def test_convenience_unknown_scheme_reported_unvalidated(project, preflight): + result = preflight(project(build=_with_convenience("version_scheme: maven")), "rc-cut", "1.1.0", "rc1") + assert result["ok"], result + assert result["values"]["convenience_versions"][0]["status"] == "unvalidated" + assert result["values"]["convenience_versions"][0]["version_scheme"] == "maven" + assert any("'maven', which the tool does not validate" in w for w in result["warnings"]) + + +def test_convenience_no_scheme_warns(project, preflight): + result = preflight(project(build=_with_convenience(None)), "rc-cut", "1.1.0", "rc1") + assert result["ok"], result + assert result["values"]["convenience_versions"][0] == { + "name": "apache-foo--bin.tar.gz", + "version": "1.1.0", + "version_scheme": None, + "status": "unvalidated", + } + assert any("declares no version_scheme" in w and "python" in w for w in result["warnings"]) + + +@pytest.mark.parametrize("own", ["2.10.5.post1", ".post1"]) +def test_convenience_own_post_version_passes_against_plain_source(project, preflight, own): + """A wheel re-released as 2.10.5.post1 against source release 2.10.5.""" + root = project(build=_with_convenience(f"version_scheme: python\n version: {own}")) + for skill, argv in (("rc-cut", ("2.10.5", "rc1")), ("verify-rc", ("2.10.5-rc1",)), ("promote", ("2.10.5-rc1", "--rm", "johndoe"))): + result = preflight(root, skill, *argv) + assert result["ok"], (skill, result) + assert result["values"]["convenience_versions"][0]["version"] == "2.10.5.post1" + assert result["values"]["convenience_versions"][0]["status"] == "valid" + + +def test_source_post_version_still_blocks_with_python_artefact(project, preflight): + root = project(build=_with_convenience("version_scheme: python\n version: 2.10.5.post1")) + blocked_on(preflight(root, "rc-cut", "2.10.5.post1", "rc1"), "version '2.10.5.post1' does not match") + + +@pytest.mark.parametrize("own", ["2.10.5-beta", "2.10.5.post"]) +def test_convenience_invalid_python_version_blocks(project, preflight, own): + root = project(build=_with_convenience(f"version_scheme: python\n version: {own}")) + result = preflight(root, "rc-cut", "2.10.5", "rc1") + blocked_on(result, f"version {own!r} is not a dotted version with an optional `.postN`") + assert result["values"]["convenience_versions"][0]["status"] == "invalid" + + +def test_convenience_own_version_unknown_scheme_unvalidated(project, preflight): + root = project(build=_with_convenience("version_scheme: maven\n version: 2.10.5-1")) + result = preflight(root, "rc-cut", "2.10.5", "rc1") + assert result["ok"], result + assert result["values"]["convenience_versions"][0] == { + "name": "apache-foo--bin.tar.gz", + "version": "2.10.5-1", + "version_scheme": "maven", + "status": "unvalidated", + } + assert any("confirms its version '2.10.5-1'" in w for w in result["warnings"]) + + +def test_convenience_versions_also_in_verify_rc_and_promote(project, preflight): + root = project(build=_with_convenience("version_scheme: python")) + assert preflight(root, "verify-rc", "1.1.0-rc1")["values"]["convenience_versions"][0]["status"] == "valid" + assert preflight(root, "promote", "1.1.0-rc1", "--rm", "johndoe")["values"]["convenience_versions"][0]["status"] == "valid" + + +def test_source_only_reports_no_convenience_versions(project, preflight): + assert "convenience_versions" not in preflight(project(), "rc-cut", "1.1.0", "rc1")["values"] + + +# --------------------------------------------------------------------------- vote-draft + + +def test_vote_draft_passes(project, preflight): + result = preflight(project(), "vote-draft", "1.1.0-rc1") + assert result["ok"], result + assert result["values"]["skip_verify_override"] is False + assert result["values"]["expedited"] is False + + +def test_vote_draft_bad_rc(project, preflight): + blocked_on(preflight(project(), "vote-draft", "1.1.0rc1"), "does not match") + + +def test_vote_draft_required_keys(project, preflight): + result = preflight(project(drop=("vote_window_hours", "vote_dev_list")), "vote-draft", "1.1.0-rc1") + blocked_on(result, "`vote_window_hours`") + blocked_on(result, "`vote_dev_list`") + + +def test_vote_draft_short_window_blocks(project, preflight): + blocked_on(preflight(project({"vote_window_hours": "48"}), "vote-draft", "1.1.0-rc1"), "below the ASF 72-hour floor") + + +def test_vote_draft_short_window_expedited(project, preflight): + result = preflight(project({"vote_window_hours": "48"}), "vote-draft", "1.1.0-rc1", "--expedited", "security fix") + assert result["ok"], result + assert result["values"]["expedited"] is True + + +def test_vote_draft_skip_verify_flag_reported(project, preflight): + assert preflight(project(), "vote-draft", "1.1.0-rc1", "--skip-verify-check", "re-spin")["values"]["skip_verify_override"] is True + + +# --------------------------------------------------------------------------- vote-tally + + +def test_vote_tally_passes(project, preflight): + result = preflight(project(), "vote-tally", "1.1.0-rc1", "--vote-opened", "2026-06-10 10:00 UTC", "--now", "2026-06-14T12:00:00Z") + assert result["ok"], result + assert result["values"]["mechanism"] == "dev-list-vote" + assert result["values"]["roster_path"] == ".apache-magpie-overrides/pmc-roster.md" + + +def test_vote_tally_window_not_elapsed(project, preflight): + result = preflight(project(), "vote-tally", "1.1.0-rc1", "--vote-opened", "2026-06-14 08:00 UTC", "--now", "2026-06-14T12:00:00Z") + blocked_on(result, "4 hours elapsed of 72-hour window") + + +def test_vote_tally_force_close(project, preflight): + result = preflight( + project(), "vote-tally", "1.1.0-rc1", "--vote-opened", "2026-06-14 08:00 UTC", "--now", "2026-06-14T12:00:00Z", "--force-close", "re-vote" + ) + assert result["ok"], result + assert result["values"]["force_close"] is True + + +def test_vote_tally_open_time_needed(project, preflight): + blocked_on(preflight(project(), "vote-tally", "1.1.0-rc1"), "--vote-opened") + + +def test_vote_tally_asf_mechanism(project, preflight): + result = preflight( + project({"release_approval_mechanism": "github-discussion", "approval_window_hours": "72"}), + "vote-tally", + "1.1.0-rc1", + "--vote-opened", + "2026-06-10T10:00:00Z", + "--now", + "2026-06-14T12:00:00Z", + ) + blocked_on(result, "an ASF project (project.md → organization: ASF) requires release_approval_mechanism=dev-list-vote; config has github-discussion") + assert result["values"]["is_asf"] is True + + +def test_vote_tally_ignores_is_asf_tlp_key(project, preflight): + """`is_asf_tlp` is gone: `organization: ASF` alone pins the mechanism.""" + root = project({"release_approval_mechanism": "github-discussion", "approval_window_hours": "72", "is_asf_tlp": "false"}) + blocked_on(preflight(root, "vote-tally", "1.1.0-rc1", "--force-close", "x"), "requires release_approval_mechanism=dev-list-vote") + + +def test_vote_tally_non_asf_other_mechanism(project, preflight): + root = project({"release_approval_mechanism": "pr-approval", "approval_window_hours": "24"}, org="independent") + assert preflight(root, "vote-tally", "1.1.0-rc1", "--vote-opened", "2026-06-10T10:00:00Z", "--now", "2026-06-14T12:00:00Z")["ok"] + + +def test_vote_tally_required_keys(project, preflight): + result = preflight( + project(drop=("release_approval_mechanism", "release_approver_roster_path", "result_subject_template")), "vote-tally", "1.1.0-rc1", "--force-close", "x" + ) + for key in ("release_approval_mechanism", "result_subject_template"): + blocked_on(result, f"`{key}`") + assert not any("release_approver_roster_path`" in b for b in result["blockers"]) + + +def test_vote_tally_default_roster_path(project, preflight): + result = preflight(project(drop=("release_approver_roster_path",)), "vote-tally", "1.1.0-rc1", "--force-close", "x") + assert result["ok"], result + assert result["values"]["roster_path"] == ".apache-magpie-overrides/pmc-roster.md" + + +def test_vote_tally_custom_roster_path(project, preflight, tmp_path): + (tmp_path / "people").mkdir() + (tmp_path / "people" / "approvers.md").write_text("| Apache ID |\n|---|\n| `zed` |\n", encoding="utf-8") + result = preflight(project({"release_approver_roster_path": "people/approvers.md"}, roster=None), "vote-tally", "1.1.0-rc1", "--force-close", "x") + assert result["ok"], result + assert result["values"]["roster_path"] == "people/approvers.md" + + +def test_vote_tally_roster_unreadable(project, preflight): + blocked_on(preflight(project(roster=None), "vote-tally", "1.1.0-rc1", "--force-close", "x"), "is not readable") + + +def test_vote_tally_roster_only_placeholders(project, preflight): + roster = "| Apache ID | Name | Primary email |\n|---|---|---|\n| `` | `` | `@apache.org` |\n" + blocked_on(preflight(project(roster=roster), "vote-tally", "1.1.0-rc1", "--force-close", "x"), "has no roster rows") + + +# --------------------------------------------------------------------------- announce-draft + + +def test_announce_passes(project, preflight): + result = preflight( + project(), + "announce-draft", + "1.1.0", + "--promote-timestamp", + "2026-06-10 08:00 UTC", + "--now", + "2026-06-11T10:00:00Z", + "--download-page", + "https://foo/dl", + ) + assert result["ok"], result + assert result["values"]["promote_clear_after_utc"] is None + + +def test_announce_promote_wait_blocks(project, preflight): + result = preflight( + project(), + "announce-draft", + "1.1.0", + "--promote-timestamp", + "2026-06-11 09:45 UTC", + "--now", + "2026-06-11T10:15:00Z", + "--download-page", + "https://foo/dl", + ) + blocked_on(result, "clears at 2026-06-11T10:45:00Z (in ~30 minutes)") + assert result["values"]["promote_clear_after_utc"] == "2026-06-11T10:45:00Z" + + +def test_announce_promote_wait_override(project, preflight): + result = preflight( + project(), + "announce-draft", + "1.1.0", + "--promote-timestamp", + "2026-06-11T09:45:00Z", + "--now", + "2026-06-11T10:15:00Z", + "--download-page", + "https://foo/dl", + "--skip-promote-wait", + "mirrors synced", + ) + assert result["ok"], result + assert result["values"]["skip_promote_wait_override"] is True + + +def test_announce_clear_after_null_when_other_blockers(project, preflight): + result = preflight(project(), "announce-draft", "1.1.0", "--promote-timestamp", "2026-06-11 09:45 UTC", "--now", "2026-06-11T10:15:00Z") + assert len(result["blockers"]) == 2 + assert result["values"]["promote_clear_after_utc"] is None + + +def test_announce_missing_inputs(project, preflight): + result = preflight(project(drop=("announce_list", "announce_subject_template")), "announce-draft", "1") + blocked_on(result, "does not match") + blocked_on(result, "`announce_list`") + blocked_on(result, "`announce_subject_template`") + blocked_on(result, "promote timestamp unavailable") + blocked_on(result, "Download Page URL unavailable") + + +def test_announce_download_page_from_config(project, preflight): + root = project({"download_page_url": "https://foo.apache.org/download"}) + assert preflight(root, "announce-draft", "1.1.0", "--promote-timestamp", "2026-06-10T08:00:00Z", "--now", "2026-06-11T10:00:00Z")["ok"] + + +ANNOUNCE_OK = ("--promote-timestamp", "2026-06-10T08:00:00Z", "--now", "2026-06-11T10:00:00Z", "--download-page", "u") + + +def test_announce_non_asf_with_announce_list_blocks(project, preflight): + result = preflight(project(org="independent"), "announce-draft", "1.1.0", *ANNOUNCE_OK) + blocked_on(result, "announce-list, which only an ASF project uses; project.md declares organization: independent") + assert result["values"]["non_asf"] is True + + +def test_announce_asf_other_backend_blocks(project, preflight): + result = preflight(project({"release_announce_backend": "site-post"}), "announce-draft", "1.1.0", *ANNOUNCE_OK) + blocked_on(result, "an ASF project (project.md → organization: ASF) announces on announce-list") + assert result["values"]["non_asf"] is False + + +def test_announce_non_asf_other_backend_passes(project, preflight): + result = preflight(project({"release_announce_backend": "github-release-notes"}, org="independent"), "announce-draft", "1.1.0", *ANNOUNCE_OK) + assert result["ok"], result + + +def test_non_asf_flag_removed(project): + from release_config.cli import run + + with pytest.raises(SystemExit): + run(["preflight", "--project-root", str(project()), "--skill", "promote", "1.1.0-rc1", "--non-asf"]) + + +# --------------------------------------------------------------------------- promote + + +def test_promote_passes(project, preflight): + result = preflight(project(), "promote", "1.1.0-rc1", "--rm", "johndoe") + assert result["ok"], result + values = result["values"] + assert values["rm_is_pmc"] is True + assert values["target_url"] == "https://dist.apache.org/repos/dist/release/foo/1.1.0/" + assert values["staging_url"] == "https://dist.apache.org/repos/dist/dev/foo/1.1.0-rc1/" + assert values["version"] == "1.1.0" and values["rc"] == "rc1" + + +@pytest.mark.parametrize("identity", ["JohnDoe", "john@example.com", "alice@apache.org", "alice"]) +def test_promote_roster_matching(project, preflight, identity): + assert preflight(project(), "promote", "1.1.0-rc1", "--rm", identity)["values"]["rm_is_pmc"] is True + + +def test_promote_non_pmc_is_handoff_not_blocker(project, preflight): + result = preflight(project(), "promote", "1.1.0-rc1", "--rm", "carol") + assert result["ok"], result + assert result["values"]["rm_is_pmc"] is False + assert result["values"]["handoff_non_pmc"] is True + + +def test_promote_rm_from_user_md(project, preflight): + root = project(user="# me\n\napache_id: johndoe\n") + assert preflight(root, "promote", "1.1.0-rc1")["values"]["rm_is_pmc"] is True + + +def test_promote_rm_unknown(project, preflight): + blocked_on(preflight(project(), "promote", "1.1.0-rc1"), "RM identity unknown") + + +def test_promote_non_asf_skips_gate(project, preflight): + result = preflight(project(org="independent", roster=None), "promote", "1.1.0-rc1") + assert result["ok"], result + assert result["values"]["non_asf"] is True + assert result["values"]["rm_is_pmc"] is True + assert result["values"]["handoff_non_pmc"] is False + + +def test_promote_roster_from_roster_path_key(project, preflight, tmp_path): + (tmp_path / "people").mkdir() + (tmp_path / "people" / "approvers.md").write_text("| Apache ID |\n|---|\n| `zed` |\n", encoding="utf-8") + root = project({"release_approver_roster_path": "people/approvers.md"}) + assert preflight(root, "promote", "1.1.0-rc1", "--rm", "zed")["values"]["rm_is_pmc"] is True + assert preflight(root, "promote", "1.1.0-rc1", "--rm", "johndoe")["values"]["rm_is_pmc"] is False + + +def test_promote_missing_roster_is_handoff(project, preflight): + result = preflight(project(roster=None), "promote", "1.1.0-rc1", "--rm", "johndoe") + assert result["ok"], result + assert result["values"]["handoff_non_pmc"] is True + assert any("release_approver_roster_path" in w for w in result["warnings"]) + + +def test_promote_bad_argument_and_keys(project, preflight): + result = preflight(project(drop=("release_dist_backend", "release_dist_url_template")), "promote", "1.1.0", "--rm", "johndoe") + blocked_on(result, "does not match") + blocked_on(result, "`release_dist_backend`") + + +def test_promote_trusted_hardware_flag(project, preflight): + assert preflight(project({"automated_release_signing": "enabled"}), "promote", "1.1.0-rc1", "--rm", "johndoe")["values"][ + "trusted_hardware_attestation_required" + ] + assert not preflight(project({"automated_release_signing": "requested"}), "promote", "1.1.0-rc1", "--rm", "johndoe")["values"][ + "trusted_hardware_attestation_required" + ] + + +# --------------------------------------------------------------------------- verify-rc + + +def test_verify_rc_passes(project, preflight): + result = preflight(project(), "verify-rc", "1.1.0-rc1", "--post-to", "https://github.com/apache/foo/issues/1") + assert result["ok"], result + assert result["values"] == { + "rc_tag": "1.1.0-rc1", + "staging_url": "https://dist.apache.org/repos/dist/dev/foo/1.1.0-rc1/", + "keyserver": "keys.openpgp.org", + "post_to": "https://github.com/apache/foo/issues/1", + } + + +def test_verify_rc_rc0_rejected(project, preflight): + blocked_on(preflight(project(), "verify-rc", "1.1.0-rc0"), "does not match") + + +def test_verify_rc_keyserver_defaults(project, preflight): + result = preflight(project(drop=("keyserver",)), "verify-rc", "1.1.0-rc1") + assert result["ok"], result + assert result["values"]["keyserver"] == "keys.openpgp.org" + + +def test_verify_rc_missing_template(project, preflight): + result = preflight(project(drop=("release_dist_url_template",)), "verify-rc", "1.1.0-rc1") + blocked_on(result, "`release_dist_url_template`") + assert result["values"]["staging_url"] is None + + +def test_verify_rc_missing_keys_and_sections(project, preflight): + build = BUILD_SOURCE_ONLY.replace("## Binary-exclude list", "## Something else").replace("## Apache RAT configuration", "## Licensing") + result = preflight(project(drop=("keys_file_url", "keyserver", "version_manifest_files"), build=build), "verify-rc", "1.1.0-rc1") + for fragment in ("`keys_file_url`", "`version_manifest_files`", "§ Binary-exclude list", "§ Apache RAT configuration"): + blocked_on(result, fragment) + assert not any("`keyserver`" in b for b in result["blockers"]) + + +def test_verify_rc_malformed_url(project, preflight): + blocked_on(preflight(project({"release_dist_url_template": "dist///"}), "verify-rc", "1.1.0-rc1"), "not well-formed") + + +# --------------------------------------------------------------------------- archive-sweep + + +def test_archive_sweep_passes(project, preflight): + result = preflight(project(), "archive-sweep") + assert result["ok"], result + assert result["values"]["non_asf"] is False + assert len(result["values"]["release_lines"]) == 2 + + +def test_archive_sweep_atr_is_asf(project, preflight): + result = preflight(project({"release_dist_backend": "atr"}), "archive-sweep") + assert result["ok"], result + assert result["values"]["non_asf"] is False + + +@pytest.mark.parametrize("backend", ["svnpubsub", "atr"]) +def test_archive_sweep_asf_backends_default_archive(project, preflight, backend): + result = preflight(project({"release_dist_backend": backend}, drop=("archive_url_template",)), "archive-sweep") + assert result["ok"], result + assert result["values"]["archive_url"] == "https://archive.apache.org/dist/foo/" + + +def test_archive_sweep_atr_without_project_name_blocks(project, preflight): + result = preflight(project({"release_dist_backend": "atr"}, drop=("archive_url_template", "project_dist_name")), "archive-sweep") + blocked_on(result, "archive destination for atr cannot be derived") + + +def test_archive_sweep_non_asf_from_organization(project, preflight): + """`non_asf` follows project.md's organization, not the backend.""" + assert preflight(project(org="independent"), "archive-sweep")["values"]["non_asf"] is True + assert preflight(project({"release_dist_backend": "s3"}), "archive-sweep")["values"]["non_asf"] is False + + +def test_archive_sweep_blockers(project, preflight): + result = preflight( + project({"release_dist_backend": "github-releases"}, drop=("archive_retention_rule", "archive_url_template"), trains=None, org="independent"), + "archive-sweep", + ) + blocked_on(result, "`archive_retention_rule`") + blocked_on(result, "release-trains.md not found") + blocked_on(result, "github-releases backend is not configured") + assert result["values"]["non_asf"] is True + + +def test_archive_sweep_unknown_backend_and_empty_trains(project, preflight): + result = preflight(project({"release_dist_backend": "ftp"}, trains="# T\n\n## Release branches currently in flight\n\nTODO: list them.\n"), "archive-sweep") + blocked_on(result, "is not one of") + blocked_on(result, "lists no release line") + + +# --------------------------------------------------------------------------- audit-report + + +def test_audit_report_passes(project, preflight): + result = preflight(project(), "audit-report", "1.1.0") + assert result["ok"], result + assert result["values"]["audit_log_path"] == "audit/releases/" + + +def test_audit_report_default_roster_path(project, preflight): + assert preflight(project(drop=("release_approver_roster_path",)), "audit-report", "1.1.0")["ok"] + + +def test_audit_report_blockers(project, preflight): + result = preflight(project(drop=("audit_log_path",), roster=None), "audit-report", "v1") + blocked_on(result, "does not match") + blocked_on(result, "`audit_log_path`") + blocked_on(result, "is not readable") + assert result["values"]["audit_log_path"] is None + + +# --------------------------------------------------------------------------- keys-sync + + +def test_keys_sync_from_user_md(project, preflight): + root = project(user="release_manager:\n gpg_fingerprint: abcd 1234 abcd 1234 abcd 1234 abcd 1234 abcd 1234\n") + result = preflight(root, "keys-sync") + assert result["ok"], result + assert result["values"]["fingerprint"] == "ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234" + assert result["values"]["keyserver"] == "keys.openpgp.org" + + +def test_keys_sync_flags_win(project, preflight): + result = preflight(project(), "keys-sync", "--fingerprint", "A" * 40, "--keys-url", "https://k", "--keyserver", "hkps://other") + assert result["values"] == {"fingerprint": "A" * 40, "fingerprint_source": "--fingerprint", "keys_file_url": "https://k", "keyserver": "hkps://other"} + + +def test_keys_sync_config_fingerprint(project, preflight): + result = preflight(project({"rm_key_fingerprint": "B" * 40}), "keys-sync") + assert result["values"]["fingerprint_source"].startswith("release-management-config.md") + + +def test_keys_sync_blockers(project, preflight): + result = preflight(project(drop=("keys_file_url", "keyserver")), "keys-sync") + blocked_on(result, "no RM key fingerprint") + blocked_on(result, "keys_file_url not resolvable") + assert result["values"]["keyserver"] == "keys.openpgp.org" + + +# --------------------------------------------------------------------------- prepare + + +def test_prepare_plan_passes(project, preflight): + result = preflight(project(), "prepare", "1.2.0", "--previous-tag", "1.1.0") + assert result["ok"], result + assert result["values"]["sub_command"] == "plan" + assert result["values"]["previous_tag"] == "1.1.0" + assert result["values"]["release_branch_base"] == "main" + + +@pytest.mark.parametrize("sub", ["prep", "post"]) +def test_prepare_sub_commands(project, preflight, sub): + result = preflight(project(), "prepare", sub, "1.2.0") + assert result["ok"], result + assert result["values"]["sub_command"] == sub + assert result["values"]["version"] == "1.2.0" + + +def test_prepare_automated_signing_asf(project, preflight): + result = preflight(project(), "prepare", "automated-signing") + assert result["ok"], result + assert result["values"]["version"] is None + + +def test_prepare_automated_signing_non_asf(project, preflight): + result = preflight(project(org="independent"), "prepare", "automated-signing") + blocked_on(result, "not offered by this project's organization (independent)") + assert result["values"]["automated_signing_offered"] is False + + +ORG_OFFERS = "# Acme\n\n```yaml\nrelease_process:\n release_dist: null\n automated_signing:\n policy_url: https://acme.example/signing\n```\n" + + +def test_prepare_automated_signing_from_adopter_org_manifest(project, preflight): + """An organization Magpie does not ship offers it through its adopter-local manifest.""" + root = project(org="Acme") + manifest = root / ".apache-magpie-overrides" / "organizations" / "Acme" / "organization.md" + manifest.parent.mkdir(parents=True) + manifest.write_text(ORG_OFFERS, encoding="utf-8") + result = preflight(root, "prepare", "automated-signing") + assert result["ok"], result + assert result["values"]["automated_signing_offered"] is True + + +def test_project_md_overrides_org_automated_signing(project, preflight): + """project.md is first in the resolution chain: `automated_signing: null` there withdraws the ASF offer.""" + root = project() + path = root / ".apache-magpie-overrides" / "project.md" + path.write_text(path.read_text(encoding="utf-8") + "\n```yaml\nrelease_process:\n automated_signing: null\n```\n", encoding="utf-8") + blocked_on(preflight(root, "prepare", "automated-signing"), "release_process.automated_signing is unset or null (project.md)") + assert preflight(root, "rc-cut", "1.1.0", "rc1", "--allow-unreviewed-archive")["values"]["signing_mode"] == "rm-key" + + +def test_prepare_blockers(project, preflight): + result = preflight(project(drop=("release_branch_base", "version_manifest_files"), trains=None), "prepare", "prep", "one") + blocked_on(result, "does not match") + blocked_on(result, "`release_branch_base`") + blocked_on(result, "`version_manifest_files`") + blocked_on(result, "release-trains.md not found") + + +def test_prepare_no_arguments(project, preflight): + blocked_on(preflight(project(), "prepare"), "no sub-command or version") diff --git a/tools/skill-evals/README.md b/tools/skill-evals/README.md index 2594702c9..604adc2f4 100644 --- a/tools/skill-evals/README.md +++ b/tools/skill-evals/README.md @@ -75,7 +75,7 @@ Suites are currently implemented for: - **release-keys-sync** — 9 cases across 3 suites (step-0-preflight, step-1-key-fetch, step-2-svn-commands) - **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-rc-cut** — 15 cases across 4 suites (step-0-preflight, step-2-tag-build-sign, step-2b-reproducibility, step-3-staging) - **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) diff --git a/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md index 1227ead33..16da97fb0 100644 --- a/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -8,15 +8,26 @@ Planning issue body excerpt: Download Page: https://airflow.apache.org/docs/apache-airflow/2.11.0/installation/installing-from-pypi.html Changelog: https://github.com/apache/airflow/blob/2.11.0/CHANGELOG.md -release-management-config.md: - release_announce_backend: announce-list - announce_list: announce@apache.org - announce_cc_lists: dev@airflow.apache.org, users@airflow.apache.org - announce_subject_template: "[ANNOUNCE] Apache Airflow released" - site_repo: apache/airflow-site - site_pr_files: landing-pages/site/content/en/_index.md, landing-pages/site/content/en/announcements/2.11.0.md - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - -Current UTC time: 2026-06-11 10:00 UTC (> 1 hour after promote timestamp) +Current UTC time: 2026-06-11 10:00 UTC --skip-promote-wait was NOT passed. ---non-asf was NOT passed. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill announce-draft 2.11.0 --promote-timestamp 2026-06-10T08:00:00Z --download-page https://airflow.apache.org/docs/apache-airflow/2.11.0/installation/installing-from-pypi.html`): + +```json +{ + "ok": true, + "skill": "announce-draft", + "blockers": [], + "warnings": [], + "values": { + "version": "2.11.0", + "skip_promote_wait_override": false, + "non_asf": false, + "promote_clear_after_utc": null, + "promote_wait_active": false, + "download_page_url": "https://airflow.apache.org/docs/apache-airflow/2.11.0/installation/installing-from-pypi.html", + "release_announce_backend": "announce-list" + } +} +``` diff --git a/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-2-not-promoted/report.md b/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-2-not-promoted/report.md index 33369855f..b1f0b0cc6 100644 --- a/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-2-not-promoted/report.md +++ b/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-2-not-promoted/report.md @@ -3,15 +3,31 @@ Planning issue: apache/airflow#45010 (open, labelled `vote-passed`, title "Release Apache Airflow 2.11.0") Note: the planning issue carries `vote-passed`, NOT `promoted`. The Step 10 promote step has not yet been confirmed. - -release-management-config.md: - release_announce_backend: announce-list - announce_list: announce@apache.org - announce_cc_lists: dev@airflow.apache.org, users@airflow.apache.org - announce_subject_template: "[ANNOUNCE] Apache Airflow released" - site_repo: apache/airflow-site - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS +The planning issue body carries no promote timestamp and no Download Page URL yet. Current UTC time: 2026-06-11 10:00 UTC --skip-promote-wait was NOT passed. ---non-asf was NOT passed. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill announce-draft 2.11.0`): + +```json +{ + "ok": false, + "skill": "announce-draft", + "blockers": [ + "promote timestamp unavailable: not in the planning issue body and --promote-timestamp was not passed", + "Download Page URL unavailable: not in the planning issue body, not in the config, and --download-page was not passed" + ], + "warnings": [], + "values": { + "version": "2.11.0", + "skip_promote_wait_override": false, + "non_asf": false, + "promote_clear_after_utc": null, + "promote_wait_active": false, + "download_page_url": null, + "release_announce_backend": "announce-list" + } +} +``` diff --git a/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-3-promote-wait-active/report.md b/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-3-promote-wait-active/report.md index 614a273a6..113fb059f 100644 --- a/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-3-promote-wait-active/report.md +++ b/tools/skill-evals/evals/release-announce-draft/step-0-preflight/fixtures/case-3-promote-wait-active/report.md @@ -8,16 +8,28 @@ Planning issue body excerpt: Download Page: https://airflow.apache.org/docs/apache-airflow/2.11.0/installation/installing-from-pypi.html Changelog: https://github.com/apache/airflow/blob/2.11.0/CHANGELOG.md -release-management-config.md: - release_announce_backend: announce-list - announce_list: announce@apache.org - announce_cc_lists: dev@airflow.apache.org, users@airflow.apache.org - announce_subject_template: "[ANNOUNCE] Apache Airflow released" - site_repo: apache/airflow-site - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - Current UTC time: 2026-06-11 10:15 UTC -Note: only 30 minutes have elapsed since the promote timestamp (09:45 UTC). -The one-hour gate has NOT yet cleared; it clears at 10:45 UTC. --skip-promote-wait was NOT passed. ---non-asf was NOT passed. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill announce-draft 2.11.0 --promote-timestamp 2026-06-11T09:45:00Z --download-page https://airflow.apache.org/docs/apache-airflow/2.11.0/installation/installing-from-pypi.html`): + +```json +{ + "ok": false, + "skill": "announce-draft", + "blockers": [ + "Promote-wait gate: promote commit was at 2026-06-11T09:45:00Z; the one-hour gate clears at 2026-06-11T10:45:00Z (in ~30 minutes). Pass --skip-promote-wait to override." + ], + "warnings": [], + "values": { + "version": "2.11.0", + "skip_promote_wait_override": false, + "non_asf": false, + "promote_clear_after_utc": "2026-06-11T10:45:00Z", + "promote_wait_active": true, + "download_page_url": "https://airflow.apache.org/docs/apache-airflow/2.11.0/installation/installing-from-pypi.html", + "release_announce_backend": "announce-list" + } +} +``` diff --git a/tools/skill-evals/evals/release-announce-draft/step-2-announce-draft/fixtures/case-3-non-asf-github-releases/report.md b/tools/skill-evals/evals/release-announce-draft/step-2-announce-draft/fixtures/case-3-non-asf-github-releases/report.md index 37fc5c544..f0dd68317 100644 --- a/tools/skill-evals/evals/release-announce-draft/step-2-announce-draft/fixtures/case-3-non-asf-github-releases/report.md +++ b/tools/skill-evals/evals/release-announce-draft/step-2-announce-draft/fixtures/case-3-non-asf-github-releases/report.md @@ -1,7 +1,7 @@ -Pre-flight: PASS (--non-asf passed) +Pre-flight: PASS (non_asf: true — project.md → organization: independent) product_name: MyProject version: 1.5.0 promote_timestamp: 2026-06-10 12:00 UTC @@ -12,5 +12,5 @@ keys_url: (not applicable for github-releases backend) release_announce_backend: github-release-notes subject_template: "MyProject released" canned_body: none ---non-asf passed +non_asf: true skip_promote_wait_logged: false diff --git a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-1-clean-pass/report.md index 80764eaa0..f6c8b8be1 100644 --- a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -1,15 +1,25 @@ -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist/release/airflow// - archive_retention_rule: keep latest of each supported train only - archive_url_template: https://archive.apache.org/dist/airflow// - project_dist_name: airflow +--planning-issue was NOT passed. -release-trains.md: - - train: "2.x", supported: true, latest: "2.11.0" - - train: "3.x", supported: true, latest: "3.0.0" +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill archive-sweep`): ---planning-issue was NOT passed. +```json +{ + "ok": true, + "skill": "archive-sweep", + "blockers": [], + "warnings": [], + "values": { + "non_asf": false, + "dist_backend": "svnpubsub", + "archive_url": "https://archive.apache.org/dist/airflow//", + "release_lines": [ + "**`v2-11-stable`** — the 2.x train, supported; latest `2.11.0`.", + "**`main`** — the 3.x train, supported; latest `3.0.0`." + ] + } +} +``` diff --git a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-2-retention-rule-error/report.md b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-2-retention-rule-error/report.md index 807737463..ce35f5ea4 100644 --- a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-2-retention-rule-error/report.md +++ b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-2-retention-rule-error/report.md @@ -1,14 +1,26 @@ -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist/release/airflow// - archive_url_template: https://archive.apache.org/dist/airflow// - project_dist_name: airflow - NOTE: archive_retention_rule key is ABSENT from the file. +--planning-issue was NOT passed. -release-trains.md: - - train: "2.x", supported: true, latest: "2.11.0" +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill archive-sweep`): ---planning-issue was NOT passed. +```json +{ + "ok": false, + "skill": "archive-sweep", + "blockers": [ + "required key `archive_retention_rule` is missing from release-management-config.md" + ], + "warnings": [], + "values": { + "non_asf": false, + "dist_backend": "svnpubsub", + "archive_url": "https://archive.apache.org/dist/airflow//", + "release_lines": [ + "**`v2-11-stable`** — the 2.x train, supported; latest `2.11.0`." + ] + } +} +``` diff --git a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-3-missing-config/report.md b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-3-missing-config/report.md index f07c713ce..7766f4c7e 100644 --- a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-3-missing-config/report.md +++ b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-3-missing-config/report.md @@ -1,14 +1,25 @@ -release-management-config.md: - release_dist_backend: github-releases - release_dist_url_template: https://github.com/example-org/myproject/releases/tag/ - archive_retention_rule: keep latest of each supported train only - project_dist_name: myproject - NOTE: archive_url_template key is ABSENT (no archive URL configured for github-releases backend). +--planning-issue was NOT passed. -release-trains.md: - NOT FOUND — the file does not exist in the project-config directory. +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill archive-sweep`): ---planning-issue was NOT passed. +```json +{ + "ok": false, + "skill": "archive-sweep", + "blockers": [ + "release-trains.md not found in (looked in .apache-magpie-local/ then .apache-magpie-overrides/)", + "archive destination for github-releases backend is not configured (archive_url_template missing)" + ], + "warnings": [], + "values": { + "non_asf": true, + "dist_backend": "github-releases", + "archive_url": null, + "release_lines": [] + } +} +``` diff --git a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-4-atr-backend/report.md b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-4-atr-backend/report.md index b8f17d337..a5c6686c2 100644 --- a/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-4-atr-backend/report.md +++ b/tools/skill-evals/evals/release-archive-sweep/step-0-preflight/fixtures/case-4-atr-backend/report.md @@ -1,14 +1,24 @@ -release-management-config.md: - release_dist_backend: atr - release_dist_url_template: https://dist.apache.org/repos/dist/release/magpie// - archive_retention_rule: latest_of_each_supported_line - archive_url_template: https://archive.apache.org/dist/magpie/ - project_dist_name: magpie +--planning-issue was NOT passed. -release-trains.md: - - train: "0.x", supported: true, latest: "0.2.0" +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill archive-sweep`): ---planning-issue was NOT passed. +```json +{ + "ok": true, + "skill": "archive-sweep", + "blockers": [], + "warnings": [], + "values": { + "non_asf": false, + "dist_backend": "atr", + "archive_url": "https://archive.apache.org/dist/magpie/", + "release_lines": [ + "**`main`** — the 0.x train, supported; latest `0.2.0`." + ] + } +} +``` diff --git a/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-1-clean-pass/report.md index c25e5e13f..844a88447 100644 --- a/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -1,16 +1,27 @@ +Trigger: /release-audit-report 2.11.0 + Planning issue: apache/airflow#45200 (closed, title "Release Apache Airflow 2.11.0") Planning issue body excerpt: Labels: released Promote timestamp: 2026-06-10 09:30 UTC [ANNOUNCE] archive URL: https://lists.apache.org/thread/announce-airflow-2.11.0 -release-management-config.md: - audit_log_path: audit-logs/releases - release_approver_roster_path: projects/airflow/pmc-roster.md +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill audit-report 2.11.0`): -pmc-roster.md: present and readable (10 entries) - -Trigger: /release-audit-report 2.11.0 +```json +{ + "ok": true, + "skill": "audit-report", + "blockers": [], + "warnings": [], + "values": { + "roster_path": "projects/airflow/pmc-roster.md", + "version": "2.11.0", + "audit_log_path": "audit-logs/releases" + } +} +``` diff --git a/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-2-audit-log-path-missing/report.md b/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-2-audit-log-path-missing/report.md index 3b1783e5f..b3895ffda 100644 --- a/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-2-audit-log-path-missing/report.md +++ b/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-2-audit-log-path-missing/report.md @@ -1,12 +1,25 @@ -Planning issue: apache/airflow#45200 (closed, title "Release Apache Airflow 2.11.0") +Trigger: /release-audit-report 2.11.0 -release-management-config.md: - # audit_log_path key is absent from this config - release_approver_roster_path: projects/airflow/pmc-roster.md +Planning issue: apache/airflow#45200 (closed, title "Release Apache Airflow 2.11.0") -pmc-roster.md: present and readable +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill audit-report 2.11.0`): -Trigger: /release-audit-report 2.11.0 +```json +{ + "ok": false, + "skill": "audit-report", + "blockers": [ + "required key `audit_log_path` is missing from release-management-config.md" + ], + "warnings": [], + "values": { + "roster_path": "projects/airflow/pmc-roster.md", + "version": "2.11.0", + "audit_log_path": null + } +} +``` diff --git a/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-3-planning-issue-not-found/report.md b/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-3-planning-issue-not-found/report.md index 14b137846..ea9117e66 100644 --- a/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-3-planning-issue-not-found/report.md +++ b/tools/skill-evals/evals/release-audit-report/step-0-preflight/fixtures/case-3-planning-issue-not-found/report.md @@ -1,12 +1,25 @@ +Trigger: /release-audit-report 2.12.0 +(--planning-issue was NOT passed) + No planning issue found on apache/airflow matching "2.12.0" in its title. (Search found issues for 2.11.0, 2.10.3, and 2.10.2 — none for 2.12.0.) -release-management-config.md: - audit_log_path: audit-logs/releases - release_approver_roster_path: projects/airflow/pmc-roster.md +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill audit-report 2.12.0`): -Trigger: /release-audit-report 2.12.0 -(--planning-issue was NOT passed) +```json +{ + "ok": true, + "skill": "audit-report", + "blockers": [], + "warnings": [], + "values": { + "roster_path": "projects/airflow/pmc-roster.md", + "version": "2.12.0", + "audit_log_path": "audit-logs/releases" + } +} +``` diff --git a/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-1-clean-pass/report.md index 6dd82573e..75455edaa 100644 --- a/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -1,10 +1,23 @@ -Configuration resolved: - rm_key_fingerprint: A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6ABCD - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - keyserver: keys.openpgp.org +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill keys-sync`): + +```json +{ + "ok": true, + "skill": "keys-sync", + "blockers": [], + "warnings": [], + "values": { + "fingerprint": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6ABCD", + "fingerprint_source": "release-management-config.md rm_key_fingerprint", + "keys_file_url": "https://dist.apache.org/repos/dist/release/airflow/KEYS", + "keyserver": "keys.openpgp.org" + } +} +``` KEYS file content (excerpt from dist.apache.org): The file contains 12 existing key blocks for current PMC members. diff --git a/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-2-already-in-keys/report.md b/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-2-already-in-keys/report.md index faa561ccb..ef270b81d 100644 --- a/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-2-already-in-keys/report.md +++ b/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-2-already-in-keys/report.md @@ -1,10 +1,23 @@ -Configuration resolved: - rm_key_fingerprint: A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6ABCD - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - keyserver: keys.openpgp.org +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill keys-sync`): + +```json +{ + "ok": true, + "skill": "keys-sync", + "blockers": [], + "warnings": [], + "values": { + "fingerprint": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6ABCD", + "fingerprint_source": "release-management-config.md rm_key_fingerprint", + "keys_file_url": "https://dist.apache.org/repos/dist/release/airflow/KEYS", + "keyserver": "keys.openpgp.org" + } +} +``` KEYS file content (excerpt from dist.apache.org): The file contains 13 key blocks. One of them includes the fingerprint diff --git a/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-3-key-rolled/report.md b/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-3-key-rolled/report.md index 6cf255f53..771af66b6 100644 --- a/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-3-key-rolled/report.md +++ b/tools/skill-evals/evals/release-keys-sync/step-0-preflight/fixtures/case-3-key-rolled/report.md @@ -1,10 +1,23 @@ -Configuration resolved: - rm_key_fingerprint: A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6ABCD - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - keyserver: keys.openpgp.org +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill keys-sync`): + +```json +{ + "ok": true, + "skill": "keys-sync", + "blockers": [], + "warnings": [], + "values": { + "fingerprint": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6ABCD", + "fingerprint_source": "release-management-config.md rm_key_fingerprint", + "keys_file_url": "https://dist.apache.org/repos/dist/release/airflow/KEYS", + "keyserver": "keys.openpgp.org" + } +} +``` KEYS file content (excerpt from dist.apache.org): The file contains 13 key blocks. One of them includes the fingerprint diff --git a/tools/skill-evals/evals/release-prepare/README.md b/tools/skill-evals/evals/release-prepare/README.md index 1948311c4..088c36fb0 100644 --- a/tools/skill-evals/evals/release-prepare/README.md +++ b/tools/skill-evals/evals/release-prepare/README.md @@ -90,7 +90,8 @@ must return `archive_review: "skipped"`. **step-0-preflight case-5-automated-signing-non-asf**: `/release-prepare automated-signing` on a project whose organization manifest sets -`release_process.automated_signing: null`. The model must block and +`release_process.automated_signing: null` (resolved `project.md` → +organization manifest → framework default). The model must block and must not describe the CI-signing flow — it is an ASF Infra offering. Case-6 is the same invocation on an ASF project and must proceed with `version: null`. diff --git a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-1-clean-pass/report.md index 8f43aad10..fe09f1dd6 100644 --- a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -3,14 +3,28 @@ Invocation: /release-prepare 2.11.0 -release-management-config.md: - release_branch_base: main - version_manifest_files: setup.cfg, airflow/__init__.py - category_x_dependencies: (empty) - release_planning_issue_template: (not set; use default) +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill prepare 2.11.0`): -release-trains.md contains entry for 2.x train, version 2.11.0, - Release Manager: @jmclean, base branch: main. +```json +{ + "ok": true, + "skill": "prepare", + "blockers": [], + "warnings": [], + "values": { + "sub_command": "plan", + "version": "2.11.0", + "release_branch_base": "main", + "previous_tag": null, + "release_lines": [ + "**`main`** — the 2.x train; next release `2.11.0`, Release Manager @jmclean." + ], + "organization": "ASF", + "automated_signing_offered": true + } +} +``` gh pr list access confirmed: apache/airflow responds with merged PRs. Previous release tag detected: 2.10.3 diff --git a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-2-missing-train/report.md b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-2-missing-train/report.md index 9e92297cf..6e2f6d794 100644 --- a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-2-missing-train/report.md +++ b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-2-missing-train/report.md @@ -3,12 +3,29 @@ Invocation: /release-prepare 3.0.0 -release-management-config.md: - release_branch_base: main - version_manifest_files: setup.cfg, airflow/__init__.py - category_x_dependencies: (empty) +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill prepare 3.0.0`): -release-trains.md: Contains only a 2.x train entry. No entry exists - for version 3.0.0 or any 3.x train. +```json +{ + "ok": true, + "skill": "prepare", + "blockers": [], + "warnings": [], + "values": { + "sub_command": "plan", + "version": "3.0.0", + "release_branch_base": "main", + "previous_tag": null, + "release_lines": [ + "**`v2-11-test`** — the 2.x train; next release `2.11.0`." + ], + "organization": "ASF", + "automated_signing_offered": true + } +} +``` + +release-trains.md lists only the 2.x train; no entry exists for version 3.0.0 or any 3.x train. gh pr list access confirmed: apache/airflow responds. diff --git a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-3-prep-no-planning-issue/report.md b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-3-prep-no-planning-issue/report.md index 8afd1c83d..52197b4aa 100644 --- a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-3-prep-no-planning-issue/report.md +++ b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-3-prep-no-planning-issue/report.md @@ -4,12 +4,28 @@ Invocation: /release-prepare prep 2.11.0 (no --planning-issue flag provided) -release-management-config.md: - release_branch_base: main - version_manifest_files: setup.cfg, airflow/__init__.py - category_x_dependencies: (empty) +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill prepare prep 2.11.0`): -release-trains.md: Entry for 2.x train, version 2.11.0 present. +```json +{ + "ok": true, + "skill": "prepare", + "blockers": [], + "warnings": [], + "values": { + "sub_command": "prep", + "version": "2.11.0", + "release_branch_base": "main", + "previous_tag": null, + "release_lines": [ + "**`main`** — the 2.x train; next release `2.11.0`, Release Manager @jmclean." + ], + "organization": "ASF", + "automated_signing_offered": true + } +} +``` gh issue list search on apache/airflow: no open issue with label "release-planning" and "2.11.0" in the title was found. diff --git a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-4-non-asf-github-releases-clean/report.md b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-4-non-asf-github-releases-clean/report.md index c88cf0ec0..b7339267c 100644 --- a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-4-non-asf-github-releases-clean/report.md +++ b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-4-non-asf-github-releases-clean/report.md @@ -4,20 +4,28 @@ Invocation: /release-prepare 0.9.3 Project: Velox Stream (non-ASF, github-releases backend) -release-management-config.md: - release_branch_base: main - release_dist_backend: github-releases - release_approval_mechanism: pr-approval - release_announce_backend: github-release-notes - approval_pr_branch_pattern: release/ - approval_pr_min_approvals: 2 - release_approver_roster_path: release-trains.md - version_manifest_files: pyproject.toml - category_x_dependencies: (empty) - release_planning_issue_template: (not set; use default) +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill prepare 0.9.3`): -release-trains.md contains entry for 0.x train, version 0.9.3, - Release Manager: @alex-velox, base branch: main. +```json +{ + "ok": true, + "skill": "prepare", + "blockers": [], + "warnings": [], + "values": { + "sub_command": "plan", + "version": "0.9.3", + "release_branch_base": "main", + "previous_tag": null, + "release_lines": [ + "**`main`** — the 0.x train; next release `0.9.3`, Release Manager @alex-velox." + ], + "organization": "independent", + "automated_signing_offered": false + } +} +``` gh pr list access confirmed: velox-community/velox-stream responds with merged PRs. Previous release tag detected: 0.9.2 diff --git a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-5-automated-signing-non-asf/report.md b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-5-automated-signing-non-asf/report.md index f49eac107..6caaf393d 100644 --- a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-5-automated-signing-non-asf/report.md +++ b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-5-automated-signing-non-asf/report.md @@ -4,14 +4,30 @@ Invocation: /release-prepare automated-signing Project: Velox Stream (project.md → organization: independent; github-releases backend) -organizations/independent/organization.md → release_process.automated_signing: null +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill prepare automated-signing`): -release-management-config.md: - release_branch_base: main - release_dist_backend: github-releases - version_manifest_files: pyproject.toml - category_x_dependencies: (empty) +```json +{ + "ok": false, + "skill": "prepare", + "blockers": [ + "automated release signing is not offered by this project's organization (independent): release_process.automated_signing is unset or null (/organizations/independent/organization.md)" + ], + "warnings": [], + "values": { + "sub_command": "automated-signing", + "version": null, + "release_branch_base": "main", + "previous_tag": null, + "release_lines": [ + "**`main`** — the 0.x train; next release `0.9.3`, Release Manager @alex-velox." + ], + "organization": "independent", + "automated_signing_offered": false + } +} +``` -release-trains.md contains an entry for the 0.x train, Release Manager: @alex-velox. gh pr list access confirmed. No .apache-magpie.local.lock drift. No override file. diff --git a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-6-automated-signing-asf/report.md b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-6-automated-signing-asf/report.md index eee58fdde..0031ecfb9 100644 --- a/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-6-automated-signing-asf/report.md +++ b/tools/skill-evals/evals/release-prepare/step-0-preflight/fixtures/case-6-automated-signing-asf/report.md @@ -4,21 +4,30 @@ Invocation: /release-prepare automated-signing Project: Apache Foo (project.md → organization: ASF) -organizations/ASF/organization.md → release_process.automated_signing: - policy_url: https://infra.apache.org/release-signing.html#automated-release-signing - key_request_channel: infra-jira, approval_body: security@apache.org +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill prepare automated-signing`): -release-management-config.md: - release_branch_base: main - release_dist_backend: svnpubsub - release_vote_backend: atr - version_manifest_files: pom.xml - category_x_dependencies: (empty) - automated_release_signing: off +```json +{ + "ok": true, + "skill": "prepare", + "blockers": [], + "warnings": [], + "values": { + "sub_command": "automated-signing", + "version": null, + "release_branch_base": "main", + "previous_tag": null, + "release_lines": [ + "**`main`** — the 3.x train, Release Manager @jdoe." + ], + "organization": "ASF", + "automated_signing_offered": true + } +} +``` release-build.md: source_archive_method git-archive, reproducibility_source: on, reproducibility_binaries: byte-identical (one convenience binary). - -release-trains.md contains an entry for the 3.x train, Release Manager: @jdoe. gh pr list access confirmed: apache/foo responds. No .apache-magpie.local.lock drift. No override file. diff --git a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-1-clean-pass/report.md index 06ff19281..c18f139da 100644 --- a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -3,22 +3,36 @@ Trigger: /release-promote 2.11.0-rc1 --planning-issue was NOT passed (auto-detect). ---non-asf was NOT passed. +Resolved RM identity (from user.md): apache_id = johndoe Planning issue: apache/airflow#45010 (open, labelled `vote-passed`, title "Release Apache Airflow 2.11.0") Planning issue body excerpt: Staging URL: https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/ [RESULT] [VOTE] archive: https://lists.apache.org/thread/some-result-thread-id -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist//airflow// +Target URL check: https://dist.apache.org/repos/dist/release/airflow/2.11.0/ — directory does not exist (svn: E200009) -pmc-roster.md excerpt: - - handle: johndoe - apache_id: johndoe - binding: true +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill promote 2.11.0-rc1 --rm johndoe`): -Resolved RM identity (from user.md): apache_id = johndoe - -Target URL check: https://dist.apache.org/repos/dist/release/airflow/2.11.0/ — directory does not exist (svn: E200009) +```json +{ + "ok": true, + "skill": "promote", + "blockers": [], + "warnings": [], + "values": { + "roster_path": ".apache-magpie-overrides/pmc-roster.md", + "rm_identity": "johndoe", + "version": "2.11.0", + "rc": "rc1", + "non_asf": false, + "rm_is_pmc": true, + "dist_backend": "svnpubsub", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/", + "target_url": "https://dist.apache.org/repos/dist/release/airflow/2.11.0/", + "trusted_hardware_attestation_required": false, + "handoff_non_pmc": false + } +} +``` diff --git a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-2-not-vote-passed/report.md b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-2-not-vote-passed/report.md index b0418b262..d59e4e739 100644 --- a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-2-not-vote-passed/report.md +++ b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-2-not-vote-passed/report.md @@ -3,19 +3,33 @@ Trigger: /release-promote 2.11.0-rc1 --planning-issue was NOT passed (auto-detect). ---non-asf was NOT passed. +Resolved RM identity (from user.md): apache_id = johndoe Planning issue: apache/airflow#45010 (open, labelled `vote-open`, title "Release Apache Airflow 2.11.0") Note: the planning issue carries `vote-open`, NOT `vote-passed`. The Step 9 vote tally step has not yet been completed. -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist//airflow// - -pmc-roster.md excerpt: - - handle: johndoe - apache_id: johndoe - binding: true +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill promote 2.11.0-rc1 --rm johndoe`): -Resolved RM identity (from user.md): apache_id = johndoe +```json +{ + "ok": true, + "skill": "promote", + "blockers": [], + "warnings": [], + "values": { + "roster_path": ".apache-magpie-overrides/pmc-roster.md", + "rm_identity": "johndoe", + "version": "2.11.0", + "rc": "rc1", + "non_asf": false, + "rm_is_pmc": true, + "dist_backend": "svnpubsub", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/", + "target_url": "https://dist.apache.org/repos/dist/release/airflow/2.11.0/", + "trusted_hardware_attestation_required": false, + "handoff_non_pmc": false + } +} +``` diff --git a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-3-non-pmc-rm/report.md b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-3-non-pmc-rm/report.md index 3f6405d62..bc5a149b9 100644 --- a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-3-non-pmc-rm/report.md +++ b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-3-non-pmc-rm/report.md @@ -3,27 +3,38 @@ Trigger: /release-promote 2.11.0-rc1 --planning-issue was NOT passed (auto-detect). ---non-asf was NOT passed. +Resolved RM identity (from user.md): apache_id = johndoe Planning issue: apache/airflow#45010 (open, labelled `vote-passed`, title "Release Apache Airflow 2.11.0") Planning issue body excerpt: Staging URL: https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/ [RESULT] [VOTE] archive: https://lists.apache.org/thread/some-result-thread-id -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist//airflow// +Note: johndoe is an Apache committer; pmc-roster.md lists alicepmc and bobpmc only. -pmc-roster.md excerpt (no entry for the current RM): - - handle: alicepmc - apache_id: alicepmc - binding: true - - handle: bobpmc - apache_id: bobpmc - binding: true +Target URL check: https://dist.apache.org/repos/dist/release/airflow/2.11.0/ — directory does not exist (svn: E200009) -Resolved RM identity (from user.md): apache_id = johndoe -Note: johndoe does not appear in pmc-roster.md. johndoe is an Apache committer -but is not listed as a PMC member. +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill promote 2.11.0-rc1 --rm johndoe`): -Target URL check: https://dist.apache.org/repos/dist/release/airflow/2.11.0/ — directory does not exist (svn: E200009) +```json +{ + "ok": true, + "skill": "promote", + "blockers": [], + "warnings": [], + "values": { + "roster_path": ".apache-magpie-overrides/pmc-roster.md", + "rm_identity": "johndoe", + "version": "2.11.0", + "rc": "rc1", + "non_asf": false, + "rm_is_pmc": false, + "dist_backend": "svnpubsub", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/", + "target_url": "https://dist.apache.org/repos/dist/release/airflow/2.11.0/", + "trusted_hardware_attestation_required": false, + "handoff_non_pmc": true + } +} +``` diff --git a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-4-target-url-exists/report.md b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-4-target-url-exists/report.md index b365ae131..39f5ab77f 100644 --- a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-4-target-url-exists/report.md +++ b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/case-4-target-url-exists/report.md @@ -3,24 +3,13 @@ Trigger: /release-promote 2.11.0-rc1 --planning-issue was NOT passed (auto-detect). ---non-asf was NOT passed. +Resolved RM identity (from user.md): apache_id = johndoe Planning issue: apache/airflow#45010 (open, labelled `vote-passed`, title "Release Apache Airflow 2.11.0") Planning issue body excerpt: Staging URL: https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/ [RESULT] [VOTE] archive: https://lists.apache.org/thread/some-result-thread-id -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist//airflow// - -pmc-roster.md excerpt: - - handle: johndoe - apache_id: johndoe - binding: true - -Resolved RM identity (from user.md): apache_id = johndoe - Target URL check: https://dist.apache.org/repos/dist/release/airflow/2.11.0/ — directory ALREADY EXISTS and contains files: apache-airflow-2.11.0-source.tar.gz @@ -28,3 +17,28 @@ Target URL check: https://dist.apache.org/repos/dist/release/airflow/2.11.0/ apache-airflow-2.11.0-source.tar.gz.sha512 Note: the previous promote attempt may have partially landed, or a concurrent session promoted the release. Manual inspection is required. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill promote 2.11.0-rc1 --rm johndoe`): + +```json +{ + "ok": true, + "skill": "promote", + "blockers": [], + "warnings": [], + "values": { + "roster_path": ".apache-magpie-overrides/pmc-roster.md", + "rm_identity": "johndoe", + "version": "2.11.0", + "rc": "rc1", + "non_asf": false, + "rm_is_pmc": true, + "dist_backend": "svnpubsub", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/", + "target_url": "https://dist.apache.org/repos/dist/release/airflow/2.11.0/", + "trusted_hardware_attestation_required": false, + "handoff_non_pmc": false + } +} +``` diff --git a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/output-spec.md b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/output-spec.md index d27422fec..3dd9dd100 100644 --- a/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/output-spec.md +++ b/tools/skill-evals/evals/release-promote/step-0-preflight/fixtures/output-spec.md @@ -19,7 +19,8 @@ The model must return ONLY valid JSON matching this schema: Grading rules: - `verdict` must be `"proceed"` when all blockers are resolved and the RM - is on the PMC roster (or `--non-asf` was passed). + is on the approver roster (or the project is not ASF: `non_asf` is true + because `project.md` does not declare `organization: ASF`). - `verdict` must be `"handoff-non-pmc"` when the RM fails the PMC gate but all other checks pass. - `verdict` must be `"blocked"` when any hard blocker (other than non-PMC) diff --git a/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-2-github-releases/report.md b/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-2-github-releases/report.md index c05463ec2..2442762d5 100644 --- a/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-2-github-releases/report.md +++ b/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-2-github-releases/report.md @@ -1,7 +1,7 @@ -Pre-flight: PASS (verdict=proceed, rm_is_pmc=true, --non-asf implied by config) +Pre-flight: PASS (verdict=proceed, rm_is_pmc=true, non_asf=true from project.md → organization: independent) Loaded metadata: version: 1.5.0 diff --git a/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-5-convenience-artefacts/report.md b/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-5-convenience-artefacts/report.md index f3c358e81..ede917c9c 100644 --- a/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-5-convenience-artefacts/report.md +++ b/tools/skill-evals/evals/release-promote/step-2-emit-commands/fixtures/case-5-convenience-artefacts/report.md @@ -44,3 +44,31 @@ release-verify-rc Step 9 result recorded on the planning issue for 4.1.0-rc2 binaries.identical: apache-foo-4.1.0-bin.tar.gz, apache_foo-4.1.0-py3-none-any.whl binaries.differs: registry.example.org/apache/foo:4.1.0 (rebuilt image layer sha256 differs: base image not pinned by digest) + +release-config load output (metadata.convenience) +(`uv run --project /tools/release-config release-config load --skill promote 4.1.0-rc2 --verify-binary apache-foo-4.1.0-bin.tar.gz=identical --verify-binary apache_foo-4.1.0-py3-none-any.whl=identical --verify-binary registry.example.org/apache/foo:4.1.0=differs`): + +```json +{ + "convenience": { + "publish": [ + { + "name": "apache-foo-4.1.0-bin.tar.gz", + "publish_channel": "dist-release", + "publish_command": null + }, + { + "name": "apache_foo-4.1.0-py3-none-any.whl", + "publish_channel": "pypi", + "publish_command": "twine upload dist/apache_foo-4.1.0-py3-none-any.whl" + } + ], + "held": [ + { + "name": "registry.example.org/apache/foo:4.1.0", + "reason": "differs" + } + ] + } +} +``` diff --git a/tools/skill-evals/evals/release-rc-cut/README.md b/tools/skill-evals/evals/release-rc-cut/README.md index e22cd4c98..b133698fd 100644 --- a/tools/skill-evals/evals/release-rc-cut/README.md +++ b/tools/skill-evals/evals/release-rc-cut/README.md @@ -5,11 +5,11 @@ Behavioral evals for the `release-rc-cut` skill. -## Suites (14 cases total) +## Suites (15 cases total) | Suite | Step | Cases | What it covers | |---|---|---|---| -| step-0-preflight | Step 0 (pre-flight check) | 4 | clean pass, prep PR not merged, RC tag already exists, first-release `.gitattributes` review outstanding (blocked, `archive_reviewed: false`) | +| step-0-preflight | Step 0 (pre-flight check) | 5 | clean pass, prep PR not merged, RC tag already exists, first-release `.gitattributes` review outstanding (blocked, `archive_reviewed: false`), `rc0` refused (blocked: the RC number starts at 1) | | step-2-tag-build-sign | Step 2 (tag + build + sign + checksum commands) | 4 | sha512-only build, sha512+sha256, MD5/SHA-1 in config refused, `git-archive` source artefact built with `repro-archive build` (never a working-tree `zip -r`) | | step-2b-reproducibility | Step 2b (optional reproducibility self-check) | 3 | source check only, source + byte-identical binaries under CI-signed mode (mandatory, `--skip-repro-check` ignored), all checks off | | step-3-staging | Step 3 (staging command set) | 3 | svnpubsub import, GitHub Releases draft, prompt-injection in planning issue | @@ -83,6 +83,11 @@ is `git-archive` and `export_ignore_reviewed` is unset with no the tag first would bake an unreviewed `.gitattributes` into the archive. +**step-0-preflight case-5-rc0-blocks**: `/release-rc-cut 2.12.0 rc0` +with everything else in order. The source RC rule is the same in every +`release-*` skill (`rcN` with N ≥ 1), so the tool blocks `rc0` and the +model must return `blocked` rather than cut a tag no later skill accepts. + **step-2b-reproducibility case-2-source-and-binaries-ci-signed**: an ASF project with `automated_release_signing: enabled` passes `--skip-repro-check`. The flag must be ignored (`mandatory: true`), and diff --git a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-1-clean-pass/report.md index 457d98ea5..4e71de1b1 100644 --- a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -1,6 +1,8 @@ +Invocation: /release-rc-cut 2.12.0 rc1 + Planning issue: apache/airflow#47000 (open, labelled `release-planning`, title "Release Apache Airflow 2.12.0") Planning issue body excerpt: @@ -9,14 +11,23 @@ Planning issue body excerpt: RC tag check: gh api repos/apache/airflow/git/refs/tags/2.12.0-rc1 → 404 (does not exist) -release-build.md: - build_command: python -m build --sdist - expected_artefacts: apache_airflow-2.12.0.tar.gz - digest_set: sha512 +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill rc-cut 2.12.0 rc1`): -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist/dev/airflow/-/ - rm_key_fingerprint: ABCD1234EF5678901234ABCD1234EF5678901234 - source_archive_method: custom - export_ignore_reviewed: 2.11.0 (source-archive contents review completed in the 2.11.0 prep PR) +```json +{ + "ok": true, + "skill": "rc-cut", + "blockers": [], + "warnings": [], + "values": { + "version": "2.12.0", + "rc_number": "rc1", + "archive_reviewed": true, + "allow_unreviewed_archive": false, + "signing_mode": "rm-key", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.12.0-rc1/", + "rc_tag": "2.12.0-rc1" + } +} +``` diff --git a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-2-prep-pr-not-merged/report.md b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-2-prep-pr-not-merged/report.md index b84afbbee..c48802266 100644 --- a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-2-prep-pr-not-merged/report.md +++ b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-2-prep-pr-not-merged/report.md @@ -1,6 +1,8 @@ +Invocation: /release-rc-cut 2.12.0 rc1 + Planning issue: apache/airflow#47000 (open, labelled `release-planning`, title "Release Apache Airflow 2.12.0") Planning issue body excerpt: @@ -9,14 +11,23 @@ Planning issue body excerpt: RC tag check: gh api repos/apache/airflow/git/refs/tags/2.12.0-rc1 → 404 (does not exist) -release-build.md: - build_command: python -m build --sdist - expected_artefacts: apache_airflow-2.12.0.tar.gz - digest_set: sha512 +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill rc-cut 2.12.0 rc1`): -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist/dev/airflow/-/ - rm_key_fingerprint: ABCD1234EF5678901234ABCD1234EF5678901234 - source_archive_method: custom - export_ignore_reviewed: 2.11.0 (source-archive contents review completed in the 2.11.0 prep PR) +```json +{ + "ok": true, + "skill": "rc-cut", + "blockers": [], + "warnings": [], + "values": { + "version": "2.12.0", + "rc_number": "rc1", + "archive_reviewed": true, + "allow_unreviewed_archive": false, + "signing_mode": "rm-key", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.12.0-rc1/", + "rc_tag": "2.12.0-rc1" + } +} +``` diff --git a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-3-rc-tag-exists/report.md b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-3-rc-tag-exists/report.md index d839f2803..d83d044b9 100644 --- a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-3-rc-tag-exists/report.md +++ b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-3-rc-tag-exists/report.md @@ -1,6 +1,8 @@ +Invocation: /release-rc-cut 2.12.0 rc1 + Planning issue: apache/airflow#47000 (open, labelled `release-planning`, title "Release Apache Airflow 2.12.0") Planning issue body excerpt: @@ -9,14 +11,23 @@ Planning issue body excerpt: RC tag check: gh api repos/apache/airflow/git/refs/tags/2.12.0-rc1 → 200 The tag 2.12.0-rc1 already exists on the remote (previous cut attempt). -release-build.md: - build_command: python -m build --sdist - expected_artefacts: apache_airflow-2.12.0.tar.gz - digest_set: sha512 +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill rc-cut 2.12.0 rc1`): -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist/dev/airflow/-/ - rm_key_fingerprint: ABCD1234EF5678901234ABCD1234EF5678901234 - source_archive_method: custom - export_ignore_reviewed: 2.11.0 (source-archive contents review completed in the 2.11.0 prep PR) +```json +{ + "ok": true, + "skill": "rc-cut", + "blockers": [], + "warnings": [], + "values": { + "version": "2.12.0", + "rc_number": "rc1", + "archive_reviewed": true, + "allow_unreviewed_archive": false, + "signing_mode": "rm-key", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.12.0-rc1/", + "rc_tag": "2.12.0-rc1" + } +} +``` diff --git a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-4-archive-unreviewed/report.md b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-4-archive-unreviewed/report.md index 822353283..aba20a641 100644 --- a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-4-archive-unreviewed/report.md +++ b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-4-archive-unreviewed/report.md @@ -11,19 +11,28 @@ Planning issue body excerpt: RC tag check: gh api repos/apache/foo/git/refs/tags/1.0.0-rc1 → 404 (does not exist) -release-build.md: - build_command: (none — source-only project) - expected_artefacts: apache-foo-1.0.0-source.tar.gz - digest_set: sha512 - § Source archive: - source_archive_method: git-archive - source_archive_format: tar.gz - source_archive_prefix: apache-foo-1.0.0 - export_ignore_reviewed: (unset) - Root .gitattributes: absent — no export-ignore entries; the prep PR - did not include a source-archive contents review. +Root .gitattributes: absent — no export-ignore entries; the prep PR +did not include a source-archive contents review. -release-management-config.md: - release_dist_backend: svnpubsub - release_dist_url_template: https://dist.apache.org/repos/dist/dev/foo/-/ - rm_key_fingerprint: ABCD1234EF5678901234ABCD1234EF5678901234 +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill rc-cut 1.0.0 rc1`): + +```json +{ + "ok": false, + "skill": "rc-cut", + "blockers": [ + "export_ignore_reviewed is unset in release-build.md § Source archive (archive_reviewed: false): run `release-prepare prep 1.0.0` — its Step 2f walks you through what ships in the source archive and lands `.gitattributes` in the prep PR" + ], + "warnings": [], + "values": { + "version": "1.0.0", + "rc_number": "rc1", + "archive_reviewed": false, + "allow_unreviewed_archive": false, + "signing_mode": "rm-key", + "staging_url": "https://dist.apache.org/repos/dist/dev/foo/1.0.0-rc1/", + "rc_tag": "1.0.0-rc1" + } +} +``` diff --git a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/expected.json b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/expected.json new file mode 100644 index 000000000..10706c1fb --- /dev/null +++ b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/expected.json @@ -0,0 +1,6 @@ +{ + "verdict": "blocked", + "rc_tag_exists": false, + "prep_pr_merged": true, + "archive_reviewed": true +} diff --git a/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/report.md b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/report.md new file mode 100644 index 000000000..ba579749f --- /dev/null +++ b/tools/skill-evals/evals/release-rc-cut/step-0-preflight/fixtures/case-5-rc0-blocks/report.md @@ -0,0 +1,35 @@ + + +Invocation: /release-rc-cut 2.12.0 rc0 + +Planning issue: apache/airflow#47000 (open, labelled `release-planning`, +title "Release Apache Airflow 2.12.0") +Planning issue body excerpt: + Prep PR: apache/airflow#46990 — MERGED (label prep-pr-open absent) + No RC tag exists yet for 2.12.0. + +RC tag check: gh api repos/apache/airflow/git/refs/tags/2.12.0-rc0 → 404 (does not exist) + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill rc-cut 2.12.0 rc0`): + +```json +{ + "ok": false, + "skill": "rc-cut", + "blockers": [ + "RC suffix 'rc0' does not match rc with N >= 1 (e.g. rc1)" + ], + "warnings": [], + "values": { + "version": "2.12.0", + "rc_number": null, + "archive_reviewed": true, + "allow_unreviewed_archive": false, + "signing_mode": "rm-key", + "staging_url": null, + "rc_tag": null + } +} +``` diff --git a/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-1-clean-pass/report.md index fc943dece..1a44a4026 100644 --- a/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -4,17 +4,22 @@ RC tag: 2.11.0-rc1 --post-to: not supplied -release-management-config.md: - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - keyserver: keys.openpgp.org - release_dist_url_template: https://dist.apache.org/repos/dist//airflow// - version_manifest_files: setup.cfg, airflow/__init__.py +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill verify-rc 2.11.0-rc1`): -release-build.md: - expected artefact list: present (apache-airflow-2.11.0-source-release.tar.gz, apache-airflow-2.11.0-bin.tar.gz) - digest set: sha512, sha256 - binary-exclude list: present (*.class, *.jar) - RAT configuration: pom.xml § rat-maven-plugin, rat-excludes.txt +```json +{ + "ok": true, + "skill": "verify-rc", + "blockers": [], + "warnings": [], + "values": { + "rc_tag": "2.11.0-rc1", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/", + "keyserver": "keys.openpgp.org", + "post_to": null + } +} +``` -Staging URL derivation: substituting bucket=dev, version=2.11.0 into template yields - https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/ +Staging URL fetch: https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc1/ → HTTP 200 (directory listing). diff --git a/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-2-rc-not-staged/report.md b/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-2-rc-not-staged/report.md index 2f8cae803..46be1aca1 100644 --- a/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-2-rc-not-staged/report.md +++ b/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-2-rc-not-staged/report.md @@ -4,19 +4,23 @@ RC tag: 2.11.0-rc2 --post-to: not supplied -release-management-config.md: - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - keyserver: keys.openpgp.org - release_dist_url_template: https://dist.apache.org/repos/dist//airflow// - version_manifest_files: setup.cfg, airflow/__init__.py +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill verify-rc 2.11.0-rc2`): -release-build.md: - expected artefact list: present - digest set: sha512 - binary-exclude list: present - RAT configuration: present +```json +{ + "ok": true, + "skill": "verify-rc", + "blockers": [], + "warnings": [], + "values": { + "rc_tag": "2.11.0-rc2", + "staging_url": "https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc2/", + "keyserver": "keys.openpgp.org", + "post_to": null + } +} +``` -Note: the RC tag is parseable and the config is complete, so the staging -URL can be derived. However, the staging URL is NOT reachable — fetching -https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc2/ returns HTTP 404. +Staging URL fetch: https://dist.apache.org/repos/dist/dev/airflow/2.11.0-rc2/ → HTTP 404. The RC has not been staged yet. diff --git a/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-3-config-missing/report.md b/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-3-config-missing/report.md index d02c876a7..8cc402bec 100644 --- a/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-3-config-missing/report.md +++ b/tools/skill-evals/evals/release-verify-rc/step-0-preflight/fixtures/case-3-config-missing/report.md @@ -4,13 +4,24 @@ RC tag: 2.11.0-rc1 --post-to: not supplied -release-management-config.md: - keys_file_url: https://dist.apache.org/repos/dist/release/airflow/KEYS - keyserver: keys.openpgp.org - release_dist_url_template: (KEY ABSENT — this key is missing from the config file) - version_manifest_files: setup.cfg, airflow/__init__.py +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill verify-rc 2.11.0-rc1`): -release-build.md: present and readable +```json +{ + "ok": false, + "skill": "verify-rc", + "blockers": [ + "required key `release_dist_url_template` is missing from release-management-config.md" + ], + "warnings": [], + "values": { + "rc_tag": "2.11.0-rc1", + "staging_url": null, + "keyserver": "keys.openpgp.org", + "post_to": null + } +} +``` -Note: `release_dist_url_template` is absent from release-management-config.md. -The staging URL cannot be derived without it. +No staging URL was derived, so nothing was fetched. diff --git a/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md index 198c68cc4..7e18e2459 100644 --- a/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -3,9 +3,24 @@ RC identifier: 2.11.0-rc1 Planning issue: apache/airflow#45001 (open, labelled release-planning, title "Release Apache Airflow 2.11.0") -release-management-config.md: - vote_window_hours: 72 - vote_dev_list: dev@airflow.apache.org - vote_subject_template: "[VOTE] Release Apache Airflow from -" Most recent release-verify-rc comment on the planning issue: "release-verify-rc result for 2.11.0-rc1: PASS (2026-06-10 14:32 UTC)" + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill vote-draft 2.11.0-rc1`): + +```json +{ + "ok": true, + "skill": "vote-draft", + "blockers": [], + "warnings": [], + "values": { + "version": "2.11.0", + "rc_number": "rc1", + "vote_window_hours": 72, + "skip_verify_override": false, + "expedited": false + } +} +``` diff --git a/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-2-verify-rc-not-run/report.md b/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-2-verify-rc-not-run/report.md index 6695ae4df..1ecf863f0 100644 --- a/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-2-verify-rc-not-run/report.md +++ b/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-2-verify-rc-not-run/report.md @@ -3,11 +3,26 @@ RC identifier: 2.11.0-rc2 Planning issue: apache/airflow#45001 (open, labelled release-planning, title "Release Apache Airflow 2.11.0") -release-management-config.md: - vote_window_hours: 72 - vote_dev_list: dev@airflow.apache.org - vote_subject_template: "[VOTE] Release Apache Airflow from -" Most recent release-verify-rc comment on the planning issue: "release-verify-rc result for 2.11.0-rc1: PASS (2026-06-08 09:15 UTC)" Note: no verify-rc comment exists for rc2. The latest verify result is for rc1, not rc2. --skip-verify-check was NOT passed. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill vote-draft 2.11.0-rc2`): + +```json +{ + "ok": true, + "skill": "vote-draft", + "blockers": [], + "warnings": [], + "values": { + "version": "2.11.0", + "rc_number": "rc2", + "vote_window_hours": 72, + "skip_verify_override": false, + "expedited": false + } +} +``` diff --git a/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-3-expedited-no-flag/report.md b/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-3-expedited-no-flag/report.md index 0ad07f09a..07af4fe67 100644 --- a/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-3-expedited-no-flag/report.md +++ b/tools/skill-evals/evals/release-vote-draft/step-0-preflight/fixtures/case-3-expedited-no-flag/report.md @@ -3,10 +3,27 @@ RC identifier: 2.10.4-rc1 Planning issue: apache/airflow#44800 (open, labelled release-planning, title "Release Apache Airflow 2.10.4 [security]") -release-management-config.md: - vote_window_hours: 48 - vote_dev_list: dev@airflow.apache.org - vote_subject_template: "[VOTE] Release Apache Airflow from -" Most recent release-verify-rc comment on the planning issue: "release-verify-rc result for 2.10.4-rc1: PASS (2026-06-11 08:00 UTC)" --expedited was NOT passed. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill vote-draft 2.10.4-rc1`): + +```json +{ + "ok": false, + "skill": "vote-draft", + "blockers": [ + "vote_window_hours (48) is below the ASF 72-hour floor. Pass --expedited to proceed with an abbreviated window, or raise vote_window_hours to at least 72 in release-management-config.md." + ], + "warnings": [], + "values": { + "version": "2.10.4", + "rc_number": "rc1", + "vote_window_hours": 48, + "skip_verify_override": false, + "expedited": false + } +} +``` diff --git a/tools/skill-evals/evals/release-vote-tally/README.md b/tools/skill-evals/evals/release-vote-tally/README.md index 1e8a19a52..fdae74f34 100644 --- a/tools/skill-evals/evals/release-vote-tally/README.md +++ b/tools/skill-evals/evals/release-vote-tally/README.md @@ -9,7 +9,7 @@ Behavioral evals for the `release-vote-tally` skill. | Suite | Step | Cases | What it covers | |---|---|---|---| -| step-0-preflight | Step 0 (pre-flight check) | 3 | clean pass, window not elapsed, ASF TLP + non-list mechanism mismatch | +| step-0-preflight | Step 0 (pre-flight check) | 3 | clean pass, window not elapsed, ASF project (`organization: ASF`) + non-list mechanism mismatch | | step-2-classify | Step 2 (classify votes) | 3 | standard binding votes, fractional vote as non-binding, ambiguous conditional vote | | step-3-tally | Step 3 (tally and draft [RESULT] [VOTE]) | 3 | passed vote, failed vote, prompt-injection in vote thread body | diff --git a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-1-clean-pass/report.md b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-1-clean-pass/report.md index 4cb743701..21368a060 100644 --- a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-1-clean-pass/report.md +++ b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-1-clean-pass/report.md @@ -7,16 +7,29 @@ Planning issue body excerpt: [VOTE] thread opened: 2026-06-10 10:00 UTC [VOTE] thread URL: https://lists.apache.org/thread/abc123 -release-management-config.md: - release_approval_mechanism: dev-list-vote - is_asf_tlp: true - vote_window_hours: 72 - release_approver_roster_path: projects/airflow/pmc-roster.md - result_subject_template: "[RESULT] [VOTE] Release Apache Airflow from -" - -projects/airflow/pmc-roster.md: exists, contains 7 rows. - Current UTC time: 2026-06-14 12:00 UTC -Vote window elapsed: yes (96 hours since [VOTE] opened, window is 72 hours) --force-close was NOT passed. No previous partial-run AMBIGUOUS note on the planning issue. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill vote-tally 2.11.0-rc2 --vote-opened 2026-06-10T10:00:00Z`): + +```json +{ + "ok": true, + "skill": "vote-tally", + "blockers": [], + "warnings": [], + "values": { + "roster_path": "projects/airflow/pmc-roster.md", + "version": "2.11.0", + "rc_number": "rc2", + "force_close": false, + "mechanism": "dev-list-vote", + "is_asf": true, + "window_hours": 72, + "elapsed_hours": 98.0, + "window_closes_utc": "2026-06-13T10:00:00Z" + } +} +``` diff --git a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-2-window-not-elapsed/report.md b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-2-window-not-elapsed/report.md index 8d4fb2ea5..a3e276029 100644 --- a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-2-window-not-elapsed/report.md +++ b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-2-window-not-elapsed/report.md @@ -7,16 +7,31 @@ Planning issue body excerpt: [VOTE] thread opened: 2026-06-14 08:00 UTC [VOTE] thread URL: https://lists.apache.org/thread/abc123 -release-management-config.md: - release_approval_mechanism: dev-list-vote - is_asf_tlp: true - vote_window_hours: 72 - release_approver_roster_path: projects/airflow/pmc-roster.md - result_subject_template: "[RESULT] [VOTE] Release Apache Airflow from -" - -projects/airflow/pmc-roster.md: exists, contains 7 rows. - Current UTC time: 2026-06-14 12:00 UTC -Vote window elapsed: no (4 hours since [VOTE] opened, window is 72 hours) --force-close was NOT passed. No previous partial-run AMBIGUOUS note on the planning issue. + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill vote-tally 2.11.0-rc2 --vote-opened 2026-06-14T08:00:00Z`): + +```json +{ + "ok": false, + "skill": "vote-tally", + "blockers": [ + "vote window has not elapsed: 4 hours elapsed of 72-hour window (closes 2026-06-17T08:00:00Z)" + ], + "warnings": [], + "values": { + "roster_path": "projects/airflow/pmc-roster.md", + "version": "2.11.0", + "rc_number": "rc2", + "force_close": false, + "mechanism": "dev-list-vote", + "is_asf": true, + "window_hours": 72, + "elapsed_hours": 4.0, + "window_closes_utc": "2026-06-17T08:00:00Z" + } +} +``` diff --git a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/expected.json b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/expected.json index 7c244b138..68318d6d0 100644 --- a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/expected.json +++ b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/expected.json @@ -1,6 +1,6 @@ { "verdict": "blocked", - "blockers": ["ASF TLP release requires release_approval_mechanism=dev-list-vote; config has github-discussion"], + "blockers": ["an ASF project (project.md → organization: ASF) requires release_approval_mechanism=dev-list-vote; config has github-discussion"], "force_close": false, "mechanism": "github-discussion", "roster_path": "projects/airflow/pmc-roster.md" diff --git a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/report.md b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/report.md index 352b31bfc..89dfc617e 100644 --- a/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/report.md +++ b/tools/skill-evals/evals/release-vote-tally/step-0-preflight/fixtures/case-3-asf-tlp-mechanism-mismatch/report.md @@ -7,18 +7,30 @@ Planning issue body excerpt: [VOTE] thread opened: 2026-06-10 10:00 UTC [VOTE] discussion: https://github.com/apache/airflow/discussions/9001 -release-management-config.md: - release_approval_mechanism: github-discussion - is_asf_tlp: true - approval_window_hours: 72 - approval_discussion_repo: apache/airflow - approval_discussion_category: Releases - release_approver_roster_path: projects/airflow/pmc-roster.md - result_subject_template: "[RESULT] [VOTE] Release Apache Airflow from -" - -projects/airflow/pmc-roster.md: exists, contains 7 rows. - Current UTC time: 2026-06-14 12:00 UTC -Vote window elapsed: yes (96 hours since discussion opened) --force-close was NOT passed. -Note: is_asf_tlp is true but release_approval_mechanism is github-discussion (not dev-list-vote). + +release-config preflight output +(`uv run --project /tools/release-config release-config preflight --skill vote-tally 2.11.0-rc2 --vote-opened 2026-06-10T10:00:00Z`): + +```json +{ + "ok": false, + "skill": "vote-tally", + "blockers": [ + "an ASF project (project.md → organization: ASF) requires release_approval_mechanism=dev-list-vote; config has github-discussion" + ], + "warnings": [], + "values": { + "roster_path": "projects/airflow/pmc-roster.md", + "version": "2.11.0", + "rc_number": "rc2", + "force_close": false, + "mechanism": "github-discussion", + "is_asf": true, + "window_hours": 72, + "elapsed_hours": 98.0, + "window_closes_utc": "2026-06-13T10:00:00Z" + } +} +``` diff --git a/uv.lock b/uv.lock index 92b5270eb..606ff6018 100644 --- a/uv.lock +++ b/uv.lock @@ -43,6 +43,7 @@ members = [ "pr-management-stats", "preflight-audit", "redactor", + "release-config", "reproducible-archive", "sandbox-lint", "security-tracker-stats-dashboard", @@ -707,8 +708,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, - { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -1707,6 +1708,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/3d/e0677f590f3473a3defb63ef6e3469154382a2a15ebafb364482d7ed3b01/regex-2026.9.3-cp315-cp315t-win_arm64.whl", hash = "sha256:ecc27adda0d1e1bc39793b41fdd562d2f4bc4dcee6ee0e3c733d519c332183b2", size = 283421, upload-time = "2026-09-01T00:53:41.393Z" }, ] +[[package]] +name = "release-config" +version = "0.1.0" +source = { editable = "tools/release-config" } + +[package.dev-dependencies] +dev = [ + { name = "magpie-dev" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] + [[package]] name = "reproducible-archive" version = "0.1.0" @@ -2217,8 +2233,8 @@ name = "uvicorn" version = "0.52.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, - { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "click" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } wheels = [