diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ffb2efe873..24954f70fd 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -151,6 +151,19 @@ nothing; results are in the job log and step summary. Run `scripts/release_lifecycle_rehearsal.sh --from --to ` to reproduce it locally (`PULSE_REHEARSAL_ENGINE=podman` on hosts without Docker). +`dependency-advisory-watch.yml` runs the required build-and-test frontend audit +daily against `main` and every active `release/v.` line (the +latest stable's line and newer), because a new npm advisory against an +unchanged lockfile otherwise surfaces only when it fails every pull request on +that branch. Each line is a separate matrix job that reads only that line's +`frontend-modern/package.json` and `package-lock.json` (it never checks out, +installs or runs another branch's code in the default branch's scope) and audits +them with the same Node.js pin and `scripts/npm-audit-retry.sh all`. A failing job names +the branch and advisories in its step summary and annotation; the fix is +`npm audit fix --package-lock-only` on that line plus raised floors in +`frontend-modern/src/security/__tests__/dependencySecurity.test.ts`. It is +read-only, hosted-only, uses no secrets and uploads nothing. + The shared `install-sh-smoke-body.yml` inherits its caller's token permissions; keep it free of workflow- or job-level permission overrides. Continuity calls that body directly with `contents: read`. The existing `install-sh-smoke.yml` diff --git a/.github/workflows/dependency-advisory-watch.yml b/.github/workflows/dependency-advisory-watch.yml new file mode 100644 index 0000000000..ca5aed47dd --- /dev/null +++ b/.github/workflows/dependency-advisory-watch.yml @@ -0,0 +1,112 @@ +name: Dependency Advisory Watch + +# A new npm advisory against an unchanged lockfile fails the required +# "Audit complete frontend dependency graph" step in build-and-test.yml on +# every pull request to that branch. On 30 Sep-1 Oct 2026 two advisories did +# exactly that to main, release/v6.4 and release/v6.5 at once, and nothing +# owned the fix. This watch runs the same audit daily against main and every +# active release line, fails when any of them would block, and names the +# branch, the advisories and the fix in the step summary. Scheduled workflows +# only run on the default branch. The audit needs only each line's +# frontend-modern/package.json and package-lock.json, so this job reads those +# two files with git and never checks out, installs or executes another +# branch's code in the default branch's context. +on: + schedule: + - cron: '17 5 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dependency-advisory-watch + cancel-in-progress: false + +jobs: + lines: + name: List active release lines + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + branches: ${{ steps.lines.outputs.branches }} + steps: + - name: Checkout watch helpers + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve main and active release lines + id: lines + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + heads="$(mktemp)" + git ls-remote --heads "https://github.com/${REPOSITORY}.git" 'refs/heads/release/v*' > "${heads}" + # The newest stable's line and newer are active. If the lookup fails, + # every release line is audited rather than none. + latest_stable="$(gh api "repos/${REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true)" + branches="$(python3 scripts/dependency_advisory_watch.py lines --heads "${heads}" --latest-stable "${latest_stable}")" + rm -f "${heads}" + echo "Newest stable: ${latest_stable:-unknown}; auditing ${branches}" + python3 scripts/write_github_output.py branches "${branches}" + + audit: + name: Audit frontend dependencies (${{ matrix.branch }}) + needs: lines + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.lines.outputs.branches) }} + steps: + - name: Checkout watch helpers + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Read the audited branch's lockfile inputs + env: + BRANCH: ${{ matrix.branch }} + AUDITED: ${{ runner.temp }}/audited + run: | + set -euo pipefail + mkdir -p "${AUDITED}" + git fetch --no-tags --depth=1 origin "+refs/heads/${BRANCH}:refs/remotes/audited/head" + for file in package.json package-lock.json; do + git show "refs/remotes/audited/head:frontend-modern/${file}" > "${AUDITED}/${file}" + done + + # Same Node.js and audit runner as the required build-and-test Frontend + # job. npm audit reads the lockfile, so no install is needed and no + # package from the audited branch is fetched or run. The runner always + # comes from this workflow's own commit, so a release line that predates + # it is held to the same verdict main's required check applies. + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Audit complete frontend dependency graph + id: audit + working-directory: ${{ runner.temp }}/audited + env: + NPM_AUDIT_REQUIRE_RESULT: 'true' + run: | + set -o pipefail + bash "$GITHUB_WORKSPACE/scripts/npm-audit-retry.sh" all 2>&1 | tee "$RUNNER_TEMP/npm-audit.log" + + - name: Report advisories and fix + if: ${{ !cancelled() }} + env: + BRANCH: ${{ matrix.branch }} + AUDIT_OUTCOME: ${{ steps.audit.outcome }} + run: | + python3 scripts/dependency_advisory_watch.py summary \ + --branch "${BRANCH}" \ + --outcome "${AUDIT_OUTCOME}" \ + --log "$RUNNER_TEMP/npm-audit.log" \ + --summary-file "$GITHUB_STEP_SUMMARY" diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5cb31ea930..27019e71a7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -845,6 +845,8 @@ release-latency optimization. 101. `.github/workflows/release-lifecycle-rehearsal.yml` 102. `scripts/release_lifecycle_rehearsal.sh` 103. `scripts/release_lifecycle_rehearsal_versions.py` +104. `.github/workflows/dependency-advisory-watch.yml` +105. `scripts/dependency_advisory_watch.py` ## Shared Boundaries @@ -6185,6 +6187,65 @@ passed all four phases. An earlier run correctly failed phase 1 on a new failed unit (`systemd-hostnamed`), which led to the container-only list above. The hosted Docker run is not yet claimed. +### Release-line dependency advisory watch (1 October 2026) + +On 30 September and 1 October 2026 `GHSA-q2hr-2g5m-vwhr` (`brace-expansion`) +and `GHSA-p98j-92pf-mc4p` (DOMPurify) were published against unchanged +`frontend-modern/package-lock.json` graphs. The required *Audit complete +frontend dependency graph* step in `build-and-test.yml` then failed every pull +request on `main`, `release/v6.4` and `release/v6.5`. That held the v6.4.6 +preparation pull request and the v6.5 release candidate for days with no owner. +The scheduled npm audit in `security-scan.yml` only informs, and a scheduled +workflow runs on the default branch alone, so release lines were never checked +before a pull request hit them. + +`.github/workflows/dependency-advisory-watch.yml` runs daily and on manual +dispatch with only `contents: read` on the hosted `ubuntu-24.04` image. It uses +no secrets, persists no checkout credentials and uploads nothing. Its first job +lists `main` plus every remote `release/v.` branch at or newer +than the line of the release GitHub reports as latest. Patch branches such as +`release/v6.4.2` and legacy `release/5.1` are never lines. If the latest-release +lookup fails, every release line is audited instead of none. + +The second job is a fail-fast-free matrix over those branches. A scheduled job +runs in the default branch's cache and token scope, so it never checks out, +installs or runs another branch's code: each entry fetches the audited branch +and reads only its `frontend-modern/package.json` and `package-lock.json` as +data with `git show`, uses no dependency cache, and repeats the required +build-and-test audit against that lockfile with the same `actions/setup-node` +pin and Node.js 24 and `scripts/npm-audit-retry.sh all` with +`NPM_AUDIT_REQUIRE_RESULT=true`. `npm audit` answers from the lockfile, so the +verdict is the one the required step reaches after its install. The runner always comes from the workflow's +own commit, so a line whose build-and-test predates it is held to main's +verdict, which fails on any finding at any severity just as a plain `npm audit` +does. `frontend-modern/package-lock.json` is the only lockfile the required +audit covers, so it is the only one the watch audits. The job fails whenever +the audit fails. The step summary and one failure annotation name the branch, +the GHSA identifiers and packages, and the fix: `npm audit fix +--package-lock-only` in `frontend-modern` on that line, then raising the +matching floors in `frontend-modern/src/security/__tests__/dependencySecurity.test.ts`. +An unreachable advisory endpoint, or a missing lockfile before any verdict, +also fails the job but is reported as such and never as an advisory. Only +validated branch names, package names and GHSA identifiers reach the +annotation, and registry text in the summary is escaped. + +The watch gates nothing directly. The maintainer release-path health timer +watches it, so a failed run becomes a top-priority Delivery request naming the +failing line. + +Verification: `scripts/tests/test_dependency_advisory_watch.py` covers line +selection (newest-stable floor, patch and legacy branch exclusion, the +fail-wide fallback, `main` always present), the summary and annotation for +advisory, clean, unreachable and install-failure outcomes, escaping of hostile +registry text, refusal of unexpected branch names, and the workflow contract +(triggers, read-only hosted shape, matrix wiring, and parity of the Node.js +pin, install command and audit runner with the required build-and-test step). +`scripts/check_workflow_trust.py` and actionlint pass. A local run of the +helper and `npm-audit-retry.sh` against the 1 October 2026 heads selected +`main`, `release/v6.4` and `release/v6.5`, passed `main`, and failed +`release/v6.4` (`GHSA-p98j-92pf-mc4p`) and `release/v6.5` +(`brace-expansion` and DOMPurify advisories). The hosted run is not yet claimed. + ## Release-body updater ownership (30 September 2026) Each executable `/bin/update --version` example in a published body must carry diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b6cb3524f9..834156db27 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4524,6 +4524,7 @@ ".github/workflows/build-release-candidate.yml", ".github/workflows/compile-release-payload.yml", ".github/workflows/create-release.yml", + ".github/workflows/dependency-advisory-watch.yml", ".github/workflows/deploy-demo-server.yml", ".github/workflows/helm-pages.yml", ".github/workflows/promote-floating-tags.yml", @@ -4593,6 +4594,7 @@ "scripts/clean-mock-alerts.sh", "scripts/com.pulse.hot-dev.plist.template", "scripts/demo_public_browser_smoke.cjs", + "scripts/dependency_advisory_watch.py", "scripts/dev-check.sh", "scripts/dev-deploy-agent.sh", "scripts/dev-launchd-setup.sh", @@ -4820,6 +4822,20 @@ "scripts/release_control/verify_github_release_integrity_test.py" ] }, + { + "id": "dependency-advisory-watch", + "label": "daily release-line dependency advisory watch proof", + "match_prefixes": [], + "match_files": [ + ".github/workflows/dependency-advisory-watch.yml", + "scripts/dependency_advisory_watch.py" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "scripts/tests/test_dependency_advisory_watch.py" + ] + }, { "id": "release-lifecycle-rehearsal", "label": "published-release install, upgrade and rollback rehearsal proof", diff --git a/scripts/dependency_advisory_watch.py b/scripts/dependency_advisory_watch.py new file mode 100644 index 0000000000..d03c56f8d3 --- /dev/null +++ b/scripts/dependency_advisory_watch.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Helpers for the daily dependency advisory watch workflow. + +`lines` turns `git ls-remote --heads` output and the newest stable release tag +into the JSON list of branches the watch audits: `main` plus every +`release/v.` line at or newer than the newest stable's line. +Without a usable stable tag every release line is kept, so a lookup failure +widens the watch instead of silently narrowing it. + +`summary` reads the output of `scripts/npm-audit-retry.sh` for one branch and +writes the step summary plus one failure annotation naming the branch, the +advisories and the fix. Registry-sourced text never reaches the annotation: +only the validated branch name, package names and GHSA identifiers do. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +LINE_RE = re.compile(r"^release/v(\d+)\.(\d+)$") +STABLE_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") +GHSA_RE = re.compile(r"GHSA(?:-[23456789cfghjmpqrvwx]{4}){3}") +PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$") +FINDING_PREFIX = "audit finding " +UNREACHABLE_MARKER = "could not reach the advisory endpoint" +FIX_STEPS = ( + "In frontend-modern on that branch run `npm audit fix --package-lock-only`, " + "then raise the matching floors in " + "`frontend-modern/src/security/__tests__/dependencySecurity.test.ts` " + "and land both on the branch." +) + + +def release_lines(ls_remote: str, latest_stable: str) -> list[str]: + lines: dict[tuple[int, int], str] = {} + for row in ls_remote.splitlines(): + parts = row.split() + if len(parts) != 2 or not parts[1].startswith("refs/heads/"): + continue + branch = parts[1][len("refs/heads/"):] + match = LINE_RE.fullmatch(branch) + if match: + lines[(int(match.group(1)), int(match.group(2)))] = branch + stable = STABLE_RE.fullmatch(latest_stable.strip()) + floor = (int(stable.group(1)), int(stable.group(2))) if stable else (0, 0) + active = [lines[key] for key in sorted(lines) if key >= floor] + return ["main", *active] + + +def parse_findings(log: str) -> list[dict]: + findings = [] + for row in log.splitlines(): + row = row.strip() + if not row.startswith(FINDING_PREFIX): + continue + try: + finding = json.loads(row[len(FINDING_PREFIX):]) + except ValueError: + continue + if isinstance(finding, dict): + findings.append(finding) + return findings + + +def advisory_ids(finding: dict) -> list[str]: + found = set() + for item in finding.get("via") or []: + if isinstance(item, dict): + found.update(GHSA_RE.findall(str(item.get("url") or ""))) + return sorted(found) + + +def safe_package(name: object) -> str: + text = str(name or "") + return text if PACKAGE_RE.fullmatch(text) else "unnamed-package" + + +def markdown_cell(value: object) -> str: + text = " ".join(str(value or "").split())[:160] + for character in "\\|`*_[]<>": + text = text.replace(character, "\\" + character) + return text + + +def render(branch: str, outcome: str, log: str) -> tuple[str, str | None]: + """Return the step summary and the annotation (None when clean).""" + findings = parse_findings(log) + header = f"## Frontend dependency audit: `{branch}`\n\n" + if outcome == "success": + note = "" + if UNREACHABLE_MARKER in log: + note = " The advisory endpoint did not answer, so this run proves nothing new." + return header + f"No advisories reported.{note}\n", None + if not findings: + if UNREACHABLE_MARKER in log: + text = (f"The npm advisory endpoint could not be reached for {branch}. " + "No advisory was observed; rerun the watch once npm recovers.") + else: + text = (f"The frontend dependency audit for {branch} failed without an advisory finding. " + "Read the job log: install or audit tooling broke before a verdict.") + return header + text + "\n", text + rows = ["| Package | Severity | Advisories | Fix available |", "| --- | --- | --- | --- |"] + identifiers: set[str] = set() + packages = [] + for finding in findings: + package = safe_package(finding.get("name")) + packages.append(package) + ids = advisory_ids(finding) + identifiers.update(ids) + fix = finding.get("fixAvailable") + if isinstance(fix, dict): + fix_text = f"{fix.get('name', '')} {fix.get('version', '')}".strip() + if fix.get("isSemVerMajor"): + fix_text += " (major)" + else: + fix_text = "yes" if fix is True else "no" + titles = [str(item.get("title") or "") for item in finding.get("via") or [] if isinstance(item, dict)] + rows.append("| " + " | ".join(( + markdown_cell(package), markdown_cell(finding.get("severity")), + markdown_cell(", ".join(ids) or "; ".join(titles) or "via dependency"), + markdown_cell(fix_text))) + " |") + advisories = ", ".join(sorted(identifiers)) or "see job log" + body = (header + + f"New npm advisories block every pull request and release on `{branch}` through the " + + "required *Audit complete frontend dependency graph* check in build-and-test.yml.\n\n" + + "\n".join(rows) + + "\n\n**Suggested fix.** " + FIX_STEPS + "\n") + annotation = (f"Frontend dependency advisory on {branch}: {advisories} in " + f"{', '.join(sorted(set(packages)))}. This blocks every PR and release on {branch}. " + "The fix is a lockfile advisory floor bump on that line: npm audit fix --package-lock-only " + "in frontend-modern, then raise the floors in dependencySecurity.test.ts.") + return body, annotation + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + lines = commands.add_parser("lines") + lines.add_argument("--heads", required=True, type=Path, help="git ls-remote --heads output") + lines.add_argument("--latest-stable", default="", help="newest stable release tag, if known") + summary = commands.add_parser("summary") + summary.add_argument("--branch", required=True) + summary.add_argument("--outcome", required=True) + summary.add_argument("--log", required=True, type=Path) + summary.add_argument("--summary-file", required=True, type=Path) + args = parser.parse_args(argv) + + if args.command == "lines": + print(json.dumps(release_lines(args.heads.read_text(encoding="utf-8"), args.latest_stable))) + return 0 + + if args.branch != "main" and not LINE_RE.fullmatch(args.branch): + print(f"refusing unexpected branch name {args.branch!r}", file=sys.stderr) + return 2 + try: + log = args.log.read_text(encoding="utf-8", errors="replace") + except OSError: + log = "" + body, annotation = render(args.branch, args.outcome, log) + with args.summary_file.open("a", encoding="utf-8") as stream: + stream.write(body) + if annotation: + print(f"::error title=Dependency advisory watch ({args.branch})::{annotation}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_dependency_advisory_watch.py b/scripts/tests/test_dependency_advisory_watch.py new file mode 100644 index 0000000000..792ab831a8 --- /dev/null +++ b/scripts/tests/test_dependency_advisory_watch.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "dependency_advisory_watch.py" +WORKFLOW_PATH = ROOT / ".github" / "workflows" / "dependency-advisory-watch.yml" +BUILD_AND_TEST = ROOT / ".github" / "workflows" / "build-and-test.yml" +SPEC = importlib.util.spec_from_file_location("dependency_advisory_watch", SCRIPT) +watch = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(watch) + +HEADS = "\n".join(( + "29069d4139ef908862a179e1b0dd05364d895776\trefs/heads/release/5.1", + "e261cd3eb8c2abed21357721df6506d30e2a0ec8\trefs/heads/release/v6.3.2", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/heads/release/v6.3", + "b50aeb6a8d9e38ab362b23f4ed15a010f675a90f\trefs/heads/release/v6.4", + "26476f317a68746fd353967470cd8be3bc6b7670\trefs/heads/release/v6.4.2", + "5f88b2002d1e1085053bf7bf3969aa61cdb0f637\trefs/heads/release/v6.5", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\trefs/heads/release/v6.10", + "cccccccccccccccccccccccccccccccccccccccc\trefs/heads/release/v6.5-hotfix", +)) + +FINDING = { + "name": "dompurify", "severity": "moderate", "isDirect": True, "range": "<3.2.8", + "via": [{"source": 1, "name": "dompurify", "title": "DOMPurify `bypass` | ", + "url": "https://github.com/advisories/GHSA-p98j-92pf-mc4p", "severity": "moderate"}], + "fixAvailable": {"name": "dompurify", "version": "3.2.8", "isSemVerMajor": False}, +} +TRANSITIVE = { + "name": "brace-expansion", "severity": "low", + "via": [{"url": "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr", "title": "ReDoS"}, "minimatch"], + "fixAvailable": True, +} + + +def audit_log(*findings: dict) -> str: + rows = ["npm audit (all) attempt 1/3 (limit 60s, 240s of budget left)", + "npm audit (all): vulnerabilities present (total=2)", + "::error::npm audit (all) found vulnerabilities: total=2"] + rows += ["audit finding " + json.dumps(f, sort_keys=True) for f in findings] + return "\n".join(rows) + "\n" + + +class ReleaseLinesTest(unittest.TestCase): + def test_newest_stable_line_and_newer_plus_main(self) -> None: + self.assertEqual(watch.release_lines(HEADS, "v6.4.5"), + ["main", "release/v6.4", "release/v6.5", "release/v6.10"]) + + def test_patch_and_legacy_branches_are_never_lines(self) -> None: + lines = watch.release_lines(HEADS, "") + for branch in ("release/5.1", "release/v6.3.2", "release/v6.4.2", "release/v6.5-hotfix"): + self.assertNotIn(branch, lines) + + def test_unknown_or_prerelease_stable_keeps_every_line(self) -> None: + for latest in ("", "v6.5.0-rc.1", "garbage"): + with self.subTest(latest=latest): + self.assertEqual(watch.release_lines(HEADS, latest), + ["main", "release/v6.3", "release/v6.4", "release/v6.5", "release/v6.10"]) + + def test_main_is_audited_even_without_release_lines(self) -> None: + self.assertEqual(watch.release_lines("", "v6.4.5"), ["main"]) + + def test_cli_emits_json_list(self) -> None: + with tempfile.TemporaryDirectory() as directory: + heads = Path(directory) / "heads" + heads.write_text(HEADS, encoding="utf-8") + result = subprocess.run([sys.executable, str(SCRIPT), "lines", "--heads", str(heads), + "--latest-stable", "v6.5.0"], capture_output=True, text=True, check=True) + self.assertEqual(json.loads(result.stdout), ["main", "release/v6.5", "release/v6.10"]) + + +class SummaryTest(unittest.TestCase): + def run_summary(self, branch: str, outcome: str, log: str) -> tuple[subprocess.CompletedProcess, str]: + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "audit.log" + summary = Path(directory) / "summary.md" + log_path.write_text(log, encoding="utf-8") + result = subprocess.run([sys.executable, str(SCRIPT), "summary", "--branch", branch, + "--outcome", outcome, "--log", str(log_path), "--summary-file", str(summary)], + capture_output=True, text=True, check=False) + return result, summary.read_text(encoding="utf-8") if summary.exists() else "" + + def test_advisory_names_branch_advisories_and_fix(self) -> None: + result, summary = self.run_summary("release/v6.4", "failure", audit_log(FINDING, TRANSITIVE)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("`release/v6.4`", summary) + self.assertIn("GHSA-p98j-92pf-mc4p", summary) + self.assertIn("GHSA-q2hr-2g5m-vwhr", summary) + self.assertIn("npm audit fix --package-lock-only", summary) + self.assertIn("dependencySecurity.test.ts", summary) + annotations = [line for line in result.stdout.splitlines() if line.startswith("::error")] + self.assertEqual(len(annotations), 1) + self.assertIn("release/v6.4", annotations[0]) + self.assertIn("GHSA-p98j-92pf-mc4p, GHSA-q2hr-2g5m-vwhr", annotations[0]) + self.assertIn("lockfile advisory floor bump", annotations[0]) + + def test_registry_text_is_escaped_and_kept_out_of_annotation(self) -> None: + hostile = dict(FINDING, name="evil\n::warning::x", via=[{"title": "x |