Skip to content

Commit 947df9d

Browse files
wshallwshallwshallwshallclaude
authored
ci(signal): turn a red check into a label a poller can find in one call (#716)
* ci(signal): turn a red check into a label a poller can find in one call BACKLOG #1402 and #1403. When a required check fails, GitHub tells no session. Measured 2026-08-31: exactly two workflows carried an if-failure step and both only failed the job, so the only route from a red to somebody acting on it was fetching the check rollup of every open pull request, one at a time. A workflow_run job now labels the pull request ci-red on a failure of CI, Security, CodeQL or backlog-hygiene. Noticing becomes one gh pr list --label call. This is not a push and the header says so: GitHub still cannot reach into a session. It makes the poll cheap enough to run often. The merge-queue case is why head_branch is parsed. A merge_group run carries no github.event.pull_request -- the same fact that collapsed backlog-hygiene's concurrency key until #711 keyed it on github.ref -- but its ref encodes the number as gh-readonly-queue/<base>/pr-<N>-<sha>, so an ejection can still be attributed to the pull request it ejected. A red with no pull request behind it goes to one standing issue instead, matched on its exact title so search ranking cannot spawn a second tracker. That is not the rare branch: 4 of the 9 failures in the last 200 runs were of that kind. Every github.event value reaches the scripts through the environment, because a branch name is chosen by whoever opened the branch. Top-level permissions are contents: read and the one writing job elevates for itself. CLA Assistant is deliberately not watched: a CLA failure is the contributor's to resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci(zizmor): justify failure-signal.yml's workflow_run trigger, and pin what the justification rests on zizmor reds failure-signal.yml under dangerous-triggers. workflow_run is the only trigger that can observe another workflow's conclusion, and the contexts this watches span four workflows, so it is the mechanism here rather than a convenience. Record it as a reviewed non-finding. The objection is the "pwn request" class: workflow_run runs from the default branch with a privileged token, so checking out and running the triggering pull request's code would hand any fork pull request a write token. Four properties put that out of reach, and a test now holds each one instead of the comment that claims it: * no checkout and no `uses:` at all, so nothing from the head is fetched, let alone run * file default contents: read, with one job taking pull-requests: write and issues: write, which cannot push, tag or modify code * every github.event value hoisted to env, never spliced into a run body * head_branch parsed only under RUN_EVENT = merge_group, an event a fork cannot produce This is a weaker claim than nightly-notice.yml's, and the entry says so rather than implying symmetry. That workflow gates on schedule, so no pull request reaches it at all. This one must react to pull-request runs, because labelling the pull request is the entire point, so its fork path is open. The four properties are what bound that cost, not a closed door. The entry also records that GitHub leaves workflow_run.pull_requests empty for a fork, so a fork red falls through to the standing-issue branch instead of labelling anything. tests/test_failure_signal.py, reviewed before shipping: drop an assertion that could not fail -- it ended in `if False else 0` and `or True`, so it graded nothing while reading as coverage. Rename the watch-list test, which claimed every watched workflow produces a required context; CodeQL does not, and .github/required-contexts.txt lists it under DELIBERATELY NOT REQUIRED. Add positive controls where an empty scan and a clean scan rendered identically, look the resolve step up by id rather than by position so a reorder fails where it happened, and assert the env hoist itself rather than only the absence of interpolation. All six tests red under a targeted mutation of the workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(ci): classify the failure-signal guard as repo-harness, not engine tests/test_tooling_partition.py went red on the previous commit: a new test that imports no engine module must land in tests/tooling_manifest.txt or in the explicit stay-list, and this one was in neither. Measured on the CI run for 27a856d -- 1 failed, 11122 passed, and the one failure was that classification gate. The manifest is the right list. This test's subject is a workflow file, .github/workflows/ failure-signal.yml, exactly like tests/test_nightly_notice.py beside it. The stay-list exists for tests that read messagefoundry/** off disk without importing it; this one never touches engine source, so listing it there would widen that list's stated rule for no reason. Listing it does not hide it. The tooling job's path filter fires on any change under .github/, and separately on any path named in the manifest itself, so both the workflow this guards and the guard itself trip the job that runs it. Confirmed locally: the six tests collect under `-m tooling` and deselect under `-m 'not tooling'`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci(zizmor): correct the workflow count in the failure-signal justification The entry said the required contexts span five workflows. Neither half held: failure-signal.yml watches four names -- CI, Security, CodeQL, backlog-hygiene -- and CodeQL carries no required context at all, which .github/required-contexts.txt records deliberately. Worth a commit of its own because of where the sentence sits. This file's header calls every entry a reviewed, justified non-finding, so a reader checks the reasoning and carries the count with it. A wrong number welded to sound reasoning is the half nobody re-checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: wshallwshall <mefordev@messagefoundry.org> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ab6e40f commit 947df9d

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Turn a red check into something a poller can find in ONE call.
2+
#
3+
# WHY THIS EXISTS (BACKLOG #1402 and #1403). When a required check fails, GitHub tells no session.
4+
# Measured 2026-08-31: exactly two workflows carried an `if: failure()` step, and both only failed
5+
# the job -- no label, no comment, no issue. So the only route from a red to somebody acting on it
6+
# was a seat fetching the check rollup of every open pull request, one at a time. That is expensive
7+
# enough that it does not get run often, which is the same as not having it.
8+
#
9+
# This does not make anything a PUSH, and must not be described as one. GitHub still cannot reach
10+
# into a session. What it does is collapse "fetch a rollup per pull request" into "list pull requests
11+
# carrying one label", which is the difference between a poll you can afford to run every few minutes
12+
# and one you cannot.
13+
#
14+
# WHY `workflow_run` AND NOT `if: failure()` INSIDE EACH WORKFLOW. A failure step inside a workflow
15+
# cannot see the runs of OTHER workflows, and the required contexts span five of them. `workflow_run`
16+
# observes any of them from one place, runs from the default branch with its own permissions, and --
17+
# unlike `pull_request_target` -- never executes code from the head. `nightly-notice.yml` already uses
18+
# this trigger, so the pattern is not new here.
19+
#
20+
# THE MERGE-QUEUE CASE IS WHY head_branch IS PARSED (#1403). A run triggered by `merge_group` has NO
21+
# `github.event.pull_request`: that is the same fact that collapsed backlog-hygiene's concurrency key
22+
# to a single group, where every queue entry cancelled its predecessor until #711 keyed it on
23+
# `github.ref`. The queue's ref does carry the number, in the form
24+
# `gh-readonly-queue/<base>/pr-<N>-<sha>`, so an ejection can still be attributed to the pull request
25+
# it ejected.
26+
#
27+
# CLA Assistant is deliberately NOT watched. A CLA failure is the contributor's to resolve and needs
28+
# no attribution; labelling it would train readers to ignore the label.
29+
30+
name: failure signal
31+
32+
on:
33+
workflow_run:
34+
workflows: [CI, Security, CodeQL, backlog-hygiene]
35+
types: [completed]
36+
37+
# Least privilege at the top; the one job that needs to write says so for itself.
38+
permissions:
39+
contents: read
40+
41+
concurrency:
42+
# Keyed on the RUN, not on a branch. Two different workflows failing on the same head are two
43+
# separate facts, and collapsing them would drop one -- the mistake #711 had to undo.
44+
group: failure-signal-${{ github.event.workflow_run.id }}
45+
cancel-in-progress: false
46+
47+
jobs:
48+
signal:
49+
# Only a real failure. A cancelled run is not a red: branch protection gates on the latest head,
50+
# so a cancelled predecessor says nothing about the current one.
51+
if: github.event.workflow_run.conclusion == 'failure'
52+
runs-on: ubuntu-latest
53+
permissions:
54+
pull-requests: write
55+
issues: write
56+
steps:
57+
- name: Find the pull request behind this run, if there is one
58+
id: resolve
59+
env:
60+
# EVERY value below is attacker-influenceable -- a branch name is chosen by whoever opened
61+
# the branch. They go through the environment and are matched against a strict pattern.
62+
# Interpolating them into the script body would be a template-injection hole.
63+
PR_FROM_EVENT: ${{ toJSON(github.event.workflow_run.pull_requests) }}
64+
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
65+
RUN_EVENT: ${{ github.event.workflow_run.event }}
66+
run: |
67+
set -euo pipefail
68+
pr="$(printf '%s' "$PR_FROM_EVENT" | jq -r 'if length > 0 then .[0].number else "" end')"
69+
70+
# A merge-queue run has no pull_requests entry. Its ref carries the number instead.
71+
if [ -z "$pr" ] && [ "$RUN_EVENT" = "merge_group" ]; then
72+
pr="$(printf '%s' "$HEAD_BRANCH" | grep -oE 'pr-[0-9]+' | head -1 | cut -d- -f2 || true)"
73+
fi
74+
75+
if [ -n "$pr" ]; then
76+
echo "pr=$pr" >> "$GITHUB_OUTPUT"
77+
echo "Resolved to pull request $pr"
78+
else
79+
echo "pr=" >> "$GITHUB_OUTPUT"
80+
echo "No pull request behind this run; event was $RUN_EVENT"
81+
fi
82+
83+
- name: Label the pull request
84+
if: steps.resolve.outputs.pr != ''
85+
env:
86+
GH_TOKEN: ${{ github.token }}
87+
PR: ${{ steps.resolve.outputs.pr }}
88+
REPO: ${{ github.repository }}
89+
run: |
90+
set -euo pipefail
91+
gh label create ci-red --repo "$REPO" --color B60205 --description "A required check went red. Attribute it before retrying." 2>/dev/null || true
92+
gh pr edit "$PR" --repo "$REPO" --add-label ci-red
93+
94+
- name: Otherwise record it against the standing tracker
95+
if: steps.resolve.outputs.pr == ''
96+
env:
97+
GH_TOKEN: ${{ github.token }}
98+
REPO: ${{ github.repository }}
99+
RUN_EVENT: ${{ github.event.workflow_run.event }}
100+
RUN_NAME: ${{ github.event.workflow_run.name }}
101+
RUN_URL: ${{ github.event.workflow_run.html_url }}
102+
run: |
103+
set -euo pipefail
104+
# A red with no pull request behind it -- a push to the trunk, or a scheduled run -- has
105+
# nowhere to attach. Measured 2026-08-31: 4 of the 9 failures in the last 200 runs were of
106+
# this kind, so this is not the rare branch. One standing issue collects them, because a new
107+
# issue per failure is noise nobody reads.
108+
title="Reds with no pull request behind them"
109+
# Matched on the exact title rather than a search relevance ranking, so a second tracker
110+
# cannot be created just because search ordering changed.
111+
num="$(gh issue list --repo "$REPO" --state open --limit 200 --json number,title --jq '.[] | select(.title == "Reds with no pull request behind them") | .number' | head -1)"
112+
printf '%s failed on a %s run. Nothing else reports this.\n\n%s\n' "$RUN_NAME" "$RUN_EVENT" "$RUN_URL" > signal-body.md
113+
114+
if [ -n "$num" ]; then
115+
gh issue comment "$num" --repo "$REPO" --body-file signal-body.md
116+
else
117+
printf 'Standing tracker. A required check can fail on a push to the trunk, in the merge queue, or on a schedule, and none of those has a pull request to carry a label. Each such failure is added here as a comment.\n\n' > intro.md
118+
cat intro.md signal-body.md > new-issue.md
119+
gh issue create --repo "$REPO" --title "$title" --body-file new-issue.md
120+
fi

.github/zizmor.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,42 @@ rules:
3535
# attacker-influenceable field (a branch name reaching a run title) cannot break out.
3636
# The most its token can do is open, comment on, or close one issue.
3737
- nightly-notice.yml
38+
# A red check can only be observed by something that runs AFTER the failing workflow finishes,
39+
# and the checks this watches live in four separate workflows. No other trigger sees another
40+
# workflow's conclusion, so `workflow_run` is the mechanism here rather than a convenience --
41+
# the same argument as nightly-notice.yml above.
42+
#
43+
# zizmor's objection is the "pwn request" class: `workflow_run` runs from the DEFAULT BRANCH
44+
# with a privileged token, so a workflow that checks out and EXECUTES the triggering pull
45+
# request's code hands any fork pull request a write token. Every precondition for that is
46+
# absent, and each is pinned by tests/test_failure_signal.py rather than merely asserted here:
47+
# * NO checkout, and no `uses:` at all -- `test_it_pulls_in_no_third_party_actions` fails the
48+
# moment one appears. Nothing from the triggering ref is fetched, let alone run.
49+
# * Least privilege: file default `contents: read`; the single job takes `pull-requests:
50+
# write` and `issues: write` (`test_it_is_least_privilege_and_cannot_modify_code`). It
51+
# cannot push, tag, or modify code. The most its token does is add a label to a pull
52+
# request or comment on an issue.
53+
# * Every `github.event` value reaches a script through `env` and is never interpolated into
54+
# a `run:` body (`test_every_event_value_reaches_a_script_through_env`), so a branch name
55+
# chosen by an attacker cannot break out of the shell it lands in.
56+
# * The one attacker-influenceable field it reads, `head_branch`, is parsed ONLY to recover a
57+
# pull-request number from a merge-queue ref, and only when the triggering run's event was
58+
# `merge_group` -- which a fork pull request cannot produce
59+
# (`test_the_merge_queue_parse_is_gated_on_an_event_a_fork_cannot_produce`).
60+
#
61+
# ONE DIFFERENCE FROM nightly-notice.yml, AND IT IS STATED RATHER THAN GLOSSED. That workflow
62+
# gates on `workflow_run.event == 'schedule'`, so no pull request reaches it at all -- its fork
63+
# path is CLOSED. This one MUST react to pull-request runs, because labelling the pull request
64+
# is the entire point, so its fork path is OPEN. The four properties above are what bound the
65+
# cost of that: nothing from the head is fetched or executed, and the token cannot touch code.
66+
# This entry is therefore a WEAKER claim than the one above it, and a reader comparing the two
67+
# should see that rather than infer symmetry.
68+
#
69+
# EXPECT THIS RATHER THAN BE SURPRISED BY IT: GitHub leaves `workflow_run.pull_requests` empty
70+
# for a fork pull request, so a fork red resolves to no number and falls through to the
71+
# standing-issue branch instead of labelling anything. Benign, and it is why a fork's red
72+
# arrives as a comment on the tracker rather than as a `ci-red` label.
73+
- failure-signal.yml
3874

3975
artipacked:
4076
ignore:

tests/test_failure_signal.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 MessageFoundry Organization and contributors
3+
"""Pin the premises that make `failure-signal.yml`'s `workflow_run` trigger safe.
4+
5+
`.github/zizmor.yml` suppresses `dangerous-triggers` for this file. That suppression is only honest
6+
while the properties it rests on hold, and a comment cannot enforce them. These tests are what make
7+
the suppression a claim rather than a hope, in the same shape as `test_nightly_notice.py`.
8+
9+
WHAT ZIZMOR IS OBJECTING TO, stated so a future reader does not have to guess. `workflow_run` runs
10+
from the DEFAULT BRANCH with a privileged token. The attack it names -- the "pwn request" class -- is
11+
a workflow that then checks out and EXECUTES the triggering pull request's code, which escalates any
12+
fork pull request into a write token. Every precondition for that is absent here, and each one below
13+
is asserted rather than described.
14+
15+
ONE DIFFERENCE FROM nightly-notice.yml, AND IT IS DELIBERATE. That workflow reacts only to
16+
`schedule`, so no pull request can reach it at all. This one MUST react to pull-request runs, because
17+
labelling the pull request is the entire point. So the fork path is open, and the tests below cover
18+
what that costs instead of pretending it is closed: no code from the head is fetched or run, the
19+
token cannot modify code, and the one attacker-influenceable field is gated on an event a fork cannot
20+
produce.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import re
26+
from pathlib import Path
27+
28+
import pytest
29+
30+
yaml = pytest.importorskip("yaml")
31+
32+
WORKFLOWS = Path(__file__).resolve().parents[1] / ".github" / "workflows"
33+
FILE = WORKFLOWS / "failure-signal.yml"
34+
35+
36+
def _doc() -> dict:
37+
return yaml.safe_load(FILE.read_text(encoding="utf-8"))
38+
39+
40+
def _on(doc: dict) -> dict:
41+
# PyYAML parses a bare `on:` key as the BOOLEAN True, not the string "on". Reading doc["on"]
42+
# returns None and every assertion below would pass vacuously.
43+
return doc[True]
44+
45+
46+
def _steps() -> list[dict]:
47+
steps = _doc()["jobs"]["signal"]["steps"]
48+
# Positive control for every assertion built on this list. An empty or renamed job would make
49+
# "no offenders were found" and "nothing was looked at" render identically.
50+
assert steps, "the signal job has no steps to inspect"
51+
return steps
52+
53+
54+
def _run_blocks() -> list[str]:
55+
blocks = [s["run"] for s in _steps() if "run" in s]
56+
assert blocks, "the signal job has no `run:` step to inspect"
57+
return blocks
58+
59+
60+
def _step(step_id: str) -> dict:
61+
"""One named step, looked up by `id:` rather than by position.
62+
63+
Positional indexing would still fail if the steps were reordered, but it would fail somewhere
64+
unrelated to the reorder. Naming the step makes the message say what actually moved.
65+
"""
66+
matches = [s for s in _steps() if s.get("id") == step_id]
67+
assert len(matches) == 1, f"expected exactly one step with id {step_id!r}, found {len(matches)}"
68+
return matches[0]
69+
70+
71+
def _run_block(step_id: str) -> str:
72+
step = _step(step_id)
73+
assert "run" in step, f"step {step_id!r} has no `run:` body to inspect"
74+
return str(step["run"])
75+
76+
77+
def test_it_pulls_in_no_third_party_actions() -> None:
78+
"""Nothing from the triggering ref is fetched, let alone executed.
79+
80+
This is the strongest of the properties: with no `uses:` at all there is no checkout, no
81+
third-party bundle, and therefore no path from a fork's branch to code running under the
82+
default branch's token.
83+
"""
84+
assert [s for s in _steps() if "uses" in s] == [], (
85+
"failure-signal.yml gained a `uses:`. The zizmor suppression for dangerous-triggers "
86+
"rests on this workflow running no third-party code and checking nothing out. Either "
87+
"remove it, or re-justify the suppression in .github/zizmor.yml."
88+
)
89+
90+
91+
def test_it_is_least_privilege_and_cannot_modify_code() -> None:
92+
"""The token can label a pull request or comment on an issue. It cannot push, tag or write code."""
93+
doc = _doc()
94+
assert doc["permissions"] == {"contents": "read"}, (
95+
f"top-level permissions are {doc.get('permissions')!r}. Keep the file default read-only so a "
96+
"job added here cannot inherit write scope by accident."
97+
)
98+
job_perms = doc["jobs"]["signal"].get("permissions")
99+
assert job_perms == {"pull-requests": "write", "issues": "write"}, (
100+
f"the signal job's permissions are {job_perms!r}. It needs exactly these two writes. Anything "
101+
"that can modify code -- `contents: write`, `packages: write`, `id-token: write` -- turns the "
102+
"open fork path into the escalation the zizmor suppression says is closed."
103+
)
104+
105+
106+
def test_every_event_value_reaches_a_script_through_env() -> None:
107+
"""A branch name is chosen by whoever opened the branch.
108+
109+
Interpolating `${{ github.event.* }}` into a `run:` body would splice attacker-controlled text
110+
into a shell script. Every such value must arrive as an environment variable instead.
111+
"""
112+
offenders = [b for b in _run_blocks() if "${{" in b]
113+
assert offenders == [], (
114+
"A run block interpolates a GitHub expression directly. Hoist it to the step's `env:` "
115+
"and reference it as a shell variable."
116+
)
117+
# The positive half of the same claim. Absence of `${{` also holds for a workflow that reads no
118+
# event value at all, so on its own it cannot tell "hoisted to env" from "gone". Assert the
119+
# hoist itself: the resolve step is where the attacker-influenceable fields arrive.
120+
hoisted = [v for v in _step("resolve").get("env", {}).values() if "github.event" in str(v)]
121+
assert hoisted, (
122+
"the resolve step declares no `github.event` value in its `env:`. Either the values moved "
123+
"into the script body -- which the check above would then have to catch -- or this test is "
124+
"now watching the wrong step."
125+
)
126+
127+
128+
def test_the_merge_queue_parse_is_gated_on_an_event_a_fork_cannot_produce() -> None:
129+
"""`head_branch` is the one attacker-influenceable field this workflow reads.
130+
131+
It is parsed only to recover the pull-request number from a merge-queue ref, and only when the
132+
triggering run was a `merge_group`. A fork pull request cannot produce that event, so a branch
133+
named to look like a queue ref never reaches the parse.
134+
"""
135+
resolve = _run_block("resolve")
136+
assert "HEAD_BRANCH" in resolve, (
137+
"the resolve step no longer reads HEAD_BRANCH. If the merge-queue parse moved, move this "
138+
"assertion with it; the suppression in .github/zizmor.yml names this test by name."
139+
)
140+
guard = re.search(r'if \[ -z "\$pr" \] && \[ "\$RUN_EVENT" = "merge_group" \]', resolve)
141+
assert guard is not None, (
142+
"The merge-queue branch parse is no longer gated on RUN_EVENT = merge_group. Ungated, a "
143+
"crafted branch name could steer the label onto an unrelated pull request."
144+
)
145+
146+
147+
def test_every_watched_workflow_exists() -> None:
148+
"""A watched name that no workflow answers to is dead config that reads as coverage.
149+
150+
`workflow_run` matches on a workflow's `name:`, not its filename, so renaming one silently
151+
retires the watch -- no error, no run, permanent silence. That is the failure this signal exists
152+
to end, one level up.
153+
154+
It asserts EXISTENCE only. An earlier name for this test also claimed each watched workflow
155+
produces a required context, and that is false: `.github/required-contexts.txt` lists CodeQL
156+
under "DELIBERATELY NOT REQUIRED", because its SARIF upload needs a scope fork-PR tokens lack.
157+
Watching a non-required workflow is intentional -- a red CodeQL run is still worth attributing.
158+
"""
159+
watched = set(_on(_doc())["workflow_run"]["workflows"])
160+
assert watched, "the watch list is empty, so every assertion below would pass against nothing"
161+
present = set()
162+
for path in WORKFLOWS.glob("*.yml"):
163+
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
164+
name = doc.get("name")
165+
if isinstance(name, str):
166+
present.add(name)
167+
# Positive control: the scan must actually be reading workflows, or `missing` below is just the
168+
# watch list back again and the failure message would blame the wrong file.
169+
assert len(present) > 5, f"the workflow scan found only {len(present)} named files"
170+
missing = watched - present
171+
assert missing == set(), (
172+
f"failure-signal.yml watches names no workflow answers to: {missing}. "
173+
f"Names present: {sorted(present)}"
174+
)
175+
176+
177+
def test_it_only_acts_on_a_real_failure() -> None:
178+
"""A cancelled run is not a red.
179+
180+
Branch protection gates on the latest head, so a cancelled predecessor says nothing about the
181+
current one. Labelling on `cancelled` would train readers to ignore the label.
182+
"""
183+
condition = _doc()["jobs"]["signal"]["if"]
184+
assert "conclusion == 'failure'" in condition
185+
assert "cancelled" not in condition

tests/tooling_manifest.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ tests/test_defaulted_credential_lint.py
8787
tests/test_dep1_lock_resync_lockstep.py
8888
tests/test_dependabot_automerge_guardrails.py
8989
tests/test_docs_runbooks.py
90+
tests/test_failure_signal.py
9091
tests/test_feature_map_claims.py
9192
tests/test_freethread_smoke_liveness.py
9293
tests/test_gate_installed_parity.py

0 commit comments

Comments
 (0)