Repository navigation
Expand file tree
/
Copy pathaction.yml
More file actions
132 lines (126 loc) · 5.79 KB
/
Copy pathaction.yml
File metadata and controls
132 lines (126 loc) · 5.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# Recusal as a GitHub Action: the same deterministic kernel, gating merges instead of
# tool calls. CI is, by construction, not the session that did the work - which makes it
# the natural place for a recusal verdict: the builder cannot grade its own work, and a
# nonzero exit here blocks the job.
#
# Requires a Python >= 3.9 on PATH (add an actions/setup-python step before this one).
# Each input maps to one CLI command; give at least one, or the action refuses to pass
# vacuously (a gate that ran nothing must not read as green).
#
# - uses: actions/setup-python@v6
# with:
# python-version: "3.12"
# - uses: philpaz/recusal@v0.12.2
# with:
# findings: reports/findings.json # python -m recusal verdict (RETRY=1, FAIL=2)
# audit-log: reports/audit.jsonl # python -m recusal audit verify
# expect-head: "42:abc123..." # optional external anchor for the audit head
# doctor-dir: "." # python -m recusal doctor
name: "Recusal Gate"
description: "Deterministic PASS/RETRY/FAIL adjudication in CI: adjudicate evidence findings, verify a hash-chained audit log, health-check a scaffolded gate. Builders cannot grade their own work."
author: "Philip Paz"
branding:
icon: "shield"
color: "gray-dark"
inputs:
findings:
description: "Path to a findings JSON file to adjudicate (array of findings, or an object with a 'findings' array). PASS exits 0, RETRY 1, FAIL 2."
required: false
default: ""
lenient:
description: "Verdict only: set 'true' to accept findings that omit status/passed instead of failing closed."
required: false
default: "false"
audit-log:
description: "Path to a hash-chained JSONL audit log to verify (exit 0 intact, 1 broken, 2 missing/error)."
required: false
default: ""
expect-head:
description: "External anchor for the audit head, formatted COUNT:HASH; catches truncation, tail rewrites, and forged appends."
required: false
default: ""
doctor-dir:
description: "Project directory whose scaffolded Claude Code gate should be health-checked (exit 0 healthy, 1 degraded, 2 not installed)."
required: false
default: ""
version:
description: "Deliberate override: install this recusal version from PyPI instead of the source bundled with the selected action ref. Leave empty for the default provenance (action ref = implementation)."
required: false
default: ""
use-installed:
description: "Deliberate override: set 'true' to keep an already-importable recusal (e.g. a checkout installed with `pip install -e .`) instead of the action ref's bundled source. Names the provenance trade explicitly; default false."
required: false
default: "false"
runs:
using: "composite"
steps:
# Provenance: `uses: philpaz/recusal@<ref>` selects the implementation that runs.
# The default FORCE-installs the package bundled with the selected action ref
# ($GITHUB_ACTION_PATH is that ref's checkout) - never "latest from PyPI", and never
# a package that happened to be lying on the runner - so pinning the action pins the
# code and a rollback of the ref rolls back the gate. The two escape hatches are
# explicit inputs: `version:` (a named PyPI release) and `use-installed: "true"`
# (keep a deliberately preinstalled checkout, e.g. this repo's own dogfood job).
- name: Install recusal
shell: bash
env:
RECUSAL_VERSION: ${{ inputs.version }}
USE_INSTALLED: ${{ inputs['use-installed'] }}
run: |
if [ -n "$RECUSAL_VERSION" ]; then
python -m pip install --quiet --force-reinstall "recusal==$RECUSAL_VERSION"
elif [ "$USE_INSTALLED" = "true" ] && python -c "import recusal" 2>/dev/null; then
echo "use-installed: keeping $(python -m recusal --version)"
else
python -m pip install --quiet --force-reinstall --no-deps "$GITHUB_ACTION_PATH"
fi
- name: Adjudicate
shell: bash
# Inputs flow through env, never interpolated into the script body, so a
# crafted path cannot inject shell into this step.
env:
FINDINGS: ${{ inputs.findings }}
LENIENT: ${{ inputs.lenient }}
AUDIT_LOG: ${{ inputs['audit-log'] }}
EXPECT_HEAD: ${{ inputs['expect-head'] }}
DOCTOR_DIR: ${{ inputs['doctor-dir'] }}
run: |
# GitHub runs `shell: bash` with -e (fail-fast); that would abort this script at
# the FIRST failing gate, so later gates would never run and the highest exit
# code would never be aggregated. Every selected gate must run; the max wins.
set +e
set -u
ran=0
rc=0
bump() { ran=1; [ "$1" -gt "$rc" ] && rc=$1 || true; }
if [ -n "$DOCTOR_DIR" ]; then
echo "::group::recusal doctor"
python -m recusal doctor --dir "$DOCTOR_DIR"; bump $?
echo "::endgroup::"
fi
if [ -n "$AUDIT_LOG" ]; then
echo "::group::recusal audit verify"
if [ -n "$EXPECT_HEAD" ]; then
python -m recusal audit verify "$AUDIT_LOG" --expect-head "$EXPECT_HEAD"; bump $?
else
python -m recusal audit verify "$AUDIT_LOG"; bump $?
fi
echo "::endgroup::"
fi
if [ -n "$FINDINGS" ]; then
echo "::group::recusal verdict"
if [ "$LENIENT" = "true" ]; then
python -m recusal verdict "$FINDINGS" --lenient; bump $?
else
python -m recusal verdict "$FINDINGS"; bump $?
fi
echo "::endgroup::"
fi
if [ "$ran" = 0 ]; then
echo "::error::recusal gate ran nothing (no findings / audit-log / doctor-dir input) - refusing to pass vacuously"
exit 2
fi
if [ "$rc" != 0 ]; then
echo "::error::recusal gate refused (exit $rc)"
fi
exit "$rc"