deadgate 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
deadgate-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erik Hill
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: deadgate
3
+ Version: 0.1.0
4
+ Summary: Find CI checks that cannot fail: gates satisfied by skipped jobs, exit statuses swallowed by pipes, fan-ins that never read whether their upstream passed.
5
+ Author-email: Erik Hill <contact@erikhill.dev>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/egnaro9/deadgate
8
+ Project-URL: Repository, https://github.com/egnaro9/deadgate
9
+ Project-URL: Issues, https://github.com/egnaro9/deadgate/issues
10
+ Project-URL: How it was measured, https://github.com/egnaro9/deadgate/blob/main/MEASUREMENT.md
11
+ Keywords: ci,github-actions,workflow,static-analysis,testing,mutation-testing,branch-protection,continuous-integration,devops
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: PyYAML>=6.0
27
+ Dynamic: license-file
28
+
29
+ # deadgate
30
+
31
+ **Find CI checks that cannot fail.**
32
+
33
+ Flaky-test tools find checks that fail randomly. This finds the opposite: checks that are
34
+ structurally incapable of going red, so your pipeline is green for reasons unrelated to your code.
35
+
36
+ GitHub documents the sharpest case itself:
37
+
38
+ > A job that is skipped will report its status as `Success`. It will not prevent a pull
39
+ > request from merging, even if it is a required check.
40
+
41
+ ```bash
42
+ pip install deadgate
43
+ deadgate .
44
+ ```
45
+
46
+ Exit code is 1 when there are findings, 0 when clean, and **2 when a workflow file could not
47
+ be parsed**. A file it could not read is never counted as a file with no problems.
48
+
49
+ ## What it detects
50
+
51
+ | id | defect | why it matters |
52
+ |----|--------|----------------|
53
+ | D1 | a job depends on a skip-prone job and never reads `needs.*.result` | the dependency skips, reports Success, and the gate passes with nothing run |
54
+ | D2 | a fan-in job runs on `always()` and never reads `needs.*.result` | it is green when the jobs it gates failed |
55
+ | D3 | a `run:` step ends a pipeline in a filter with no `pipefail` | the step's status is the filter's, so an upstream failure passes |
56
+
57
+ Every finding carries a reproduction. A finding without one is an opinion, and this tool
58
+ does not emit opinions.
59
+
60
+ ## The calibration corpus is the specification
61
+
62
+ `corpus/` holds workflows that are **known broken** and workflows that are **known good**, and
63
+ the good ones are deliberate near misses of the broken ones. A detector has to fire on the
64
+ defect *and* stay quiet on its near miss. A detector that fires on everything is as useless as
65
+ one that fires on nothing, and it gets the whole tool switched off.
66
+
67
+ ```bash
68
+ python -m pytest tests/
69
+ ```
70
+
71
+ Fixtures `g6` and `g7` exist because the detectors were **wrong against real repositories while
72
+ the corpus was green**:
73
+
74
+ - `g6` a job guarding itself with a job-level `if:` that reads `needs.*.outputs` IS checking its
75
+ upstream. The first version only read step-level conditions and flagged correct jobs.
76
+ - `g7` `if: ${{ !cancelled() }}` is not skip-prone. It runs in normal operation and on failure.
77
+ Treating every `if:` as a possible skip produced false alarms.
78
+
79
+ Both were found by running against a real 25k-star repository, not by the suite. That is the
80
+ argument this tool makes about everyone else's checks, so it is held to it too.
81
+
82
+ ## The branch-protection tier
83
+
84
+ The structural tier reads workflow files and reports the SHAPE of a dead gate. It cannot tell
85
+ you whether anything was relying on the job. That needs the branch's required status checks:
86
+
87
+ ```bash
88
+ deadgate . --repo owner/name # read-only GitHub API calls via `gh`
89
+ ```
90
+
91
+ Two endpoints answer, and they do not have the same reach:
92
+
93
+ | endpoint | access needed | what it covers |
94
+ |---|---|---|
95
+ | `GET /repos/{o}/{r}/rules/branches/{b}` | read | rulesets only |
96
+ | `GET /repos/{o}/{r}/branches/{b}/protection` | **admin** | classic protection |
97
+
98
+ On a repository you do not administer, only the first answers. It says nothing about classic
99
+ protection, so an empty result does not mean the branch is unprotected. That asymmetry decides
100
+ what the tier is allowed to claim:
101
+
102
+ - a **match proves** the check is required, so a MEDIUM finding escalates to HIGH
103
+ - **no match proves nothing** unless the required set is complete, which needs admin on both
104
+ endpoints. Without that, the verdict is AMBIGUOUS and the severity does not move
105
+
106
+ Only MEDIUM moves. MEDIUM is the tier that means "the workflow file does not say", so it is the
107
+ only one this evidence can settle. A structural HIGH keeps its severity even when a check is not
108
+ required, because protection can be added later and may be configured where this API does not
109
+ reach. A structural LOW keeps its severity because a release pipeline that skips on purpose does
110
+ not become a merge gate by appearing in a list.
111
+
112
+ ### Verdicts
113
+
114
+ | verdict | meaning | severity |
115
+ |---|---|---|
116
+ | `REQUIRED` | a derived check name matches a required context | MEDIUM becomes HIGH |
117
+ | `NOT_REQUIRED` | complete required set, no match | MEDIUM becomes LOW |
118
+ | `UNPROTECTED` | complete, and the branch requires nothing at all | MEDIUM becomes LOW |
119
+ | `AMBIGUOUS` | the name could not be derived, or the set is incomplete | unchanged |
120
+ | `UNREADABLE` | the API did not answer | unchanged |
121
+
122
+ ### Why it reports contexts it could not attribute
123
+
124
+ A required status check is identified by its **check-run name**, which is not the job key in the
125
+ YAML. If name derivation breaks, no required context matches any job, every MEDIUM resolves to
126
+ "not required", and the tool quietly downgrades real defects. So the run prints how many required
127
+ contexts it attributed to a job in the repository, and warns when none of them matched anything.
128
+ A broken matcher then appears as a number rather than as silence. Some contexts land there
129
+ legitimately, from third-party apps or workflows outside the repository, so it is a signal to
130
+ read and not an assertion.
131
+
132
+ Two transport facts are enforced rather than trusted, because both were observed:
133
+
134
+ - `403` is returned for rate limiting **and** for insufficient permissions. These are separated,
135
+ because one is retryable and the other means this tier cannot help on that repository.
136
+ - `404` from the classic endpoint means "not protected" for a branch you administer and "you
137
+ cannot see this" otherwise, with the same status code and only the prose differing. So it is
138
+ gated on `permissions.admin`, never on the message text.
139
+
140
+ ## Measurements
141
+
142
+ Figures, and the unit each one is in, are in [MEASUREMENT.md](MEASUREMENT.md). Reproduce them
143
+ with `python bench/build_cache.py` then `python bench/ab.py`.
144
+
145
+ The short version: corpus totals are weighted by workflow size, one monorepo carries 55% of the
146
+ naive D1 count, and the median affected repository sees 2 findings where the pre-narrowing
147
+ detector gave 3. `bench/ab.py` prints the per-repo median, p90 and max alongside every total,
148
+ so the unit cannot be dropped by accident.
149
+
150
+ ## Scope, stated plainly
151
+
152
+ This reads workflow files. It does **not** read branch-protection settings, so it reports the
153
+ *shape* of a defect, not whether a given check is actually required on your default branch.
154
+ Semantic gate testing, planting the condition a gate claims to catch and asserting it reacts,
155
+ is a separate and harder problem and is not in this release.
156
+
157
+ ## Licence
158
+ MIT
@@ -0,0 +1,130 @@
1
+ # deadgate
2
+
3
+ **Find CI checks that cannot fail.**
4
+
5
+ Flaky-test tools find checks that fail randomly. This finds the opposite: checks that are
6
+ structurally incapable of going red, so your pipeline is green for reasons unrelated to your code.
7
+
8
+ GitHub documents the sharpest case itself:
9
+
10
+ > A job that is skipped will report its status as `Success`. It will not prevent a pull
11
+ > request from merging, even if it is a required check.
12
+
13
+ ```bash
14
+ pip install deadgate
15
+ deadgate .
16
+ ```
17
+
18
+ Exit code is 1 when there are findings, 0 when clean, and **2 when a workflow file could not
19
+ be parsed**. A file it could not read is never counted as a file with no problems.
20
+
21
+ ## What it detects
22
+
23
+ | id | defect | why it matters |
24
+ |----|--------|----------------|
25
+ | D1 | a job depends on a skip-prone job and never reads `needs.*.result` | the dependency skips, reports Success, and the gate passes with nothing run |
26
+ | D2 | a fan-in job runs on `always()` and never reads `needs.*.result` | it is green when the jobs it gates failed |
27
+ | D3 | a `run:` step ends a pipeline in a filter with no `pipefail` | the step's status is the filter's, so an upstream failure passes |
28
+
29
+ Every finding carries a reproduction. A finding without one is an opinion, and this tool
30
+ does not emit opinions.
31
+
32
+ ## The calibration corpus is the specification
33
+
34
+ `corpus/` holds workflows that are **known broken** and workflows that are **known good**, and
35
+ the good ones are deliberate near misses of the broken ones. A detector has to fire on the
36
+ defect *and* stay quiet on its near miss. A detector that fires on everything is as useless as
37
+ one that fires on nothing, and it gets the whole tool switched off.
38
+
39
+ ```bash
40
+ python -m pytest tests/
41
+ ```
42
+
43
+ Fixtures `g6` and `g7` exist because the detectors were **wrong against real repositories while
44
+ the corpus was green**:
45
+
46
+ - `g6` a job guarding itself with a job-level `if:` that reads `needs.*.outputs` IS checking its
47
+ upstream. The first version only read step-level conditions and flagged correct jobs.
48
+ - `g7` `if: ${{ !cancelled() }}` is not skip-prone. It runs in normal operation and on failure.
49
+ Treating every `if:` as a possible skip produced false alarms.
50
+
51
+ Both were found by running against a real 25k-star repository, not by the suite. That is the
52
+ argument this tool makes about everyone else's checks, so it is held to it too.
53
+
54
+ ## The branch-protection tier
55
+
56
+ The structural tier reads workflow files and reports the SHAPE of a dead gate. It cannot tell
57
+ you whether anything was relying on the job. That needs the branch's required status checks:
58
+
59
+ ```bash
60
+ deadgate . --repo owner/name # read-only GitHub API calls via `gh`
61
+ ```
62
+
63
+ Two endpoints answer, and they do not have the same reach:
64
+
65
+ | endpoint | access needed | what it covers |
66
+ |---|---|---|
67
+ | `GET /repos/{o}/{r}/rules/branches/{b}` | read | rulesets only |
68
+ | `GET /repos/{o}/{r}/branches/{b}/protection` | **admin** | classic protection |
69
+
70
+ On a repository you do not administer, only the first answers. It says nothing about classic
71
+ protection, so an empty result does not mean the branch is unprotected. That asymmetry decides
72
+ what the tier is allowed to claim:
73
+
74
+ - a **match proves** the check is required, so a MEDIUM finding escalates to HIGH
75
+ - **no match proves nothing** unless the required set is complete, which needs admin on both
76
+ endpoints. Without that, the verdict is AMBIGUOUS and the severity does not move
77
+
78
+ Only MEDIUM moves. MEDIUM is the tier that means "the workflow file does not say", so it is the
79
+ only one this evidence can settle. A structural HIGH keeps its severity even when a check is not
80
+ required, because protection can be added later and may be configured where this API does not
81
+ reach. A structural LOW keeps its severity because a release pipeline that skips on purpose does
82
+ not become a merge gate by appearing in a list.
83
+
84
+ ### Verdicts
85
+
86
+ | verdict | meaning | severity |
87
+ |---|---|---|
88
+ | `REQUIRED` | a derived check name matches a required context | MEDIUM becomes HIGH |
89
+ | `NOT_REQUIRED` | complete required set, no match | MEDIUM becomes LOW |
90
+ | `UNPROTECTED` | complete, and the branch requires nothing at all | MEDIUM becomes LOW |
91
+ | `AMBIGUOUS` | the name could not be derived, or the set is incomplete | unchanged |
92
+ | `UNREADABLE` | the API did not answer | unchanged |
93
+
94
+ ### Why it reports contexts it could not attribute
95
+
96
+ A required status check is identified by its **check-run name**, which is not the job key in the
97
+ YAML. If name derivation breaks, no required context matches any job, every MEDIUM resolves to
98
+ "not required", and the tool quietly downgrades real defects. So the run prints how many required
99
+ contexts it attributed to a job in the repository, and warns when none of them matched anything.
100
+ A broken matcher then appears as a number rather than as silence. Some contexts land there
101
+ legitimately, from third-party apps or workflows outside the repository, so it is a signal to
102
+ read and not an assertion.
103
+
104
+ Two transport facts are enforced rather than trusted, because both were observed:
105
+
106
+ - `403` is returned for rate limiting **and** for insufficient permissions. These are separated,
107
+ because one is retryable and the other means this tier cannot help on that repository.
108
+ - `404` from the classic endpoint means "not protected" for a branch you administer and "you
109
+ cannot see this" otherwise, with the same status code and only the prose differing. So it is
110
+ gated on `permissions.admin`, never on the message text.
111
+
112
+ ## Measurements
113
+
114
+ Figures, and the unit each one is in, are in [MEASUREMENT.md](MEASUREMENT.md). Reproduce them
115
+ with `python bench/build_cache.py` then `python bench/ab.py`.
116
+
117
+ The short version: corpus totals are weighted by workflow size, one monorepo carries 55% of the
118
+ naive D1 count, and the median affected repository sees 2 findings where the pre-narrowing
119
+ detector gave 3. `bench/ab.py` prints the per-repo median, p90 and max alongside every total,
120
+ so the unit cannot be dropped by accident.
121
+
122
+ ## Scope, stated plainly
123
+
124
+ This reads workflow files. It does **not** read branch-protection settings, so it reports the
125
+ *shape* of a defect, not whether a given check is actually required on your default branch.
126
+ Semantic gate testing, planting the condition a gate claims to catch and asserting it reacts,
127
+ is a separate and harder problem and is not in this release.
128
+
129
+ ## Licence
130
+ MIT
File without changes
@@ -0,0 +1,180 @@
1
+ """Derive the check-run names a workflow job produces.
2
+
3
+ A required status check is identified by its CHECK-RUN NAME, which is not the job key in
4
+ the YAML. Getting this wrong is the way this tier lies: if derivation silently fails, no
5
+ required context matches any job, every finding resolves to "not required", and the tool
6
+ quietly downgrades real defects. So derivation reports its own confidence, and anything it
7
+ cannot work out is AMBIGUOUS rather than "no match".
8
+
9
+ The naming rules GitHub actually applies:
10
+
11
+ no `name:` the job key
12
+ no `name:` + a matrix `<job key> (<values, in matrix key order>)`
13
+ `name:` + a matrix that string, ALSO suffixed `(<values>)`
14
+ `name:` already naming matrix the string per leg, with expressions substituted, no suffix
15
+ `uses:` (reusable workflow) `<caller name> / <callee job name>`
16
+
17
+ The rule for a static `name:` with a matrix is append-the-leg-values, not use-it-once. Verified
18
+ against the authoritative surface: promptfoo's `main` branch requires the context
19
+ `Check Python (3.9)` while its workflow declares a static `name: Check Python` over a
20
+ `python-version` matrix, and its `Build on Node ${{ matrix.node }}` job, whose name already
21
+ references the matrix, is required as the unsuffixed `Build on Node 24.x`. Encoding the
22
+ opposite made every leg of a statically named matrix job match nothing.
23
+
24
+ Matrix legs and reusable callees are matched by PREFIX rather than enumerated, because the
25
+ prefix is decidable from the caller alone while the values often are not. That keeps a
26
+ `matrix: ${{ fromJson(needs.x.outputs.y) }}` job decidable instead of ambiguous.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import itertools
31
+ import re
32
+ from dataclasses import dataclass
33
+
34
+ EXACT = "EXACT"
35
+ AMBIGUOUS = "AMBIGUOUS"
36
+
37
+ _EXPR = re.compile(r"\$\{\{(.+?)\}\}", re.S)
38
+ _MATRIX_REF = re.compile(r"^\s*matrix\.([A-Za-z_][\w-]*)\s*$")
39
+ MAX_COMBINATIONS = 256
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Derived:
44
+ """Check-run names a job produces, plus how sure we are."""
45
+ names: frozenset[str]
46
+ prefixes: frozenset[str]
47
+ confidence: str
48
+ reason: str = ""
49
+
50
+ def matches(self, context: str) -> bool:
51
+ if self.confidence != EXACT:
52
+ raise ValueError("refusing to match on an AMBIGUOUS derivation")
53
+ return context in self.names or any(context.startswith(p) for p in self.prefixes)
54
+
55
+
56
+ def _render(value) -> str:
57
+ """A matrix value as GitHub interpolates it, which is not Python's str().
58
+
59
+ A YAML boolean renders `true`, not `True`, and null renders as the empty string. Getting
60
+ this wrong produces a name that matches nothing, and a name that matches nothing clears
61
+ the finding.
62
+ """
63
+ if isinstance(value, bool):
64
+ return "true" if value else "false"
65
+ if value is None:
66
+ return ""
67
+ return str(value)
68
+
69
+
70
+ def _literal_matrix(strategy) -> dict | None:
71
+ """The matrix as literal lists, or None when any part of it is computed.
72
+
73
+ `include` and `exclude` add and remove legs by rules not worth guessing at: an
74
+ under-enumerated name set silently fails to match a context that IS required, which clears
75
+ the finding. Both are lists of mappings, so they make the matrix non-literal here.
76
+ """
77
+ if not isinstance(strategy, dict):
78
+ return None
79
+ matrix = strategy.get("matrix")
80
+ if not isinstance(matrix, dict):
81
+ return None
82
+ axes = {}
83
+ for key, val in matrix.items():
84
+ # `include` and `exclude` land here too, and that is deliberate: both are lists of
85
+ # mappings, so the check below already makes the matrix non-literal and the caller
86
+ # falls through to AMBIGUOUS. An explicit guard for them was written first and was
87
+ # unreachable, which is the defect this tool is named after.
88
+ if not isinstance(val, list) or any(isinstance(v, (dict, list)) for v in val):
89
+ return None
90
+ if any("${{" in str(v) for v in val):
91
+ return None
92
+ axes[key] = [_render(v) for v in val]
93
+ return axes or None
94
+
95
+
96
+ def _substitute(template: str, axes: dict) -> frozenset[str] | None:
97
+ """Expand a `name:` template over a literal matrix, or None if it cannot be expanded."""
98
+ refs = []
99
+ for raw in _EXPR.findall(template):
100
+ m = _MATRIX_REF.match(raw)
101
+ if not m or m.group(1) not in axes:
102
+ return None # an expression we cannot evaluate: not our business to guess
103
+ refs.append(m.group(1))
104
+ if not refs:
105
+ return frozenset({template})
106
+ used = {r: axes[r] for r in dict.fromkeys(refs)}
107
+ combos = list(itertools.product(*used.values()))
108
+ if len(combos) > MAX_COMBINATIONS:
109
+ return None
110
+ out = set()
111
+ for combo in combos:
112
+ values = dict(zip(used.keys(), combo))
113
+ # GitHub trims the rendered name, which matters when a leg's value is empty.
114
+ out.add(_EXPR.sub(lambda m: values[_MATRIX_REF.match(m.group(1)).group(1)],
115
+ template).strip())
116
+ # A job SKIPPED by its job-level if: emits one check run carrying the RAW template, with
117
+ # the expressions left literal and no matrix expansion. Skipped jobs are precisely this
118
+ # tool's target population, so the unexpanded form is a legitimate candidate name.
119
+ out.add(template.strip())
120
+ return frozenset(out)
121
+
122
+
123
+ def derive(job_key: str, job: dict, workflow_callable: bool = False) -> Derived:
124
+ """The check-run names job `job_key` produces.
125
+
126
+ `workflow_callable` says this job's own workflow declares `on: workflow_call`. When another
127
+ workflow in the repository `uses:` it, GitHub names the check `<caller job> / <this job>`,
128
+ and which caller, if any, is not decidable from this file. Guessing the unprefixed name
129
+ clears a finding on a job that IS a required gate, so this is AMBIGUOUS instead.
130
+ """
131
+ job = job if isinstance(job, dict) else {}
132
+ if workflow_callable:
133
+ return Derived(frozenset(), frozenset(), AMBIGUOUS,
134
+ "workflow is `uses:`-callable, so its checks may be named "
135
+ "`<caller> / <job>` and the caller is not knowable from this file")
136
+ template = job.get("name")
137
+ axes = _literal_matrix(job.get("strategy"))
138
+ has_matrix = isinstance(job.get("strategy"), dict) and job["strategy"].get("matrix") is not None
139
+
140
+ if template is None:
141
+ base = job_key
142
+ if "${{" in base:
143
+ return Derived(frozenset(), frozenset(), AMBIGUOUS, "job key contains an expression")
144
+ if job.get("uses"):
145
+ # A reusable call produces `caller / callee`, and a MATRIXED one produces
146
+ # `caller (values) / callee`. Checking uses: before the matrix emitted only the
147
+ # first prefix, so every matrixed reusable call matched nothing.
148
+ prefixes = {f"{base} / "}
149
+ if has_matrix:
150
+ prefixes.add(f"{base} (")
151
+ # A skipped reusable call emits the bare caller name with no callee suffix.
152
+ return Derived(frozenset({base}), frozenset(prefixes), EXACT,
153
+ "reusable workflow" + (" with matrix legs" if has_matrix else ""))
154
+ prefixes = frozenset({f"{base} ("}) if has_matrix else frozenset()
155
+ return Derived(frozenset({base}), prefixes, EXACT,
156
+ "job key" + (" with matrix legs" if has_matrix else ""))
157
+
158
+ template = str(template)
159
+ if "${{" not in template:
160
+ # A static name is SUFFIXED with the leg values when the job has a matrix. Encoding
161
+ # the opposite, that it is used once verbatim, made every leg match nothing.
162
+ prefixes = {f"{template} ("} if has_matrix else set()
163
+ if job.get("uses"):
164
+ prefixes.add(f"{template} / ")
165
+ return Derived(frozenset({template}), frozenset(prefixes), EXACT,
166
+ "reusable workflow" + (" with matrix legs" if has_matrix else ""))
167
+ return Derived(frozenset({template}), frozenset(prefixes), EXACT,
168
+ "explicit name" + (" with matrix legs" if has_matrix else ""))
169
+
170
+ if axes is None:
171
+ return Derived(frozenset(), frozenset(), AMBIGUOUS,
172
+ "name: has an expression and the matrix is not literal")
173
+ names = _substitute(template, axes)
174
+ if names is None:
175
+ return Derived(frozenset(), frozenset(), AMBIGUOUS,
176
+ "name: has an expression that does not resolve from the matrix")
177
+ if job.get("uses"):
178
+ return Derived(names, frozenset(f"{n} / " for n in names), EXACT,
179
+ "reusable workflow, name expanded over the matrix")
180
+ return Derived(names, frozenset(), EXACT, "name expanded over the matrix")
@@ -0,0 +1,124 @@
1
+ """deadgate: find CI checks that cannot fail."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import pathlib
6
+ from dataclasses import replace
7
+ import sys
8
+
9
+ import yaml
10
+
11
+ from .detectors import scan_workflow
12
+ from .protection import fetch, gh_api, repo_meta, selftest
13
+ from .resolve import attribute, resolve, workflow_is_callable
14
+
15
+
16
+ def workflows(root: pathlib.Path):
17
+ for pat in ("*.yml", "*.yaml"):
18
+ yield from sorted(root.rglob(f".github/workflows/{pat}"))
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ ap = argparse.ArgumentParser(prog="deadgate", description=__doc__)
23
+ ap.add_argument("path", nargs="?", default=".", help="repository root")
24
+ ap.add_argument("--quiet", action="store_true", help="only print the summary")
25
+ ap.add_argument("--repo", metavar="OWNER/NAME",
26
+ help="resolve MEDIUM findings against what the branch actually requires. "
27
+ "Read-only GitHub API calls via `gh`. On a repository you do not "
28
+ "administer the required set is incomplete, so findings can only be "
29
+ "escalated, never cleared.")
30
+ ap.add_argument("--branch", help="branch to read protection from (default: the repo's default)")
31
+ ap.add_argument("--all", action="store_true",
32
+ help="include LOW findings (release and deploy pipelines, where a skip "
33
+ "is usually the intent). Hidden by default so the output stays actionable.")
34
+ args = ap.parse_args(argv)
35
+
36
+ root = pathlib.Path(args.path)
37
+ files = list(workflows(root))
38
+ if not files:
39
+ print(f"no workflow files under {root}/.github/workflows", file=sys.stderr)
40
+ return 0
41
+
42
+ prot = None
43
+ jobs_by_file: dict[str, dict] = {}
44
+ callable_files: set[str] = set()
45
+ if args.repo:
46
+ api = gh_api()
47
+ ok, why = selftest(api)
48
+ if not ok:
49
+ # Refuse rather than report every finding UNREADABLE, which would look like a
50
+ # result about the repository instead of a broken transport.
51
+ print(f"GitHub API transport self-test failed: {why}", file=sys.stderr)
52
+ return 2
53
+ default_branch, admin = repo_meta(args.repo, api)
54
+ branch = args.branch or default_branch
55
+ if not branch:
56
+ print(f"could not read {args.repo} from the GitHub API; is `gh` authenticated?",
57
+ file=sys.stderr)
58
+ return 2
59
+ prot = fetch(args.repo, branch, api, admin=admin)
60
+ print(f"branch {branch}: {prot.state}"
61
+ f"{f', {len(prot.required)} required check(s)' if prot.required else ''}"
62
+ f"{'' if prot.complete else ', required set INCOMPLETE (no admin)'}"
63
+ f"{f' [{prot.detail}]' if prot.detail else ''}\n")
64
+
65
+ findings = []
66
+ suppressed = 0
67
+ for f in files:
68
+ try:
69
+ doc = yaml.safe_load(f.read_text())
70
+ except yaml.YAMLError as exc:
71
+ # Refuse to report a clean result for a file we could not read. A parse
72
+ # failure counted as "no findings" is the exact defect this tool exists to find.
73
+ print(f"UNREADABLE {f}: {exc.__class__.__name__}", file=sys.stderr)
74
+ return 2
75
+ jobs_by_file[str(f)] = (doc or {}).get("jobs") or {}
76
+ if workflow_is_callable(doc):
77
+ callable_files.add(str(f))
78
+ for x in scan_workflow(doc):
79
+ why = ""
80
+ if prot is not None:
81
+ job = ((doc or {}).get("jobs") or {}).get(x.job)
82
+ r = resolve(x.severity, x.job, job if isinstance(job, dict) else {}, prot,
83
+ str(f) in callable_files)
84
+ why = f"{r.verdict}: {r.why}"
85
+ if r.moved:
86
+ why = f"{r.structural} -> {r.severity} {why}"
87
+ x = replace(x, severity=r.severity)
88
+ if x.severity == "LOW" and not args.all:
89
+ suppressed += 1
90
+ continue
91
+ findings.append((f, x, why))
92
+
93
+ if not args.quiet:
94
+ for f, x, why in findings:
95
+ rel = f.relative_to(root) if f.is_relative_to(root) else f
96
+ print(f"[{x.detector}/{x.severity}] {rel}::{x.job} {x.title}")
97
+ print(f" {x.detail}")
98
+ if why:
99
+ print(f" branch: {why}")
100
+ print(f" repro: {x.repro}\n")
101
+
102
+ tail = f", {suppressed} LOW hidden (use --all)" if suppressed else ""
103
+ print(f"{len(files)} workflow file(s), {len(findings)} finding(s){tail}")
104
+
105
+ if prot is not None and prot.required:
106
+ att = attribute(prot, jobs_by_file, frozenset(callable_files))
107
+ print(f"required checks: {len(att.attributed)}/{len(att.required)} attributed to a job "
108
+ f"in this repository")
109
+ if att.unattributed:
110
+ # Printed because a broken name derivation shows up here as a number instead of
111
+ # as silently cleared findings. Third-party checks land here legitimately.
112
+ print(f" unattributed: {', '.join(att.unattributed[:8])}"
113
+ f"{' ...' if len(att.unattributed) > 8 else ''}")
114
+ if att.undecidable_jobs:
115
+ print(f" jobs whose check name could not be derived: {len(att.undecidable_jobs)}")
116
+ if att.suspicious:
117
+ print(" WARNING: no required check matched any job here. Either every gate is "
118
+ "external, or the name derivation is broken. Do not read the severities "
119
+ "above as resolved.")
120
+ return 1 if findings else 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ raise SystemExit(main())