This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 680f141 feat(generate-cve-json): gate DRAFT → REVIEW on active
release vote (#360)
680f141 is described below
commit 680f14190d30c5db0482c96263c5809f12d7f71a
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu May 28 20:22:26 2026 +0200
feat(generate-cve-json): gate DRAFT → REVIEW on active release vote (#360)
The Vulnogram REVIEW state means "this CVE record is about to be
published", so for projects with a release-vote step before shipping
the advisory it should only fire while the RC is being voted —
otherwise re-runs of the generator keep auto-advancing populated
records to REVIEW outside the vote window, forcing reviewers to push
them back to DRAFT (and re-flag the record).
Make it opt-in so non-ASF adopters that publish advisories without a
separate release-vote step keep the original behaviour:
[workflow]
release_vote_gating = true # opt in
rc_voting_label = "rc voting" # label name to look for
When gating is on, REVIEW only fires when the configured label is
present on the tracker (the sync skill is responsible for adding it
in response to dev-list [VOTE] threads — out of scope for the
generator, which stays deterministic and reads gh issue view only).
Two CLI overrides land alongside the config switch:
--review force REVIEW for this run
--draft force DRAFT
fetch_issue now returns (title, body, labels) so main can read the
label list without a second gh call. Test fixture grows a [workflow]
section documenting both knobs.
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
tools/vulnogram/generate-cve-json/SKILL.md | 34 ++++
.../src/generate_cve_json/cve_json.py | 177 ++++++++++++++++++---
.../tests/fixtures/cve-json-config.toml | 16 ++
.../vulnogram/generate-cve-json/tests/test_cli.py | 160 ++++++++++++++++++-
.../tests/test_generate_cve_json.py | 60 +++++++
5 files changed, 421 insertions(+), 26 deletions(-)
diff --git a/tools/vulnogram/generate-cve-json/SKILL.md
b/tools/vulnogram/generate-cve-json/SKILL.md
index 7e83ede..7eb09bd 100644
--- a/tools/vulnogram/generate-cve-json/SKILL.md
+++ b/tools/vulnogram/generate-cve-json/SKILL.md
@@ -43,6 +43,29 @@ read the security team member's mind. Always review the
generated JSON
before pasting, and always do the final review inside Vulnogram before
moving the CVE from DRAFT → REVIEW → READY → PUBLIC.
+**Release-vote gating (opt-in, recommended for ASF projects).** The
+emitted `CNA_private.state` follows a tri-state state machine:
+
+- `DRAFT` — the CNA is incomplete *or* the project has opted into
+ release-vote gating and no vote is in progress yet.
+- `REVIEW` — the CNA is review-ready (CVE ID + title + description +
+ affected versions + CWE + severity + ≥ 1 credit + ≥ 1 reference)
+ *and* either the project hasn't opted into gating (legacy: ready ⇒
+ REVIEW) or an RC vote is in progress (signalled by the configured
+ tracker label or a `--review` CLI flag).
+- `PUBLIC` — the CNA is review-ready *and* the public advisory has
+ shipped (a `vendor-advisory` reference is present).
+
+Projects opt into gating by setting `[workflow].release_vote_gating
+= true` in their `cve-json-config.toml` and choosing the label name
+via `[workflow].rc_voting_label` (default `"rc voting"`). The sync
+skill is responsible for detecting [VOTE] threads on the project's
+dev list (e.g. `dev@<project>.apache.org`) and proposing the label
+add/remove; the generator only reads the label on the tracker. Non-
+ASF adopters who publish advisories without a separate release-vote
+step typically leave gating off — the legacy "ready ⇒ REVIEW"
+behaviour is the right default for that workflow.
+
**Determinism:** the same input issue body produces exactly the same JSON
bytes on every run. The script uses only the Python standard library, has
no timestamps or machine-dependent values in its output, sorts JSON keys,
@@ -95,6 +118,17 @@ diff the two to see what the tool has added / what you
changed by hand.
`EXTERNAL`, `USER`).
- `--no-envelope` — emit only the inner `cna` container instead of
the full CVE 5.x record (envelope is the default).
+ - `--review` / `--draft` (mutually exclusive) — force the emitted
+ `CNA_private.state` to `REVIEW` or `DRAFT` regardless of the
+ tracker's labels. Useful in two cases:
+ - `--review` lets a release manager nudge a record forward by
+ hand when the `rc voting` label is not yet set on the tracker.
+ - `--draft` walks a record back when an RC vote was cancelled or
+ failed and the label is still around.
+ Both flags only matter when release-vote gating is enabled in
+ the project's TOML config (see below); otherwise the state is
+ derived from the CNA's readiness alone and these flags have no
+ effect beyond what the legacy logic produces.
- `--attach` — after generating the JSON, embed it at the end of
the tracking issue's **body** (after the *CVE tool link* field),
wrapped in a collapsible `<details>` block. The block is bracketed
diff --git
a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
index 3bf04c2..c9252d5 100644
--- a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
+++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
@@ -183,6 +183,7 @@ def _populate_constants() -> None:
global TOP_LEVEL_NAME, TOP_LEVEL_PRODUCT, PROJECT_PRODUCT_TEMPLATE
global CNA_PRIVATE_PROJECT_URL, CNA_PRIVATE_OWNER, CNA_PRIVATE_USERS_LIST
global TITLE_STRIP_RE, TRACKER_FILTER_TOKEN
+ global RELEASE_VOTE_GATING, RC_VOTING_LABEL
DEFAULT_REPO = cfg["meta"]["tracker_repo"]
DEFAULT_VENDOR = cfg["product"]["vendor"]
@@ -209,6 +210,23 @@ def _populate_constants() -> None:
CNA_PRIVATE_OWNER = cna_private_cfg.get("owner", "")
CNA_PRIVATE_USERS_LIST = cna_private_cfg.get("users_list", "")
+ # Workflow gating around the DRAFT → REVIEW transition. The ASF CVE
+ # tool's REVIEW state means "this record is about to be published"
+ # — so for ASF projects with a release-vote cadence, advancing to
+ # REVIEW only makes sense once an RC is being voted on. Non-ASF
+ # adopters (which often don't have a separate release-vote step
+ # before publishing the advisory) keep the original behavior: a
+ # fully-populated record auto-advances to REVIEW.
+ #
+ # Opt in by setting `[workflow].release_vote_gating = true` and the
+ # tracker label name in `[workflow].rc_voting_label`. The sync skill
+ # is responsible for detecting active [VOTE] threads on the project's
+ # dev mailing list (e.g. `dev@<project>.apache.org`) and proposing
+ # the label add/remove; the generator only reads the label.
+ workflow_cfg = cfg.get("workflow", {})
+ RELEASE_VOTE_GATING = bool(workflow_cfg.get("release_vote_gating", False))
+ RC_VOTING_LABEL = workflow_cfg.get("rc_voting_label", "rc voting")
+
# Title-strip regex for `resolve_title` — built from the configured
# top-level product so the *"<vendor>: <product>:"* prefix is
# stripped without hardcoding a specific project's name.
@@ -249,6 +267,8 @@ CNA_PRIVATE_OWNER: str = ""
CNA_PRIVATE_USERS_LIST: str = ""
TITLE_STRIP_RE: re.Pattern[str] = re.compile("")
TRACKER_FILTER_TOKEN: str = ""
+RELEASE_VOTE_GATING: bool = False
+RC_VOTING_LABEL: str = ""
# CVE 5.x convention values that are not project-specific.
DEFAULT_CREDIT_TYPE = "finder"
@@ -399,12 +419,15 @@ ATTACHMENT_MARKER_PREFIX = ATTACHMENT_MARKER_BEGIN_PREFIX
# -----------------------------------------------------------------------------
-def fetch_issue(issue_number: int | str, repo: str) -> tuple[str, str]:
- """Return ``(title, body)`` for the given issue.
+def fetch_issue(issue_number: int | str, repo: str) -> tuple[str, str,
list[str]]:
+ """Return ``(title, body, labels)`` for the given issue.
- Calls ``gh issue view <N> --repo <repo> --json title,body``. Raises a
- ``RuntimeError`` with the stderr output if ``gh`` is unavailable or
- the issue does not exist.
+ Calls ``gh issue view <N> --repo <repo> --json title,body,labels``.
+ The ``labels`` list contains label names (strings); the gh JSON
+ objects' ``.name`` field is unwrapped here so callers can do a
+ simple ``"rc voting" in labels`` check. Raises a ``RuntimeError``
+ with the stderr output if ``gh`` is unavailable or the issue does
+ not exist.
"""
try:
result = subprocess.run(
@@ -416,7 +439,7 @@ def fetch_issue(issue_number: int | str, repo: str) ->
tuple[str, str]:
"--repo",
repo,
"--json",
- "title,body",
+ "title,body,labels",
],
check=True,
capture_output=True,
@@ -431,7 +454,12 @@ def fetch_issue(issue_number: int | str, repo: str) ->
tuple[str, str]:
raise RuntimeError(f"`gh issue view {issue_number} --repo {repo}`
failed:\n{exc.stderr}") from exc
data = json.loads(result.stdout)
- return data.get("title", ""), data.get("body", "")
+ labels = [
+ label.get("name", "")
+ for label in (data.get("labels") or [])
+ if isinstance(label, dict) and label.get("name")
+ ]
+ return data.get("title", ""), data.get("body", ""), labels
# -----------------------------------------------------------------------------
@@ -1093,17 +1121,55 @@ def _is_cna_ready_for_review(cna: dict, cve_id: str) ->
bool:
return any(r.get("url") for r in references)
-def compute_cna_private_state(cna: dict, cve_id: str) -> str:
+def compute_cna_private_state(
+ cna: dict,
+ cve_id: str,
+ *,
+ release_vote_in_progress: bool | None = None,
+) -> str:
"""Return the ``CNA_private.state`` value (DRAFT / REVIEW / PUBLIC).
- Centralises the two-step decision that :func:`wrap_cve_record` makes
- so other code paths (the issue-body attachment table) can display
- the same state without duplicating the logic.
+ State machine:
+
+ * ``DRAFT`` — the CNA is incomplete (some required field missing)
+ *or* the caller signalled "release-vote gating is on but no vote
+ is happening" (``release_vote_in_progress=False``).
+ * ``REVIEW`` — the CNA is fully populated (``_is_cna_ready_for_review``
+ passes), no public advisory has shipped yet, AND either
+ release-vote gating is disabled (``release_vote_in_progress=None``,
+ the default — legacy behaviour for non-ASF adopters) or the
+ caller signalled that an RC is being voted
+ (``release_vote_in_progress=True``).
+ * ``PUBLIC`` — the CNA is review-ready *and* the public advisory
+ has shipped (a ``vendor-advisory`` reference is present).
+
+ The ``release_vote_in_progress`` parameter is tri-state:
+
+ * ``None`` (default): release-vote gating is *off* — a ready CNA
+ advances to REVIEW immediately. This preserves the original
+ generator behaviour and is the right default for non-ASF
+ adopters that don't have a separate release-vote step before
+ publishing an advisory.
+ * ``True``: release-vote gating is on and the vote is in progress
+ — a ready CNA advances to REVIEW. ASF projects opt into this
+ via ``[workflow].release_vote_gating = true`` plus either the
+ ``rc voting`` tracker label or a ``--review`` CLI override.
+ * ``False``: release-vote gating is on but no vote is happening
+ — a ready CNA stays at DRAFT. This is the new "don't auto-
+ advance to REVIEW outside the vote window" behaviour requested
+ for ASF projects, where REVIEW carries the semantics
+ "release manager is about to publish".
+
+ Centralises the decision that :func:`wrap_cve_record` makes so
+ other code paths (the issue-body attachment table) can display the
+ same state without duplicating the logic.
"""
if not _is_cna_ready_for_review(cna, cve_id):
return "DRAFT"
if _has_vendor_advisory_reference(cna):
return "PUBLIC"
+ if release_vote_in_progress is False:
+ return "DRAFT"
return "REVIEW"
@@ -1154,7 +1220,13 @@ def format_version_range(versions: list[dict]) -> str:
return "; ".join(parts)
-def wrap_cve_record(cna: dict, *, cve_id: str, org_id: str) -> dict:
+def wrap_cve_record(
+ cna: dict,
+ *,
+ cve_id: str,
+ org_id: str,
+ release_vote_in_progress: bool | None = None,
+) -> dict:
"""Wrap the ``cna`` container in a CVE 5.x record envelope.
The envelope matches the real Vulnogram export shape: ``dataType``,
@@ -1179,9 +1251,15 @@ def wrap_cve_record(cna: dict, *, cve_id: str, org_id:
str) -> dict:
* ``"REVIEW"`` when every field a release manager needs is
present (CVE ID, title, description, affected versions,
CWE, non-``Unknown`` severity, at least one credit, at
- least one reference) **but** no public advisory URL has
- been captured yet. This is the state the RM pastes at
- Step 13 when sending the advisory email.
+ least one reference), **no public advisory URL has been
+ captured yet**, *and* the caller signalled that an RC is
+ being voted (``release_vote_in_progress=True`` —
+ typically because the tracker carries the configured
+ ``rc voting`` label). This is the state the RM pastes at
+ Step 13 when sending the advisory email. Without the
+ vote signal the record stays at ``DRAFT`` even when fully
+ populated; this avoids auto-advancing records that are
+ not yet at the *send-the-advisory* moment.
* ``"PUBLIC"`` when the CNA is review-ready **and** at least
one ``references[]`` entry is tagged ``vendor-advisory`` —
which is the case as soon as the tracking issue's *"Public
@@ -1205,7 +1283,9 @@ def wrap_cve_record(cna: dict, *, cve_id: str, org_id:
str) -> dict:
that cve.org reads when the record eventually flows through
Vulnogram.
"""
- cna_private_state = compute_cna_private_state(cna, cve_id)
+ cna_private_state = compute_cna_private_state(
+ cna, cve_id, release_vote_in_progress=release_vote_in_progress
+ )
record: dict = {
"CNA_private": {
"emailed": "yes" if cna_private_state == "PUBLIC" else None,
@@ -1693,6 +1773,29 @@ def parse_args(argv: list[str] | None = None) ->
argparse.Namespace:
"only' mode."
),
)
+ state_override = parser.add_mutually_exclusive_group()
+ state_override.add_argument(
+ "--review",
+ action="store_true",
+ help=(
+ "Force `CNA_private.state = REVIEW` for this run (overrides "
+ "the tracker-label signal). Use when nudging a record forward "
+ "by hand — for example when the release manager has cut an RC "
+ "but the `rc voting` label is not yet on the tracker. Requires "
+ "the CNA to be review-ready (all fields populated); otherwise "
+ "the state stays DRAFT regardless."
+ ),
+ )
+ state_override.add_argument(
+ "--draft",
+ action="store_true",
+ help=(
+ "Force `CNA_private.state = DRAFT` for this run (overrides "
+ "the tracker-label signal). Use to walk a record back to "
+ "DRAFT when an RC vote failed or was cancelled and the "
+ "`rc voting` label is still on the tracker."
+ ),
+ )
parser.add_argument(
"--attach",
action="store_true",
@@ -1782,6 +1885,7 @@ def main(argv: list[str] | None = None) -> int:
)
return 2
+ issue_labels: list[str] = []
if args.stdin:
body = sys.stdin.read()
issue_title = args.title or ""
@@ -1793,11 +1897,35 @@ def main(argv: list[str] | None = None) -> int:
)
return 2
try:
- issue_title, body = fetch_issue(args.issue, args.repo)
+ issue_title, body, issue_labels = fetch_issue(args.issue,
args.repo)
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
+ # CNA_private.state = REVIEW is tri-state. When release-vote gating
+ # is disabled (the default, [workflow].release_vote_gating = false),
+ # a fully-populated CNA advances to REVIEW immediately — the legacy
+ # behaviour kept for non-ASF adopters that don't have a separate
+ # release-vote step before publishing the advisory.
+ #
+ # When release-vote gating is enabled (ASF adopters opt in), the
+ # signal is computed from one of:
+ # - explicit CLI overrides (--review / --draft);
+ # - the presence of RC_VOTING_LABEL on the tracker;
+ # - default to False (no vote happening).
+ # In --stdin mode we have no labels to inspect, so the gated default
+ # is False — pass --review explicitly to opt into REVIEW from a
+ # piped body.
+ release_vote_in_progress: bool | None
+ if args.review:
+ release_vote_in_progress = True
+ elif args.draft:
+ release_vote_in_progress = False
+ elif RELEASE_VOTE_GATING:
+ release_vote_in_progress = RC_VOTING_LABEL in issue_labels
+ else:
+ release_vote_in_progress = None # legacy: ready ⇒ REVIEW
+
summary = extract_field(body, "Short public summary for publish")
affected_field = extract_field(body, "Affected versions")
mailing_list = extract_field(body, "Security mailing list thread")
@@ -1869,7 +1997,12 @@ def main(argv: list[str] | None = None) -> int:
if args.no_envelope:
payload: dict = cna
else:
- payload = wrap_cve_record(cna, cve_id=cve_id, org_id=args.org_id)
+ payload = wrap_cve_record(
+ cna,
+ cve_id=cve_id,
+ org_id=args.org_id,
+ release_vote_in_progress=release_vote_in_progress,
+ )
text = emit_json(payload, args.output)
if args.output is None:
@@ -1890,7 +2023,13 @@ def main(argv: list[str] | None = None) -> int:
cve_id=cve_id,
json_text=text,
cna=cna,
- cna_private_state=None if args.no_envelope else
compute_cna_private_state(cna, cve_id),
+ cna_private_state=(
+ None
+ if args.no_envelope
+ else compute_cna_private_state(
+ cna, cve_id,
release_vote_in_progress=release_vote_in_progress
+ )
+ ),
)
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
diff --git
a/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
index ec9f83a..602f499 100644
--- a/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
+++ b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
@@ -61,6 +61,22 @@ project_url = "https://example.apache.org/"
owner = "example"
users_list = "[email protected]"
+[workflow]
+# Opt-in: when true, the DRAFT → REVIEW transition is gated on an
+# active release vote (detected either via the rc_voting_label below
+# or via the --review CLI override). Default: false — non-ASF
+# adopters that publish advisories without a separate release-vote
+# step want the original behavior where a fully-populated record
+# auto-advances to REVIEW. ASF adopters set this to true to keep the
+# Vulnogram REVIEW state aligned with the actual "RC is being voted"
+# window.
+release_vote_gating = false
+
+# Tracker label that signals "an RC is currently being voted". Only
+# consulted when release_vote_gating = true; otherwise the label has
+# no effect on the emitted state. Default: "rc voting".
+rc_voting_label = "rc voting"
+
[meta]
# Tracker repo slug (org/name). Used for the `x_generator.engine`
# tag in the CVE record and for the self-source-link below.
diff --git a/tools/vulnogram/generate-cve-json/tests/test_cli.py
b/tools/vulnogram/generate-cve-json/tests/test_cli.py
index 937b543..29dd634 100644
--- a/tools/vulnogram/generate-cve-json/tests/test_cli.py
+++ b/tools/vulnogram/generate-cve-json/tests/test_cli.py
@@ -202,16 +202,39 @@ class TestSpliceAttachment:
class TestFetchIssue:
- def test_returns_title_and_body(self):
- completed = MagicMock(stdout=json.dumps({"title": "T", "body": "B"}))
+ def test_returns_title_body_and_labels(self):
+ completed = MagicMock(
+ stdout=json.dumps(
+ {
+ "title": "T",
+ "body": "B",
+ "labels": [
+ {"name": "rc voting"},
+ {"name": "airflow"},
+ ],
+ }
+ )
+ )
with patch("generate_cve_json.cve_json.subprocess.run",
return_value=completed) as run:
- title, body = cve_json.fetch_issue(42, "owner/repo")
+ title, body, labels = cve_json.fetch_issue(42, "owner/repo")
assert (title, body) == ("T", "B")
+ assert labels == ["rc voting", "airflow"]
# Verify the gh call shape.
cmd = run.call_args.args[0]
assert cmd[:3] == ["gh", "issue", "view"]
assert "--repo" in cmd
assert "owner/repo" in cmd
+ # Verify we ask gh for labels in addition to title and body.
+ json_arg_index = cmd.index("--json")
+ assert "labels" in cmd[json_arg_index + 1]
+
+ def test_returns_empty_labels_when_field_missing(self):
+ # gh returns no `labels` key when the issue has no labels — we
+ # default to an empty list rather than blowing up.
+ completed = MagicMock(stdout=json.dumps({"title": "T", "body": "B"}))
+ with patch("generate_cve_json.cve_json.subprocess.run",
return_value=completed):
+ title, body, labels = cve_json.fetch_issue(42, "owner/repo")
+ assert (title, body, labels) == ("T", "B", [])
def test_gh_missing_raises_runtime_error(self):
with patch(
@@ -456,7 +479,7 @@ class TestMainHappyPath:
def test_fetch_path_uses_gh(self, capsys):
with patch(
"generate_cve_json.cve_json.fetch_issue",
- return_value=("Issue title", _issue_body()),
+ return_value=("Issue title", _issue_body(), []),
) as fetch:
rc = cve_json.main(["123"])
assert rc == 0
@@ -468,7 +491,7 @@ class TestMainHappyPath:
with (
patch(
"generate_cve_json.cve_json.fetch_issue",
- return_value=("Issue title", _issue_body()),
+ return_value=("Issue title", _issue_body(), []),
),
patch(
"generate_cve_json.cve_json.attach_to_issue",
@@ -485,7 +508,7 @@ class TestMainHappyPath:
with (
patch(
"generate_cve_json.cve_json.fetch_issue",
- return_value=("Issue title", _issue_body()),
+ return_value=("Issue title", _issue_body(), []),
),
patch(
"generate_cve_json.cve_json.attach_to_issue",
@@ -500,7 +523,7 @@ class TestMainHappyPath:
with (
patch(
"generate_cve_json.cve_json.fetch_issue",
- return_value=("T", _issue_body()),
+ return_value=("T", _issue_body(), []),
),
patch(
"generate_cve_json.cve_json.attach_to_issue",
@@ -510,3 +533,126 @@ class TestMainHappyPath:
rc = cve_json.main(["123", "--attach"])
assert rc == 1
assert "attach boom" in capsys.readouterr().err
+
+
+# --- main: release-vote gating --------------------------------------------
+
+
+class TestReleaseVoteGating:
+ """End-to-end coverage for the [workflow].release_vote_gating switch
+ and the --review / --draft CLI overrides.
+
+ The state machine has three modes:
+
+ 1. Gating off (default): fully-populated CNA ⇒ REVIEW. Legacy
+ behaviour preserved for non-ASF adopters.
+ 2. Gating on, no label / no override: CNA stays at DRAFT.
+ 3. Gating on + rc-voting label *or* --review: CNA ⇒ REVIEW.
+
+ The --review / --draft flags also work in gating-off mode (manual
+ overrides remain available regardless of config).
+ """
+
+ @staticmethod
+ def _write_gating_on_config(tmp_path, label: str = "rc voting") -> str:
+ """Write a config that mirrors the fixture but with
+ release_vote_gating = true. Returns its path as a string
+ suitable for the --config CLI flag.
+ """
+ fixture = (Path(__file__).resolve().parent / "fixtures" /
"cve-json-config.toml").read_text()
+ # Flip the gating flag and (optionally) the label name.
+ patched = fixture.replace(
+ "release_vote_gating = false",
+ "release_vote_gating = true",
+ ).replace(
+ 'rc_voting_label = "rc voting"',
+ f'rc_voting_label = "{label}"',
+ )
+ cfg_path = tmp_path / "cve-json-config-gating-on.toml"
+ cfg_path.write_text(patched)
+ return str(cfg_path)
+
+ def test_default_gating_off_emits_review(self, capsys):
+ # Default config has release_vote_gating = false, so a ready
+ # CNA without any vote signal still advances to REVIEW.
+ with patch(
+ "generate_cve_json.cve_json.fetch_issue",
+ return_value=("Issue title", _issue_body(), []),
+ ):
+ rc = cve_json.main(["123"])
+ assert rc == 0
+ record = json.loads(capsys.readouterr().out)
+ assert record["CNA_private"]["state"] == "REVIEW"
+
+ def test_gating_on_without_label_stays_draft(self, capsys, tmp_path):
+ cfg = self._write_gating_on_config(tmp_path)
+ with patch(
+ "generate_cve_json.cve_json.fetch_issue",
+ return_value=("Issue title", _issue_body(), ["airflow"]),
+ ):
+ rc = cve_json.main(["123", "--config", cfg])
+ assert rc == 0
+ record = json.loads(capsys.readouterr().out)
+ assert record["CNA_private"]["state"] == "DRAFT"
+
+ def test_gating_on_with_label_emits_review(self, capsys, tmp_path):
+ cfg = self._write_gating_on_config(tmp_path)
+ with patch(
+ "generate_cve_json.cve_json.fetch_issue",
+ return_value=("Issue title", _issue_body(), ["airflow", "rc
voting"]),
+ ):
+ rc = cve_json.main(["123", "--config", cfg])
+ assert rc == 0
+ record = json.loads(capsys.readouterr().out)
+ assert record["CNA_private"]["state"] == "REVIEW"
+
+ def test_review_flag_overrides_gating_with_no_label(self, capsys,
tmp_path):
+ cfg = self._write_gating_on_config(tmp_path)
+ with patch(
+ "generate_cve_json.cve_json.fetch_issue",
+ return_value=("Issue title", _issue_body(), []),
+ ):
+ rc = cve_json.main(["123", "--config", cfg, "--review"])
+ assert rc == 0
+ record = json.loads(capsys.readouterr().out)
+ assert record["CNA_private"]["state"] == "REVIEW"
+
+ def test_draft_flag_overrides_gating_with_label(self, capsys, tmp_path):
+ # --draft beats a "rc voting" label — used to walk a record
+ # back when the RC vote failed but the label is still on the
+ # tracker.
+ cfg = self._write_gating_on_config(tmp_path)
+ with patch(
+ "generate_cve_json.cve_json.fetch_issue",
+ return_value=("Issue title", _issue_body(), ["rc voting"]),
+ ):
+ rc = cve_json.main(["123", "--config", cfg, "--draft"])
+ assert rc == 0
+ record = json.loads(capsys.readouterr().out)
+ assert record["CNA_private"]["state"] == "DRAFT"
+
+ def test_review_and_draft_are_mutually_exclusive(self):
+ # argparse raises SystemExit on mutually-exclusive group conflict;
+ # the exit code is 2.
+ with pytest.raises(SystemExit) as exc_info:
+ cve_json.main(["--stdin", "--review", "--draft"])
+ assert exc_info.value.code == 2
+
+ def test_custom_rc_voting_label_from_config(self, capsys, tmp_path):
+ # The label name is configurable; if a project sets a custom
+ # rc_voting_label, only that label gates REVIEW. The default
+ # "rc voting" label on a tracker for such a project is just
+ # an unrelated tag.
+ cfg = self._write_gating_on_config(tmp_path,
label="release-vote-in-progress")
+ with patch(
+ "generate_cve_json.cve_json.fetch_issue",
+ return_value=(
+ "Issue title",
+ _issue_body(),
+ ["rc voting"], # wrong label name — should NOT trigger
+ ),
+ ):
+ rc = cve_json.main(["123", "--config", cfg])
+ assert rc == 0
+ record = json.loads(capsys.readouterr().out)
+ assert record["CNA_private"]["state"] == "DRAFT"
diff --git a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
index 2615577..dfb860e 100644
--- a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
+++ b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
@@ -868,10 +868,36 @@ class TestWrapCveRecord:
assert record["cveMetadata"]["state"] == "PUBLISHED"
def test_ready_record_emits_review_workflow_state(self):
+ # Legacy / non-gated default: a ready CNA advances to REVIEW
+ # without any signal. This is what non-ASF adopters get when
+ # they don't set [workflow].release_vote_gating in their config.
cna = _ready_cna()
record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
assert record["CNA_private"]["state"] == "REVIEW"
+ def test_ready_record_gated_without_vote_stays_draft(self):
+ # Gated path (ASF adopters with release_vote_gating = true): when
+ # the caller signals that no vote is happening, a ready CNA
+ # stays at DRAFT. REVIEW is reserved for the actual vote window.
+ cna = _ready_cna()
+ record = wrap_cve_record(
+ cna,
+ cve_id="CVE-2026-00001",
+ org_id="org",
+ release_vote_in_progress=False,
+ )
+ assert record["CNA_private"]["state"] == "DRAFT"
+
+ def test_ready_record_gated_with_vote_emits_review(self):
+ cna = _ready_cna()
+ record = wrap_cve_record(
+ cna,
+ cve_id="CVE-2026-00001",
+ org_id="org",
+ release_vote_in_progress=True,
+ )
+ assert record["CNA_private"]["state"] == "REVIEW"
+
def test_incomplete_record_emits_draft_workflow_state(self):
cna = _ready_cna()
cna["credits"] = []
@@ -959,6 +985,40 @@ class TestComputeCnaPrivateState:
cna["credits"] = []
assert compute_cna_private_state(cna, "CVE-2026-00001") == "DRAFT"
+ def test_gated_without_vote_is_draft(self):
+ # Tri-state: explicit False ⇒ DRAFT even when ready.
+ assert (
+ compute_cna_private_state(
+ _ready_cna(),
+ "CVE-2026-00001",
+ release_vote_in_progress=False,
+ )
+ == "DRAFT"
+ )
+
+ def test_gated_with_vote_is_review(self):
+ assert (
+ compute_cna_private_state(
+ _ready_cna(),
+ "CVE-2026-00001",
+ release_vote_in_progress=True,
+ )
+ == "REVIEW"
+ )
+
+ def test_gated_with_advisory_overrides_vote_flag(self):
+ # Even when release_vote_in_progress=False, a vendor-advisory
+ # reference promotes the state to PUBLIC. The PUBLIC transition
+ # is not gated by the vote signal — once the advisory shipped,
+ # the record IS public.
+ cna = _ready_cna()
+ cna["references"] = build_references(
+ mailing_list_field="",
+ pr_field="https://github.com/apache/airflow/pull/123",
+ extra_urls=["https://lists.apache.org/thread/abc123xyz789"],
+ )
+ assert compute_cna_private_state(cna, "CVE-2026-00001",
release_vote_in_progress=False) == "PUBLIC"
+
# ---------------------------------------------------------------------------
# compute_package_url