mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-08 17:50:21 -10:00
### Why / What / How
Backend CI can pass on a pull request from a fork again: a test that
skips because a repository secret is absent no longer fails the skip
gate. Forks get no secrets, so
`backend/copilot/tools/run_agent_test.py::test_run_agent_with_llm_credentials`
skips on every one of them (`OPENAI_API_KEY is not set`,
`_test_data.py:205-207`), its ID is not in the allowlist, and the three
copilot shards fail at `Validate test reports` — not at the tests.
`Validate backend skip policy` then fails downstream because it needs
all twelve artifacts, `Check PR Status` goes red, and since that is the
only required check, nothing from a fork can enter the merge queue. The
merge queue's own run has the secret, so a PR ejected for any other
reason reads as fixed and still cannot re-enter.
The skip policy gains a `secret_gated` section naming the environment
variable each ID depends on:
```json
"secret_gated": {
"OPENAI_API_KEY": ["backend.copilot.tools.run_agent_test.test_run_agent_with_llm_credentials"]
}
```
Enforcement splits along where the secrets actually are. The per-shard
`Validate test reports` step runs inside the `test` job, which has
`OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}` in its env, so it is the
strict check: the skip is allowed only while the variable is absent or
empty, and a run that holds the secret and skips anyway still fails. The
aggregate `Validate backend skip policy` job holds no secrets and cannot
tell the two cases apart, so it accepts those IDs and leaves them out of
its staleness sweep. Nothing else moves: a skip nobody declared still
fails both checks, exactly as before.
**Allowlisting the ID in `common` is the obvious fix and is the wrong
one.** The test executes when the secret is present, so the aggregate
job's staleness sweep would report the allowance as obsolete and turn
every push to `dev` and every merge-queue run red — trading a red on
fork PRs for a red on everything else. Verified against the two runs
below:
```
$ python3 .github/scripts/validate_backend_skips.py --allow-skips-from <ID added to "common"> --reports-dir <dev run 35033474274>
Invalid backend skip reports: Python 3.11 has stale skip IDs in ...:
backend.copilot.tools.run_agent_test.test_run_agent_with_llm_credentials. Remove obsolete allowances or ...
```
Also considered and rejected: accepting the skip on the strength of its
message (`"<VAR> is not set"`) alone. It would need no env at all, but
it turns a reworded skip reason into a CI failure and it accepts the
skip even on runs that do have the secret, which is the case worth
catching.
### Changes 🏗️
- `backend-allowed-skips.json`: new optional `secret_gated` section,
mapping an environment variable to the skip IDs it gates. One entry
today.
- `validate_junit.py`: parses the section, rejects a variable name that
is not an uppercase identifier and an ID that is also unconditionally
allowed, and adds the gated IDs to the allowed set only when their
variable is absent or empty in the job environment.
- `validate_backend_skips.py`: accepts gated IDs regardless, and keeps
them out of the stale-ID sweep.
- Tests in both suites, plus a workflow test asserting every
`secret_gated` variable is really supplied to the `test` job from
`secrets.*` — a variable that is never set is not secret-gated, it
belongs in `common`.
No workflow change: the `test` job already has the secret in scope, and
the aggregate job deliberately does not.
#### Inventory: every environment-gated skip in the backend
`OPENAI_API_KEY` is the only repository secret any backend test gates
on. The whole workflow references `secrets.` exactly twice —
`CODECOV_TOKEN`, which no test reads, and `OPENAI_API_KEY` — so every
other gate resolves identically on a fork and on `dev` by construction.
| Variable | Tests | Set in CI? | Fork vs non-fork | Treatment |
| --- | --- | --- | --- | --- |
| `OPENAI_API_KEY` | `copilot/tools/_test_data.py` → 1 |
`secrets.OPENAI_API_KEY` | **differs** | new `secret_gated` entry |
| `OPEN_ROUTER_API_KEY` | `copilot/baseline/service_test.py`,
`copilot/service_test.py` → 2 | never | same | already in `common` |
| `AIRTABLE_API_KEY` | `blocks/airtable/_api_test.py` → 5 | never | same
| already in `common` |
| `RUN_E2E` | `blocks/mcp/test_e2e.py` → 5 | never | same | already in
`common` |
| `CI` | `test/test_data_creator.py` → 1 | always `true` | same |
already in `common` |
| `TRIAL_TEST_DATABASE` | `data/subscription_trial_*` | always `"1"` |
same | tests run; nothing to allow |
| `E2E_REDIS_CLUSTER_RESTART` | `data/e2e_redis_restart_test.py` | set
for the `data` shard | same | tests run; nothing to allow |
The remaining allowlisted skips are gated on a missing browser, an
absent FalkorDB port or an uninstalled package rather than on an
environment variable, and are unaffected by who opened the pull request.
### Verified
I ran both validators against the real shard reports of two CI runs —
[run
34851839105](https://github.com/Significant-Gravitas/AutoGPT/actions/runs/34851839105)
on fork PR #14545, whose only failures are the three copilot shards and
the aggregate job, and [run
35033474274](https://github.com/Significant-Gravitas/AutoGPT/actions/runs/35033474274)
on `dev` — and confirmed eight behaviours: the fork reports pass
per-shard and in aggregate; the same reports still fail when the secret
is present; the `dev` reports pass either way and raise no stale ID; and
an unlisted skip injected into the fork reports still fails both checks.
I then removed each half of the change in turn and watched the matching
test go red, including the workflow wiring test. 59 tests in
`.github/scripts` pass; `ruff check` is clean. Full evidence in a
comment below.
I did not reason about anything I could execute here, with one
exception: that this makes a fork PR's `Check PR Status` green
end-to-end is inferred from run 34851839105's job list, where the only
non-success jobs are the four this fixes. A same-repo PR cannot
reproduce a secretless run.
### Agents and large language models used
Claude Code with Claude Opus 5.
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Both validators run against the 12 real shard reports of a blocked
fork run and pass
- [x] Both validators run against the 12 real shard reports of a `dev`
run and pass, with no stale ID
- [x] The per-shard validator still rejects the gated skip on a run that
holds the secret
- [x] An unlisted skip injected into the fork reports is still rejected
by both validators
- [x] Each half of the change removed in turn, and the matching test
observed failing
- [x] `.github/scripts` suite: 59 passed
#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 <[email protected]>
313 lines
11 KiB
Python
313 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class JUnitSummary:
|
|
tests: int
|
|
failures: int
|
|
errors: int
|
|
skipped: int
|
|
|
|
@property
|
|
def passed(self) -> int:
|
|
return self.tests - self.failures - self.errors - self.skipped
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkipPolicy:
|
|
common: frozenset[str]
|
|
python_versions: dict[str, frozenset[str]] | None = None
|
|
secret_gated: Mapping[str, frozenset[str]] = field(default_factory=dict)
|
|
|
|
def for_python_version(self, version: str | None) -> set[str]:
|
|
if self.python_versions is None:
|
|
return set(self.common)
|
|
if version not in self.python_versions:
|
|
raise ValueError(
|
|
f"an explicitly configured Python version is required; got {version!r}"
|
|
)
|
|
return set(self.common | self.python_versions[version])
|
|
|
|
def secret_gated_ids(self, env: Mapping[str, str] | None = None) -> set[str]:
|
|
"""IDs a missing repository secret is allowed to skip.
|
|
|
|
Given ``env``, only the IDs whose variable is absent there; without it,
|
|
all of them, for callers that cannot see the secrets themselves.
|
|
"""
|
|
return {
|
|
skip_id
|
|
for variable, skip_ids in self.secret_gated.items()
|
|
if env is None or not env.get(variable)
|
|
for skip_id in skip_ids
|
|
}
|
|
|
|
|
|
def _skip_ids(entries: object) -> frozenset[str]:
|
|
if (
|
|
not isinstance(entries, list)
|
|
or any(
|
|
not isinstance(entry, str)
|
|
or "." not in entry
|
|
or not entry.split(".", 1)[0].strip()
|
|
or not entry.rsplit(".", 1)[-1].strip()
|
|
for entry in entries
|
|
)
|
|
or len(set(entries)) != len(entries)
|
|
):
|
|
raise ValueError(
|
|
"skip allowlist must contain unique nonempty classname.name IDs"
|
|
)
|
|
return frozenset(entries)
|
|
|
|
|
|
def _secret_gated(
|
|
entries: object, unconditional: frozenset[str]
|
|
) -> dict[str, frozenset[str]]:
|
|
if not isinstance(entries, dict):
|
|
raise ValueError("secret_gated must map environment variables to skip IDs")
|
|
gated = {}
|
|
for variable, skip_ids in entries.items():
|
|
if not (variable.isidentifier() and variable.isupper()):
|
|
raise ValueError(f"invalid secret_gated environment variable: {variable!r}")
|
|
gated[variable] = _skip_ids(skip_ids)
|
|
if repeated := unconditional & gated[variable]:
|
|
raise ValueError(
|
|
f"{variable} repeats unconditionally allowed skip IDs: "
|
|
f"{', '.join(sorted(repeated))}"
|
|
)
|
|
return gated
|
|
|
|
|
|
def load_skip_policy(path: Path) -> SkipPolicy:
|
|
entries = json.loads(path.read_text(encoding="utf-8"))
|
|
if isinstance(entries, list):
|
|
return SkipPolicy(_skip_ids(entries))
|
|
if not isinstance(entries, dict) or not (
|
|
{"common", "python_versions"}
|
|
<= set(entries)
|
|
<= {"common", "python_versions", "secret_gated"}
|
|
):
|
|
raise ValueError(
|
|
"skip policy must contain common and python_versions, "
|
|
"and may contain secret_gated"
|
|
)
|
|
common = _skip_ids(entries["common"])
|
|
versions = entries["python_versions"]
|
|
if not isinstance(versions, dict) or not versions:
|
|
raise ValueError(
|
|
"python_versions must explicitly configure every tested version"
|
|
)
|
|
version_skips = {}
|
|
for version, ids in versions.items():
|
|
parts = version.split(".")
|
|
if len(parts) != 2 or not all(part.isdigit() for part in parts):
|
|
raise ValueError(f"invalid Python version: {version!r}")
|
|
specific = _skip_ids(ids)
|
|
if duplicate := common & specific:
|
|
raise ValueError(
|
|
f"Python {version} repeats common skip IDs: {', '.join(sorted(duplicate))}"
|
|
)
|
|
version_skips[version] = specific
|
|
return SkipPolicy(
|
|
common,
|
|
version_skips,
|
|
_secret_gated(
|
|
entries.get("secret_gated", {}), common.union(*version_skips.values())
|
|
),
|
|
)
|
|
|
|
|
|
def _declared_count(element: ElementTree.Element, key: str, label: str) -> int | None:
|
|
raw = element.get(key)
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{label} {key} count is not an integer: {raw!r}") from exc
|
|
if value < 0:
|
|
raise ValueError(f"{label} {key} count is negative: {value}")
|
|
return value
|
|
|
|
|
|
def _summarize_cases(element: ElementTree.Element) -> JUnitSummary:
|
|
cases = list(element.iter("testcase"))
|
|
return JUnitSummary(
|
|
tests=len(cases),
|
|
failures=sum(case.find("failure") is not None for case in cases),
|
|
errors=sum(case.find("error") is not None for case in cases),
|
|
skipped=sum(case.find("skipped") is not None for case in cases),
|
|
)
|
|
|
|
|
|
def _validate_declared_counts(
|
|
element: ElementTree.Element, summary: JUnitSummary, label: str
|
|
) -> None:
|
|
for key in ("tests", "failures", "errors", "skipped"):
|
|
declared = _declared_count(element, key, label)
|
|
actual = getattr(summary, key)
|
|
if declared is not None and declared != actual:
|
|
raise ValueError(
|
|
f"{label} {key} count is {declared}, but test cases contain {actual}"
|
|
)
|
|
|
|
|
|
def summarize_junit(root: ElementTree.Element) -> JUnitSummary:
|
|
if root.tag not in {"testsuite", "testsuites"}:
|
|
raise ValueError(f"unexpected root element: {root.tag!r}")
|
|
|
|
summary = _summarize_cases(root)
|
|
_validate_declared_counts(root, summary, "root")
|
|
for suite in root.iter("testsuite"):
|
|
if suite is root:
|
|
continue
|
|
suite_summary = _summarize_cases(suite)
|
|
_validate_declared_counts(
|
|
suite, suite_summary, f"testsuite {suite.get('name', '<unnamed>')!r}"
|
|
)
|
|
|
|
if summary.tests == 0:
|
|
raise ValueError("report contains no test cases")
|
|
if summary.failures or summary.errors:
|
|
raise ValueError(
|
|
f"report contains {summary.failures} failures and {summary.errors} errors"
|
|
)
|
|
return summary
|
|
|
|
|
|
def write_synthetic_error(path: Path, reason: str) -> None:
|
|
root = ElementTree.Element(
|
|
"testsuites", tests="1", failures="0", errors="1", skipped="0"
|
|
)
|
|
suite = ElementTree.SubElement(
|
|
root,
|
|
"testsuite",
|
|
name="ci-report-finalization",
|
|
tests="1",
|
|
failures="0",
|
|
errors="1",
|
|
skipped="0",
|
|
)
|
|
case = ElementTree.SubElement(
|
|
suite,
|
|
"testcase",
|
|
classname="ci.report",
|
|
name=f"{path.name} was not produced",
|
|
)
|
|
error = ElementTree.SubElement(case, "error", message=reason)
|
|
error.text = reason
|
|
ElementTree.indent(root)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
ElementTree.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
|
|
|
|
|
|
def validate_path(
|
|
path: Path, synthesize_invalid: bool, allowed_skips: set[str] | None = None
|
|
) -> JUnitSummary:
|
|
try:
|
|
if not path.is_file() or path.stat().st_size == 0:
|
|
raise ValueError("report is missing or empty")
|
|
root = ElementTree.parse(path).getroot()
|
|
except (OSError, ElementTree.ParseError, ValueError) as exc:
|
|
if synthesize_invalid:
|
|
try:
|
|
write_synthetic_error(path, str(exc))
|
|
except OSError as write_exc:
|
|
raise ValueError(
|
|
f"{exc}; could not write synthetic error report: {write_exc}"
|
|
) from write_exc
|
|
raise ValueError(str(exc)) from exc
|
|
summary = summarize_junit(root)
|
|
if allowed_skips is not None:
|
|
unexpected = [
|
|
f"{case.get('classname', '')}.{case.get('name', '')}"
|
|
for case in root.iter("testcase")
|
|
if case.find("skipped") is not None
|
|
and f"{case.get('classname', '')}.{case.get('name', '')}"
|
|
not in allowed_skips
|
|
]
|
|
if unexpected:
|
|
raise ValueError(f"unapproved skipped test IDs: {', '.join(unexpected)}")
|
|
if summary.passed == 0:
|
|
raise ValueError("report contains no executed passing tests")
|
|
return summary
|
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--synthesize-invalid",
|
|
action="store_true",
|
|
help="replace missing, empty, or malformed reports with a one-error JUnit report",
|
|
)
|
|
skip_policy = parser.add_mutually_exclusive_group()
|
|
skip_policy.add_argument(
|
|
"--require-no-skips",
|
|
action="store_true",
|
|
help="reject reports containing skipped test cases",
|
|
)
|
|
skip_policy.add_argument(
|
|
"--allow-skips-from",
|
|
type=Path,
|
|
help="reject skips not listed as exact classname.name IDs in this JSON file",
|
|
)
|
|
parser.add_argument(
|
|
"--python-version", help="Python version that produced the reports"
|
|
)
|
|
parser.add_argument("reports", nargs="+", type=Path)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
problems: list[str] = []
|
|
allowed_skips = None
|
|
if args.allow_skips_from:
|
|
try:
|
|
policy = load_skip_policy(args.allow_skips_from)
|
|
allowed_skips = policy.for_python_version(
|
|
args.python_version
|
|
) | policy.secret_gated_ids(os.environ)
|
|
except (OSError, ValueError) as exc:
|
|
print(f"Invalid skip allowlist: {exc}", file=sys.stderr)
|
|
return 1
|
|
for report in args.reports:
|
|
try:
|
|
summary = validate_path(report, args.synthesize_invalid, allowed_skips)
|
|
except ValueError as exc:
|
|
problems.append(f"{report}: {exc}")
|
|
if args.allow_skips_from and "unapproved skipped test IDs" in str(exc):
|
|
problems.append(
|
|
f"Review the skip policy in {args.allow_skips_from} before "
|
|
"approving an intentional new skip; do not allowlist a regression."
|
|
)
|
|
continue
|
|
if args.require_no_skips and summary.skipped:
|
|
problems.append(
|
|
f"{report}: report contains {summary.skipped} skipped tests"
|
|
)
|
|
continue
|
|
print(
|
|
f"{report}: {summary.tests} tests, {summary.passed} passed, "
|
|
f"{summary.skipped} skipped"
|
|
)
|
|
|
|
if problems:
|
|
for problem in problems:
|
|
print(problem, file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|