fix(ci): retarget label docs at triage.yml and guard that applied labels are declared (#7328)

* fix(ci): retarget label docs at triage.yml and guard label declarations

.github/labels.yml still described the auto-labeling setup as the three
workflows triage.yml replaced (pr-labeler.yml / pr-triage.yml /
issue-triage.yml), and its section headers pointed at the deleted
.github/labeler.yml and pr-triage.yml. A reader following those pointers
lands on 404s.

The same pair had an unguarded failure mode: triage.yml applies labels by
name, and GitHub rejects the whole addLabels batch with 422 when any name is
missing from the repository. Nothing checked that a label added to the
workflow also reached the declarative source of truth.

Point the comments at triage.yml and add scripts/check_label_declarations.py,
which fails when the workflow applies a label labels.yml does not declare.

* fix(ci): trace applied labels and cover the declaration guard

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Smilewithoutfalling 2026-10-24 10:28:06 +08:00 committed by GitHub
parent 4583c46311
commit cf15f05f4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 499 additions and 18 deletions

12
.github/labels.yml vendored
View File

@ -1,9 +1,9 @@
# Declarative label source of truth for DeerFlow.
#
# This file is the single source of truth for repository labels used by the
# auto-labeling workflows (.github/workflows/pr-labeler.yml, pr-triage.yml,
# issue-triage.yml). Auto-labelers can only apply labels that already exist,
# so every label referenced by a workflow MUST be declared here.
# auto-labeling workflow (.github/workflows/triage.yml). Auto-labelers can only
# apply labels that already exist, so every label applied by that workflow MUST
# be declared here; scripts/check_label_declarations.py enforces that pair.
#
# Apply with: uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME]
# CI keeps it in sync via .github/workflows/label-sync.yml (runs on changes here).
@ -30,7 +30,7 @@ labels:
color: d876e3
description: Further information is requested
# ── Area (auto, by changed paths — see .github/labeler.yml) ───────────────
# ── Area (auto, by changed paths — see workflows/triage.yml) ──────────────
# Mirrors the "Surface area" section of the pull request template.
- name: "area:frontend"
color: c5def5
@ -60,7 +60,7 @@ labels:
color: c5def5
description: Dependency manifests / lockfiles
# ── Size (auto, by additions + deletions — see pr-triage.yml) ─────────────
# ── Size (auto, by additions + deletions — see workflows/triage.yml) ──────
- name: "size/XS"
color: "009900"
description: PR changes < 20 lines
@ -77,7 +77,7 @@ labels:
color: ee5500
description: PR changes 700+ lines
# ── Risk (auto, by changed paths — see pr-triage.yml) ─────────────────────
# ── Risk (auto, by changed paths — see workflows/triage.yml) ──────────────
- name: "risk:low"
color: 0e8a16
description: "Low risk: docs / i18n / assets only"

View File

@ -38,6 +38,19 @@ jobs:
--after "${{ github.sha }}"
--github-annotations
label-declarations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Check every label applied by triage.yml is declared in labels.yml
run: python scripts/check_label_declarations.py
lint-backend:
runs-on: ubuntu-latest
steps:

View File

@ -0,0 +1,195 @@
"""Offline checks of the triage workflow's literal label declarations."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SPEC = importlib.util.spec_from_file_location("label_declarations", REPO_ROOT / "scripts/check_label_declarations.py")
assert SPEC is not None and SPEC.loader is not None
checker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(checker)
APPLIED = {
"area:frontend",
"area:backend",
"area:agents",
"area:sandbox",
"area:skills",
"area:mcp",
"area:ci",
"area:docs",
"area:deps",
"size/XS",
"size/S",
"size/M",
"size/L",
"size/XL",
"risk:low",
"risk:medium",
"risk:high",
"needs-validation",
"first-time-contributor",
"reviewing",
"needs-triage",
}
def workflow(script: str) -> str:
return "jobs:\n triage:\n steps:\n - uses: actions/github-script@v8\n with:\n script: |\n" + "\n".join(" " + line for line in script.splitlines()) + "\n"
@pytest.fixture
def files(tmp_path, monkeypatch):
labels = tmp_path / "labels.yml"
triage = tmp_path / "triage.yml"
labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(20)), encoding="utf-8")
triage.write_text(workflow("github.rest.issues.addLabels({labels: ['label-0']});"), encoding="utf-8")
monkeypatch.setattr(checker, "LABELS_FILE", labels)
monkeypatch.setattr(checker, "TRIAGE_WORKFLOW", triage)
return labels, triage
def test_repository_labels_are_declared():
assert checker.applied_labels(checker.TRIAGE_WORKFLOW) == APPLIED
assert checker.main() == 0
def test_missing_declaration_is_named(files, capsys):
labels, triage = files
labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(1, 21)), encoding="utf-8")
assert checker.main() == 1
assert " - label-0\n" in capsys.readouterr().err
@pytest.mark.parametrize("count", [0, 19])
def test_declaration_parse_floor(files, count):
labels, _ = files
labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(count)), encoding="utf-8")
with pytest.raises(SystemExit, match=f"Only {count} labels parsed"):
checker.main()
def test_empty_extraction_fails(files):
_, triage = files
triage.write_text(workflow("core.info('no label operations');"), encoding="utf-8")
with pytest.raises(SystemExit, match="No label literals"):
checker.main()
@pytest.mark.parametrize("label", ["area:infra", "area:i18n", "awaiting-review", "priority:P1", "urgent", "needs approval", "待审核"])
def test_all_literal_label_names_are_checked(files, capsys, label):
_, triage = files
triage.write_text(workflow(f"github.rest.issues.addLabels({{labels: ['label-0', '{label}']}});"), encoding="utf-8")
assert checker.main() == 1
assert f" - {label}\n" in capsys.readouterr().err
@pytest.mark.parametrize("comment", ["// someday maybe add 'needs-changes' here", "/* someday maybe add 'needs-changes' here */", "// addLabels({labels: ['needs-changes']});"])
def test_javascript_comments_are_not_labels(files, comment):
_, triage = files
triage.write_text(workflow(f"github.rest.issues.addLabels({{labels: ['label-0']}}); {comment}"), encoding="utf-8")
assert checker.main() == 0
def test_yaml_comments_are_not_labels(files):
_, triage = files
triage.write_text("# someday maybe add 'needs-changes' here\n" + workflow("addLabels({labels: ['label-0']});"), encoding="utf-8")
assert checker.main() == 0
def test_unrelated_strings_and_logs_are_not_labels(files):
_, triage = files
triage.write_text(workflow("const unrelated = 'needs-changes';\ncore.info('needs-changes');\ncore.info(`mentions 'needs-changes'`);\naddLabels({labels: ['label-0']});"), encoding="utf-8")
assert checker.main() == 0
def test_tracks_arrays_aliases_ternaries_and_mutations(files, capsys):
_, triage = files
triage.write_text(
workflow("""
const paths = ['docs/'];
const RULES = [['area:i18n', [/^frontend\\//]], ['area:infra', [/^docker\\//]]];
const areaLabels = RULES.filter(([, rules]) => rules.length).map(([label]) => label);
const sizeLabel = paths.includes('not-a-label') ? 'size/XS' : 'size/M';
const desired = new Set([...areaLabels, sizeLabel]);
if (paths.length) desired.add('awaiting-review');
const toAdd = [...desired].filter(n => n !== 'not-a-label');
toAdd.push('needs approval');
const alias = toAdd;
github.rest.issues.addLabels({labels: alias});
"""),
encoding="utf-8",
)
assert checker.applied_labels(triage) == {"area:i18n", "area:infra", "size/XS", "size/M", "awaiting-review", "needs approval"}
assert checker.main() == 1
assert "not-a-label" not in capsys.readouterr().err
def test_jobs_do_not_share_variable_bindings(files):
_, triage = files
triage.write_text(workflow("const labels = ['label-0'];\naddLabels({labels});") + workflow("const labels = ['label-1'];\naddLabels({labels});").replace("jobs:\n", "", 1).replace(" triage:\n", " second:\n", 1), encoding="utf-8")
assert checker.applied_labels(triage) == {"label-0", "label-1"}
def test_escaped_quotes_and_comment_markers_in_labels(files):
_, triage = files
triage.write_text(workflow(r"""addLabels({labels: ["owner's // note", 'needs \'review\'', 'area:\u0069' + '18n']});"""), encoding="utf-8")
with pytest.raises((ValueError, SystemExit), match="computed|extract"):
checker.applied_labels(triage)
def test_escaped_label_literals(files):
_, triage = files
triage.write_text(workflow(r"""addLabels({"labels": ["owner's // note", 'needs \'review\'', 'area:\u006918n']});"""), encoding="utf-8")
assert checker.applied_labels(triage) == {"owner's // note", "needs 'review'", "area:i18n"}
@pytest.mark.parametrize("script", ["addLabels({labels: computed()});", "addLabels({labels: [`label-${number}`]});"])
def test_unresolved_or_computed_values_fail_closed(files, script):
_, triage = files
triage.write_text(workflow(script), encoding="utf-8")
with pytest.raises(SystemExit, match="extract|computed"):
checker.main()
@pytest.mark.parametrize("missing", [0, 1])
def test_missing_inputs_fail(files, missing):
files[missing].unlink()
with pytest.raises(SystemExit, match="Missing"):
checker.main()
@pytest.mark.parametrize(
"script",
[
"const labels = ['label-0', ...unknownLabels]; addLabels({labels});",
"const labels = ['label-0', otherLabel]; addLabels({labels});",
"const labels = ['label-0']; const alias = labels.map(n => transform(n)); addLabels({labels: alias});",
],
)
def test_partial_extraction_cannot_hide_unresolved_labels(files, script):
_, triage = files
triage.write_text(workflow(script), encoding="utf-8")
with pytest.raises(SystemExit, match="extract|computed"):
checker.main()
def test_nested_conditional_labels(files):
_, triage = files
triage.write_text(workflow("const label = first ? second ? 'label-0' : 'label-1' : 'label-2'; addLabels({labels: [label]});"), encoding="utf-8")
assert checker.applied_labels(triage) == {"label-0", "label-1", "label-2"}
@pytest.mark.parametrize("label", sorted(APPLIED))
def test_every_real_applied_label_is_enforced(files, capsys, label):
labels, triage = files
manifest = (REPO_ROOT / ".github/labels.yml").read_text(encoding="utf-8")
labels.write_text("\n".join(line for line in manifest.splitlines() if line.strip() not in {f'- name: "{label}"', f"- name: {label}"}), encoding="utf-8")
triage.write_text((REPO_ROOT / ".github/workflows/triage.yml").read_text(encoding="utf-8"), encoding="utf-8")
assert checker.main() == 1
assert f" - {label}\n" in capsys.readouterr().err

View File

@ -23,21 +23,21 @@ For those launches, export the effective `DEER_FLOW_HOME` when collecting a bund
and ensure the checkout `.env` does not override it. Tests clear all three runtime
path variables and compare storage defaults with the launcher's actual shell blocks.
## Dependency Check Diagnostics
## Dependency and Label Checks
`check_extension.py` checks wheels with Gateway's asset validator.
CI builds and checks all bundled examples in `extension-package-check.yml`.
Tests: `backend/tests/test_extension_check_script.py`.
`check_extension.py` uses Gateway's wheel asset validator; CI checks examples
in `extension-package-check.yml`. Tests: `backend/tests/test_extension_check_script.py`.
`check.py` decodes captured output as UTF-8 with replacement and runs pnpm with
`PYTHONIOENCODING=utf-8:backslashreplace`. Preserve Unicode diagnostics and exit
status in `make check`, independently of the host locale.
Tests: `backend/tests/test_check_script.py`.
`check.py`: decode UTF-8 with replacement; pnpm uses
`PYTHONIOENCODING=utf-8:backslashreplace`. Keep Unicode/exit status in
`make check` in any locale. Tests: `backend/tests/test_check_script.py`.
`doctor.py` and `setup_wizard.py` configure stdout with `errors="replace"` at
the start of `main`, preserving its encoding so legacy terminals and pipes can
finish reports and setup. Guard `reconfigure` for embedded/captured streams.
Offline subprocess coverage: `backend/tests/test_setup_script_encoding.py`.
`doctor.py`/`setup_wizard.py`: at `main` start, keep stdout's encoding
with `errors="replace"` for legacy terminals/pipes. Guard `reconfigure` on
embedded/captured streams. Offline tests: `backend/tests/test_setup_script_encoding.py`.
`check_label_declarations.py`: stdlib-only guard; reject unsupported
expressions. Tests: `backend/tests/test_label_declarations.py`.
## Manual Claude OAuth Export

View File

@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""Check that every label the triage workflow applies is declared in labels.yml.
``.github/labels.yml`` is the declarative source of truth for repository labels,
and ``.github/workflows/triage.yml`` applies labels by name at runtime. GitHub
rejects an ``addLabels`` call with **422** when *any* requested label does not
exist, and drops the whole batch -- so a label added to the workflow but
forgotten here makes labelling stop working silently instead of failing loudly.
This is the static guard for that pair: it extracts the label literals the
triage workflow can apply and asserts each one is declared in labels.yml.
The reader follows quoted literals through arrays, conditional values, variable
aliases, Sets and add/push mutations in each workflow script. Filter predicates,
log messages, YAML comments and JavaScript comments are not label sources. The
current AREA_RULES first-element map is supported; other map transformations,
computed calls/strings and unresolved values fail closed. This is not a general
JavaScript evaluator. The read-only ``skip-validation`` control is not applied.
Standard library only -- the check has to stay cheap enough to run on every PR.
Regression coverage: backend/tests/test_label_declarations.py.
Usage:
python scripts/check_label_declarations.py
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
from typing import NamedTuple
REPO_ROOT = Path(__file__).resolve().parent.parent
LABELS_FILE = REPO_ROOT / ".github" / "labels.yml"
TRIAGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "triage.yml"
# Only workflow JavaScript is inspected; YAML and JavaScript comments are not
# label sources. Regex literals must be tokens too, so their escaped slashes
# cannot be mistaken for the start of a comment.
SCRIPT = re.compile(r"^(?P<indent> *)script:\s*[|>][-+]?\s*(?:#.*)?$")
JS_TOKEN = re.compile(
r"(?P<comment>//[^\n]*|/\*.*?\*/)"
r"|(?P<string>'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")"
r"|(?P<template>`(?:\\.|[^`\\])*`)"
r"|(?P<regex>/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+/[a-z]*)"
r"|(?P<word>[$A-Za-z_][$\w]*)"
r"|(?P<symbol>\.\.\.|===|!==|=>|==|!=|\S)",
re.DOTALL,
)
DECLARED_NAME = re.compile(r"^\s*-\s+name:\s*(?P<value>.+?)\s*$")
PAIRS = {"(": ")", "[": "]", "{": "}"}
class Token(NamedTuple):
kind: str
value: str
def workflow_scripts(text: str) -> list[str]:
scripts: list[str] = []
lines = text.splitlines()
for index, line in enumerate(lines):
match = SCRIPT.match(line)
if match is None:
continue
indent = len(match.group("indent"))
body: list[str] = []
for child in lines[index + 1 :]:
if child.strip() and len(child) - len(child.lstrip(" ")) <= indent:
break
body.append(child)
scripts.append("\n".join(body))
return scripts
def group_end(tokens: list[Token], start: int) -> int:
stack: list[str] = []
for index in range(start, len(tokens)):
token = tokens[index]
if token.kind != "symbol":
continue
if token.value in PAIRS:
stack.append(PAIRS[token.value])
elif token.value in PAIRS.values():
if not stack or token.value != stack.pop():
break
if not stack:
return index
raise ValueError("Cannot extract labels from an unbalanced JavaScript expression")
def expression_end(tokens: list[Token], start: int, separators: set[str]) -> int:
index = start
while index < len(tokens):
token = tokens[index]
if token.kind == "symbol":
if token.value in separators:
break
if token.value in PAIRS:
index = group_end(tokens, index)
index += 1
return index
def literal_values(tokens: list[Token], bindings: dict[str, list[list[Token]]], seen: frozenset[str] = frozenset()) -> set[str]:
"""Follow literal-producing bindings, arrays, Sets and add/push mutations.
This is a static reader for the workflow's data expressions, not a JavaScript
evaluator. Conditions and filter predicates do not produce label values.
Computed calls, concatenation and templates fail rather than claiming they
have been checked.
"""
if not tokens:
return set()
if tokens[0].value == "(" and group_end(tokens, 0) == len(tokens) - 1:
return literal_values(tokens[1:-1], bindings, seen)
comma = expression_end(tokens, 0, {","})
if comma < len(tokens):
return literal_values(tokens[:comma], bindings, seen) | literal_values(tokens[comma + 1 :], bindings, seen)
question = expression_end(tokens, 0, {"?"})
if question < len(tokens):
colon = question + 1
nested = 0
while colon < len(tokens):
token = tokens[colon]
if token.kind == "symbol" and token.value in PAIRS:
colon = group_end(tokens, colon)
elif token.value == "?":
nested += 1
elif token.value == ":":
if nested == 0:
break
nested -= 1
colon += 1
if colon == len(tokens):
raise ValueError("Cannot extract labels from an incomplete conditional")
return literal_values(tokens[question + 1 : colon], bindings, seen) | literal_values(tokens[colon + 1 :], bindings, seen)
values: set[str] = set()
index = 0
while index < len(tokens):
token = tokens[index]
if token.kind == "string":
values.add(ast.literal_eval(token.value.replace(r"\/", "/")))
elif token.kind == "template" or token.value == "+":
raise ValueError("Cannot extract computed label strings; use quoted label literals")
elif token.value == "." and index + 2 < len(tokens) and tokens[index + 1].value == "filter" and tokens[index + 2].value == "(":
index = group_end(tokens, index + 2)
elif token.value == "." and index + 2 < len(tokens) and tokens[index + 1].value == "map" and tokens[index + 2].value == "(":
end = group_end(tokens, index + 2)
callback = tokens[index + 3 : end]
# AREA_RULES.map(([label]) => label) preserves the first literal
# in each row. Arbitrary transformations need a real JS evaluator.
shape = [item.value for item in callback]
if len(shape) != 7 or shape[:2] != ["(", "["] or shape[3:6] != ["]", ")", "=>"] or shape[2] != shape[6]:
raise ValueError("Cannot extract labels from a computed map transformation")
index = end
elif token.value in PAIRS and token.kind == "symbol":
end = group_end(tokens, index)
values |= literal_values(tokens[index + 1 : end], bindings, seen)
index = end
elif token.kind == "word" and (index == 0 or tokens[index - 1].value != "."):
if index + 1 < len(tokens) and tokens[index + 1].value == "(" and token.value != "Set":
raise ValueError(f"Cannot extract computed label call {token.value}()")
if token.value in {"new", "Set"}:
pass
elif token.value not in bindings or token.value in seen:
raise ValueError(f"Cannot extract unresolved label value {token.value}")
else:
for expression in bindings[token.value]:
values |= literal_values(expression, bindings, seen | {token.value})
index += 1
return values
def script_labels(script: str) -> set[str]:
tokens = [Token(match.lastgroup, match.group()) for match in JS_TOKEN.finditer(script) if match.lastgroup != "comment"]
bindings: dict[str, list[list[Token]]] = {}
for index, token in enumerate(tokens):
if token.kind != "word":
continue
if index + 1 < len(tokens) and tokens[index + 1].value == "=" and (index == 0 or tokens[index - 1].value != "."):
end = expression_end(tokens, index + 2, {";"})
bindings.setdefault(token.value, []).append(tokens[index + 2 : end])
if index + 3 < len(tokens) and tokens[index + 1].value == "." and tokens[index + 2].value in {"push", "add", "unshift"} and tokens[index + 3].value == "(":
end = group_end(tokens, index + 3)
bindings.setdefault(token.value, []).append(tokens[index + 4 : end])
labels: set[str] = set()
for index, token in enumerate(tokens[:-1]):
if token.value != "addLabels" or tokens[index + 1].value != "(":
continue
end = group_end(tokens, index + 1)
found = False
for argument in range(index + 2, end):
key = tokens[argument]
if key.value != "labels" and not (key.kind == "string" and ast.literal_eval(key.value) == "labels"):
continue
if tokens[argument + 1].value == ":":
stop = expression_end(tokens, argument + 2, {",", "}"})
expression = tokens[argument + 2 : stop]
elif tokens[argument + 1].value in {",", "}"} and key.kind == "word":
expression = [key]
else:
continue
values = literal_values(expression, bindings)
if not values:
raise ValueError("Cannot extract label literals from addLabels; use a statically defined labels value")
labels |= values
found = True
break
if not found:
raise ValueError("Cannot extract labels: addLabels has no supported labels argument")
return labels
def declared_labels(path: Path) -> set[str]:
names: set[str] = set()
for line in path.read_text(encoding="utf-8").splitlines():
match = DECLARED_NAME.match(line)
if match:
names.add(match.group("value").strip().strip("'\""))
return names
def applied_labels(path: Path) -> set[str]:
labels: set[str] = set()
for script in workflow_scripts(path.read_text(encoding="utf-8")):
labels |= script_labels(script)
return labels
def main() -> int:
if not LABELS_FILE.is_file():
sys.exit(f"Missing {LABELS_FILE}")
if not TRIAGE_WORKFLOW.is_file():
sys.exit(f"Missing {TRIAGE_WORKFLOW}")
declared = declared_labels(LABELS_FILE)
try:
applied = applied_labels(TRIAGE_WORKFLOW)
except (ValueError, SyntaxError) as error:
sys.exit(f"Cannot extract labels from {TRIAGE_WORKFLOW}: {error}")
# Guard the guards: a change that makes either extraction match nothing would
# otherwise turn this check into a no-op that always passes.
if len(declared) < 20:
sys.exit(f"Only {len(declared)} labels parsed from {LABELS_FILE} -- the extraction is broken.")
if not applied:
sys.exit(f"No label literals found in {TRIAGE_WORKFLOW} -- the extraction is broken.")
missing = sorted(applied - declared)
if missing:
print(
f"{len(missing)} label(s) applied by {TRIAGE_WORKFLOW.name} are not declared in {LABELS_FILE.name}:",
file=sys.stderr,
)
for name in missing:
print(f" - {name}", file=sys.stderr)
print(
"\nGitHub drops the whole addLabels batch with 422 when a label is missing, so add these under the matching section of labels.yml.",
file=sys.stderr,
)
return 1
print(f"OK -- {len(applied)} labels applied by {TRIAGE_WORKFLOW.name} are all declared.")
return 0
if __name__ == "__main__":
raise SystemExit(main())