From 41d5ec9492008a08692115f7f668a27e2915358f Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 25 Oct 2026 18:47:41 -0700 Subject: [PATCH] feat(release): enforce REL-006 -- signing secrets never reach PR jobs (#462) Local-Gate: v1 tree=973a3d53f805f8eeb8c8bbfd9b25ecfaff4a3039 secs=140 --- AGENTS.md | 2 +- ci_lint/precheck.py | 2 + ci_lint/rules/macos_signing.py | 220 +++++++++++++++++++++++++++++++++ ci_lint/tests/test_rel_006.py | 98 +++++++++++++++ docs/policy-general.md | 2 +- 5 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 ci_lint/rules/macos_signing.py create mode 100644 ci_lint/tests/test_rel_006.py diff --git a/AGENTS.md b/AGENTS.md index 78b038d..aa74548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Every prefix `ci_lint` implements or reserves, and the next free number in each | `PY` | 001–004 | `PY-005` | 001 (scoped to `[suites.perf].run`'s declared entry point(s)), 002 (typed-dataclass records; ratchet over `ci_lint`'s own source, `ci_lint.typed_records`, which resolves module-local type aliases; burned down to zero in every module, #193 -- the baseline now only forbids regressions), 003 (no pipe-captured subprocess output, `ci_lint.subprocess_capture` / `ci-lint py lint`), 004 (ruff C901 complexity ratchet, `ci_lint.rules.complexity` / `ci-lint complexity`, #229) | | `PERF` | 001–002 | `PERF-003` | 001 (distinct from the implemented `ci-lint perf compare` runtime command -- see policy-general.md); 002 candidate, decided policy adopted from #151 | | `FLEET` | 001–003 | `FLEET-004` | 001, 002, 003 (fleet-scan-only rules, live, `ci-lint fleet scan`; 003 = default-branch push runs are >=20% of run wall time with no `reuse-check` and no live merge queue, #159); `python3 -m ci_lint history` diffs recorded `fleet scan --json` files (new/persisting/resolved/unscanned, #99; daily cadence, docs/agent-guide.md) | -| `REL` | 001–006 | `REL-007` | 001, 002, 003, 004, 005 (issue #8/#74; 004's reuse half -- `release resume-plan`/`verify-frozen`, `ci_lint.release_resume` -- #109); 006 candidate, decided policy (#462: macOS Developer ID signing + notarization in release jobs only, `actions/app-signing-macos`), not yet enforced | +| `REL` | 001–006 | `REL-007` | 001, 002, 003, 004, 005 (issue #8/#74; 004's reuse half -- `release resume-plan`/`verify-frozen`, `ci_lint.release_resume` -- #109); 006 partial (#462: static precheck, `ci_lint.rules.macos_signing` -- macOS signing secrets/`actions/app-signing-macos` never reach a `pull_request`/`pull_request_target`/`merge_group` job, no `environment:` is needs_review; the signing-procedure and unsigned-publish clauses stay candidate) | | `GATE` | 001–013 | `GATE-014` | 001, 002, 003, 004, 005, 007, 008, 009, 010, 012 (issues #166/#168/#177/#190/#196/#198/#206; 006 candidate; 012 = checks act cannot run never gate a PR -- CodeRabbit suppressed, act-impossible steps confined, zero wait on app checks, `ci_lint.remote_only`; 009 = isolated runner proves its tree (`[gate.isolation] proves-tree`, nonce echo); 010 is `ci-attestations.yml`, phase 1, co-developed with zackees/soldr#3534; 011 = lane fidelity (native/vm/emulation; emulation never proves `test`); a new family, so it cannot collide with the contested `GEN-022` -- see #154); 013 candidate: a PR gate/verify job with `if: always()` goes red on every superseded/cancelled run -- use `!cancelled()` plus a head-SHA check) | | `CPP` | 001–004 | `CPP-005` | 001 (#393, `ci_lint.rules.cpp_ctest`: CI `ctest` with no `-j`/`--parallel`/`CTEST_PARALLEL_LEVEL`/test-preset `execution.jobs`; static precheck and `local-gate lint`); 002 candidate (format-only lane keyed on the whole tree), 003 candidate (native Windows/macOS gate attested by a Linux lane, or a native job in `[gate.trust].skip`), 004 candidate (header-aware lane inputs from `compile_commands.json` + Ninja depfiles) -- see docs/policy-cpp.md | | `GHAPI` | 001 | `GHAPI-002` | 001 (static precheck group 20, `ci_lint.rules.ghapi_reread`: unbounded GitHub re-read loops and gh watch loops in `run:`, `ci/` and skill/tool scripts; its broker-bypass and machine-shared-cache clauses stay candidate until the broker ships, #224, zackees/clud#1743) | diff --git a/ci_lint/precheck.py b/ci_lint/precheck.py index 9efc838..ea09d62 100644 --- a/ci_lint/precheck.py +++ b/ci_lint/precheck.py @@ -42,6 +42,7 @@ from ci_lint.rules.py_benchmark import check_group14 from ci_lint.rules.doc_claims import check_gen_010_static from ci_lint.rules.ghapi_reread import check_ghapi_001 +from ci_lint.rules.macos_signing import check_rel_006 from ci_lint.rules.host_sweeps import check_gen_030 from ci_lint.rules.status_badges import check_gen_026 from ci_lint.rules.release_gate import check_group11 @@ -221,6 +222,7 @@ def run_precheck(repo_root: Path, *, title: str = "", local: bool = False, live: all_findings.extend(check_complexity(repo_root).findings) all_findings.extend(check_rust_019(repo_root)) all_findings.extend(check_ghapi_001(repo_root)) + all_findings.extend(check_rel_006(repo_root)) all_findings.extend(check_gen_023(repo_root)) all_findings.extend(check_gen_030(repo_root)) all_findings.extend(check_gen_026(repo_root)) diff --git a/ci_lint/rules/macos_signing.py b/ci_lint/rules/macos_signing.py new file mode 100644 index 0000000..00b03ea --- /dev/null +++ b/ci_lint/rules/macos_signing.py @@ -0,0 +1,220 @@ +"""REL-006 static clauses: macOS signing secrets never reach PR jobs (#462). + +docs/policy-general.md "macOS app signing and notarization" decides that +Developer ID signing and notarization run in release jobs only -- tag +pushes, `release` events and release `workflow_dispatch` runs -- and never +on `pull_request`, `pull_request_target` or `merge_group`. This module +enforces the part of REL-006 a workflow file can show: + +- A *signing job* is a job that references one of the documented signing + secrets (`secrets.MACOS_SIGNING_*`, `secrets.MACOS_NOTARY_*`, or the + accepted fallback `MACOS_NOTARIZATION_USERNAME/PASSWORD`), in its own + body or through a workflow-level `env:`, or that uses + `actions/app-signing-macos` (local `./actions/...` or + `zackees/ci.yml/actions/app-signing-macos@...`). +- In a workflow triggered by `pull_request_target`, a signing job is a + violation whatever its `if:` says: that event runs with secrets in the + base repository's context for fork PRs, so the secrets must not be + reachable from such a workflow at all. +- In a workflow triggered by `pull_request`/`merge_group`, a signing job + with no `if:` is a violation; one whose `if:` confines it to tag pushes, + `release` or `workflow_dispatch` passes; any other `if:` (including one + that merely excludes PR events) is `needs_review`. +- A local reusable workflow called from such a workflow is scanned with the + caller job's confinement, as GATE-012 does. +- A signing job anywhere with no `environment:` is `needs_review`: the + policy keeps the secrets in a GitHub environment restricted to the default + branch and `v*` tags, but also allows repository-level secrets, so the + absence alone cannot be proven wrong offline. + +Not enforced statically (Candidate): the signing procedure itself (inside-out +`codesign`, keychain cleanup, notarize/staple/spctl) when a repository does +not use the reference action, and "an unsigned macOS artifact is published". +""" + +from __future__ import annotations + +import re +from enum import Enum +from pathlib import Path + +from ci_lint.finding import Finding, Status +from ci_lint.workflow_scan import ParsedYamlFile, as_dict, get_on_section, jobs_of, load_workflows, steps_of +from ci_lint.yaml_io import LoadStatus, YamlValue + +RULE = "REL-006" +POLICY = "docs/policy-general.md#macos-app-signing-and-notarization-candidate-rel-006" + +SIGNING_SECRETS: tuple[str, ...] = ( + "MACOS_SIGNING_IDENTITY", + "MACOS_SIGNING_CERT", + "MACOS_SIGNING_CERT_PASSWORD", + "MACOS_NOTARY_API_KEY", + "MACOS_NOTARY_API_KEY_ID", + "MACOS_NOTARY_API_ISSUER_ID", + # The accepted Apple ID + app-specific password fallback. + "MACOS_NOTARIZATION_USERNAME", + "MACOS_NOTARIZATION_PASSWORD", +) +_SECRET_REF = re.compile(r"secrets\s*\.\s*(" + "|".join(SIGNING_SECRETS) + r")\b|secrets\[\s*['\"](" + "|".join(SIGNING_SECRETS) + r")['\"]\s*\]") +_ACTION = re.compile(r"^(\./|zackees/ci\.yml/)actions/app-signing-macos(@|/?$)", re.IGNORECASE) + +PR_EVENTS: frozenset[str] = frozenset({"pull_request", "pull_request_target", "merge_group"}) + +# Conditions that keep a job to tag pushes, release events or dispatch runs. +_RELEASE_ONLY = ( + re.compile(r"github\.event_name\s*==\s*'(release|workflow_dispatch)'"), + re.compile(r"startswith\(\s*github\.ref\s*,\s*'refs/tags/"), + re.compile(r"github\.ref\s*==\s*'refs/tags/"), + re.compile(r"github\.ref_type\s*==\s*'tag'"), + re.compile(r"^false$"), +) + + +class Gate(str, Enum): + NONE = "none" + RELEASE_ONLY = "release-only" + UNKNOWN = "unknown" + + +def classify_if(expr: YamlValue) -> Gate: + if expr is None: + return Gate.NONE + if expr is False: + return Gate.RELEASE_ONLY + if not isinstance(expr, str): + return Gate.UNKNOWN + text = expr.strip() + if text.startswith("${{") and text.endswith("}}"): + text = text[3:-2] + text = re.sub(r"\s+", " ", text.replace('"', "'")).strip().lower() + parts = [p.strip(" ()") for p in text.split("||")] if "||" in text else [text] + if all(any(rx.search(part) for rx in _RELEASE_ONLY) for part in parts): + return Gate.RELEASE_ONLY + return Gate.UNKNOWN + + +def _combine(outer: Gate, inner: Gate) -> Gate: + if Gate.RELEASE_ONLY in (outer, inner): + return Gate.RELEASE_ONLY + if Gate.UNKNOWN in (outer, inner): + return Gate.UNKNOWN + return Gate.NONE + + +def _strings(value: YamlValue) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [s for v in value.values() for s in _strings(v)] + if isinstance(value, list): + return [s for v in value for s in _strings(v)] + return [] + + +def _secrets_in(value: YamlValue) -> list[str]: + names: list[str] = [] + for text in _strings(value): + for match in _SECRET_REF.finditer(text): + name = match.group(1) or match.group(2) + if name not in names: + names.append(name) + return names + + +def _uses_action(job: dict[str, YamlValue]) -> bool: + return any(isinstance(s.get("uses"), str) and _ACTION.match(str(s.get("uses"))) for s in steps_of(job)) + + +def _what(secrets: list[str], action: bool) -> str: + parts = [] + if action: + parts.append("uses `actions/app-signing-macos`") + if secrets: + parts.append("references " + ", ".join(f"`secrets.{n}`" for n in secrets)) + return " and ".join(parts) + + +def _events(document: YamlValue) -> set[str]: + return {str(e) for e in get_on_section(as_dict(document))} + + +def _local_reusable(uses: YamlValue) -> str | None: + if isinstance(uses, str) and uses.startswith("./.github/workflows/"): + return uses[2:].split("@", 1)[0] + return None + + +def _job_findings(wf: ParsedYamlFile, events: set[str], context: Gate) -> list[Finding]: + doc = as_dict(wf.document) + wf_secrets = _secrets_in(doc.get("env")) + findings: list[Finding] = [] + for job_id, job in jobs_of(doc).items(): + secrets = [*wf_secrets, *(n for n in _secrets_in(job) if n not in wf_secrets)] + action = _uses_action(job) + if not secrets and not action: + continue + where = f"job '{job_id}'" + what = _what(secrets, action) + if "pull_request_target" in events: + findings.append(Finding( + rule=RULE, path=wf.path, + message=f"{where} {what} in a workflow triggered by `pull_request_target`, which runs with " + "the base repository's secrets for fork PRs", + fix="move the signing job to a workflow triggered only by tag pushes, `release` or " + f"`workflow_dispatch`; never expose signing secrets to `pull_request_target` ({POLICY})", + )) + elif events & PR_EVENTS: + gate = _combine(context, classify_if(job.get("if"))) + pr = ", ".join(f"`{e}`" for e in sorted(events & PR_EVENTS)) + if gate == Gate.NONE: + findings.append(Finding( + rule=RULE, path=wf.path, + message=f"{where} {what} and runs on every {pr} event; signing secrets must never reach a PR job", + fix="add `if: startsWith(github.ref, 'refs/tags/v')` (or `github.event_name == 'release'`/" + f"`'workflow_dispatch'`), or move the job to a release-only workflow ({POLICY})", + )) + elif gate == Gate.UNKNOWN: + findings.append(Finding( + rule=RULE, path=wf.path, status=Status.NEEDS_REVIEW, + message=f"{where} {what} in a {pr} workflow; its `if:` is not recognized as confining it " + "to tag pushes, `release` or `workflow_dispatch`", + fix="confine it with `if: startsWith(github.ref, 'refs/tags/v')` or " + f"`github.event_name == 'release'`/`'workflow_dispatch'` ({POLICY})", + )) + if "environment" not in job and "uses" not in job: + findings.append(Finding( + rule=RULE, path=wf.path, status=Status.NEEDS_REVIEW, + message=f"{where} {what} but declares no `environment:`; the policy keeps signing secrets in a " + "GitHub environment restricted to the default branch and `v*` tags", + fix="store the signing secrets in an environment such as `release` (deployment branches: default " + f"branch and `v*` tags) and add `environment: release` to this job ({POLICY})", + )) + return findings + + +def check_rel_006(repo_root: Path) -> list[Finding]: + workflows = [wf for wf in load_workflows(repo_root) if wf.status == LoadStatus.OK] + by_path = {wf.path: wf for wf in workflows} + findings: list[Finding] = [] + scanned_as_callee: set[str] = set() + for wf in workflows: + events = _events(wf.document) + if not events & PR_EVENTS: + continue + for job in jobs_of(as_dict(wf.document)).values(): + callee = _local_reusable(job.get("uses")) + target = by_path.get(callee) if callee else None + if target is None or callee in scanned_as_callee or _events(target.document) & PR_EVENTS: + continue + gate = classify_if(job.get("if")) + if gate == Gate.RELEASE_ONLY and "pull_request_target" not in events: + continue + scanned_as_callee.add(target.path) + findings.extend(_job_findings(target, events & PR_EVENTS, gate)) + for wf in workflows: + events = _events(wf.document) + if wf.path in scanned_as_callee: + continue + findings.extend(_job_findings(wf, events, Gate.NONE)) + return findings diff --git a/ci_lint/tests/test_rel_006.py b/ci_lint/tests/test_rel_006.py new file mode 100644 index 0000000..0763b7b --- /dev/null +++ b/ci_lint/tests/test_rel_006.py @@ -0,0 +1,98 @@ +"""REL-006 static clauses (#462): macOS signing secrets never reach PR jobs.""" + +from __future__ import annotations + +import unittest + +from ci_lint.finding import Status +from ci_lint.rules.macos_signing import check_rel_006 +from ci_lint.tests.test_complexity import _Repo + +RELEASE_JOB = """\ + sign: + {if_line} + runs-on: macos-latest + {env_line} + steps: + - uses: zackees/ci.yml/actions/app-signing-macos@0123456789abcdef0123456789abcdef01234567 + with: + identity: ${{{{ secrets.MACOS_SIGNING_IDENTITY }}}} +""" + + +def _wf(on: str, if_line: str = "", env_line: str = "environment: release") -> str: + return f"on: {on}\njobs:\n" + RELEASE_JOB.format(if_line=if_line, env_line=env_line) + + +def _run(text: str, extra: dict[str, str] | None = None): + files = {".github/workflows/ci.yml": text, **(extra or {})} + with _Repo(files) as root: + return check_rel_006(root) + + +class Rel006Test(unittest.TestCase): + def test_green_tag_confined_in_pr_workflow(self) -> None: + self.assertEqual([], _run(_wf("[pull_request, push]", "if: startsWith(github.ref, 'refs/tags/v')"))) + + def test_green_release_only_workflow(self) -> None: + self.assertEqual([], _run(_wf("{push: {tags: ['v*']}}"))) + + def test_green_dispatch_or_release_if(self) -> None: + cond = "if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'" + self.assertEqual([], _run(_wf("[merge_group, release, workflow_dispatch]", cond))) + + def test_unconfined_pr_job_is_violation(self) -> None: + findings = _run(_wf("[pull_request]")) + self.assertEqual(1, len(findings)) + self.assertEqual(Status.VIOLATION, findings[0].status) + self.assertEqual("REL-006", findings[0].rule) + + def test_merge_group_unconfined_is_violation(self) -> None: + self.assertEqual([Status.VIOLATION], [f.status for f in _run(_wf("[merge_group]"))]) + + def test_pull_request_target_is_violation_regardless(self) -> None: + findings = _run(_wf("[pull_request_target]", "if: startsWith(github.ref, 'refs/tags/v')")) + self.assertEqual([Status.VIOLATION], [f.status for f in findings]) + self.assertIn("pull_request_target", findings[0].message) + + def test_pr_exclusion_only_is_needs_review(self) -> None: + findings = _run(_wf("[pull_request, push]", "if: github.event_name != 'pull_request'")) + self.assertEqual([Status.NEEDS_REVIEW], [f.status for f in findings]) + + def test_bare_secret_reference_without_action(self) -> None: + text = ( + "on: pull_request\njobs:\n build:\n runs-on: macos-latest\n environment: release\n" + " steps:\n - run: echo \"$K\"\n env:\n K: ${{ secrets.MACOS_NOTARY_API_KEY }}\n" + ) + findings = _run(text) + self.assertEqual(1, len(findings)) + self.assertIn("MACOS_NOTARY_API_KEY", findings[0].message) + + def test_workflow_level_env_counts_for_every_job(self) -> None: + text = ( + "on: pull_request\nenv:\n P: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }}\njobs:\n" + " a:\n runs-on: ubuntu-latest\n environment: release\n steps:\n - run: true\n" + ) + self.assertEqual(1, len(_run(text))) + + def test_unrelated_secrets_and_jobs_pass(self) -> None: + text = "on: pull_request\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.GITHUB_TOKEN }}\n" + self.assertEqual([], _run(text)) + + def test_missing_environment_is_needs_review(self) -> None: + findings = _run(_wf("{push: {tags: ['v*']}}", env_line="")) + self.assertEqual([Status.NEEDS_REVIEW], [f.status for f in findings]) + self.assertIn("environment", findings[0].message) + + def test_local_reusable_called_from_pr_workflow(self) -> None: + caller = "on: pull_request\njobs:\n rel:\n uses: ./.github/workflows/sign.yml\n" + callee = _wf("workflow_call") + findings = _run(caller, {".github/workflows/sign.yml": callee}) + self.assertEqual([Status.VIOLATION], [f.status for f in findings]) + self.assertEqual(".github/workflows/sign.yml", findings[0].path) + confined = caller.replace(" uses:", " if: github.ref_type == 'tag'\n uses:") + self.assertEqual([], _run(confined, {".github/workflows/sign.yml": callee})) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/policy-general.md b/docs/policy-general.md index ef93704..55505cd 100644 --- a/docs/policy-general.md +++ b/docs/policy-general.md @@ -870,7 +870,7 @@ The **Status** column below reflects what `ci_lint` actually checks today, verif | `CACHE-027` | A cache family that PR runs restore but no `push`/default-branch run ever saves, so every PR starts cold (mimalloc-pprof zccache hit rate 0.0%, run 36809571898; llvm-ld Windows 0/1791 hits, run 36867883784, zackees/llvm-ld#72). Live evidence: the family has no default-branch entry while PR restores keep missing; fix by saving it from a main push (or the PR-scoped `pr-` key, CACHE-013) or dropping the restore. | Candidate | | `CACHE-028` | A source-baseline reuse path rewrites byte-identical tracked build inputs or copies fresh checkout timestamps onto them, or keeps old timestamps on changed contents. Merge the authoritative staged checkout by content into an owned writable baseline inside shared preparation (setup-soldr/bosn/act2, never repo-local workflow YAML). Verify the resulting Git tree, metadata and tracked inventory, treat a bad baseline as a miss, and keep fresh invalidation for changes. No mechanism has shipped yet. The sanctioned interim is zccache content keying plus opt-in setup-soldr `source-mtime-normalize: true`. Hand-written timestamp replay and repo-local rsync merges are not allowed. See [issue #281](https://github.com/zackees/ci.yml/issues/281) and "Content-aware checkout merging" above. | Candidate -- decided policy ([#281](https://github.com/zackees/ci.yml/issues/281)), not yet enforced by ci-lint | | `GHAPI-001` | Machine-local CI tooling (a skill, tool, watcher, lander or `ci/` script) re-reads a GitHub collection it already holds without a time or cursor bound, or bypasses the per-machine broker (#224). Incremental re-queries bounded by the cache's high-water mark (`since=`, `created>=`/`updated` filters, stop-at-cached pagination, `If-Modified-Since`/`If-None-Match`) are allowed; unbounded re-fetches, uncached terminal state, and per-process caches that are not shared across the machine are violations. | Enforced by ci-lint (static precheck, `precheck` group 19: unbounded GitHub re-read loops and gh watch loops in `run:`, `ci/` and skill/tool scripts); the broker-bypass and machine-shared-cache clauses are candidate until the broker ships (zackees/clud#1743) | -| `REL-006` | A release job ships a macOS app/DMG/zip without the procedure in "macOS app signing and notarization": Developer ID signing inside-out with `--timestamp --options runtime`, a temporary keychain that is always cleaned up, `notarytool` notarization (App Store Connect API key preferred), `stapler staple`/`validate` and `spctl --assess` -- or the signing secrets are exposed to a `pull_request`/`pull_request_target`/`merge_group` job, or an unsigned macOS artifact is published. Reference implementation: `actions/app-signing-macos`. | Candidate -- decided policy ([#462](https://github.com/zackees/ci.yml/issues/462)), not yet enforced by ci-lint | +| `REL-006` | A release job ships a macOS app/DMG/zip without the procedure in "macOS app signing and notarization": Developer ID signing inside-out with `--timestamp --options runtime`, a temporary keychain that is always cleaned up, `notarytool` notarization (App Store Connect API key preferred), `stapler staple`/`validate` and `spctl --assess` -- or the signing secrets are exposed to a `pull_request`/`pull_request_target`/`merge_group` job, or an unsigned macOS artifact is published. Reference implementation: `actions/app-signing-macos`. | Enforced by ci-lint (static precheck), partial -- `ci_lint.rules.macos_signing`: a job that references a signing secret (`MACOS_SIGNING_IDENTITY`/`_CERT`/`_CERT_PASSWORD`, `MACOS_NOTARY_API_KEY`/`_KEY_ID`/`_ISSUER_ID`, fallback `MACOS_NOTARIZATION_USERNAME`/`_PASSWORD`, directly or via workflow `env:`) or uses `actions/app-signing-macos` is a violation in any `pull_request_target` workflow, and in a `pull_request`/`merge_group` workflow unless its `if:` confines it to tag refs, `release` or `workflow_dispatch` (another `if:` is needs_review); local reusable workflows inherit the caller's confinement; a signing job with no `environment:` is needs_review. The signing procedure outside the reference action and "an unsigned macOS artifact is published" remain Candidate ([#462](https://github.com/zackees/ci.yml/issues/462)) | An exception must name the repository, rule ID, reason, owner, compensating coverage, and review date. The documented Soldr dependency cycle in `zackees/running-process` is an example to evaluate for an exception. Exceptions are reviewed when their date arrives or the dependency changes; they do not erase historical findings. `ci.toml`'s `[[exceptions]]` array implements this mechanically for a `ci_lint`-checked repository (`rule`, `path`, `reason`, `issue`, `expires` -- `CT-005` fails an expired entry).