Repository navigation
Expand file tree
/
Copy pathaction.yml
More file actions
373 lines (360 loc) · 17.6 KB
/
Copy pathaction.yml
File metadata and controls
373 lines (360 loc) · 17.6 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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
name: 'Discipline CI Gate'
description: 'Agent diff sentinel and repository hygiene gates for GitHub Actions, Gitea Actions, and Forgejo Actions'
author: 'orieg'
branding:
icon: 'shield'
color: 'gray-dark'
# Every input reaches the shell through `env:`, never through inline
# `${{ }}` interpolation: a PR body or a branch name is attacker-controlled
# text and must not be parsed as shell.
inputs:
config:
description: 'Path to discipline.toml. When the default discipline.toml is absent, built-in defaults apply (each gate at its built-in default enablement and severity); any other path that does not exist is an error (exit 2).'
required: false
default: 'discipline.toml'
suite:
description: 'Suite to run: all, agent-guard, hygiene, integrity, quality, verification, bench'
required: false
default: 'all'
base_ref:
description: 'Branch or commit the change is measured against. Default: PR base branch, else the pushed-from commit, else the default branch.'
required: false
default: ''
enable:
description: 'Gate ids to force on (comma or newline separated). See `discipline gates`.'
required: false
default: ''
disable:
description: 'Gate ids to force off (comma or newline separated), e.g. "time-estimates, pii".'
required: false
default: ''
config_override:
description: 'Inline TOML merged over discipline.toml: tables merge, lists append, scalars replace.'
required: false
default: ''
hostname_denylist:
description: 'Hostnames the pii gate must reject (comma or newline separated). Pass a secret; matches are never echoed.'
required: false
default: ''
fail_on_warnings:
description: 'Treat warnings as failures.'
required: false
default: 'false'
fail_on_overrides:
description: 'Treat the overrides a change writes as failures (requires human sign-off); inline markers already in the tree are not counted.'
required: false
default: 'false'
advisory:
description: 'Advisory mode: run all checks and emit reports, but exit code 0 even if violations occur.'
required: false
default: 'false'
comment:
description: "Post the report as one pull-request comment, edited on every run. Needs `pull-requests: write` (GitHub) or a token that can comment; a fork's read-only token is reported, not failed."
required: false
default: 'false'
token:
description: 'Forge token handed to discipline: read by the gates that ask the forge (issue-link verify_references, ratified-paths, review-threads, require_approval; GitHub GraphQL needs a token), and used to post the comment when `comment` is true.'
required: false
default: ${{ github.token }}
policy_from:
description: "Which side's discipline.toml judges the change: 'head' (the change's own copy) or 'base' (the base ref's, so a policy edit takes effect once merged; config-integrity still reports it)."
required: false
default: 'head'
actor:
description: 'Login judged against allowed_override_actors. Default: the pull request author (github.event.pull_request.user.login), which the server sets; otherwise github.actor or the forge equivalent. The triggering login is not used on a pull request, since whoever edits the description must not be able to authorize their own override.'
required: false
default: ${{ github.event.pull_request.user.login || github.actor }}
directive_sources:
description: 'Comma-separated list of allowed directive sources (pr-body, commits, merged-pr-body). merged-pr-body reads, on a push event, the body of the merged pull request each pushed commit arrived through.'
required: false
default: ''
pr_body:
description: 'PR description: carries override directives and is itself scanned by hygiene gates.'
required: false
default: ${{ github.event.pull_request.body }}
pr_title:
description: 'PR title: checked by hygiene gates (e.g. issue-link).'
required: false
default: ${{ github.event.pull_request.title }}
working_directory:
description: 'Directory of the repository to check.'
required: false
default: '.'
version:
description: 'Release to download (e.g. v0.1.0). Default: the tag this action was referenced by (`@vX.Y.Z`); for a major tag, a commit SHA or a branch, the release in the action''s own Cargo.toml; the latest release only when neither is available.'
required: false
default: ''
binary_path:
description: 'Use this discipline binary instead of downloading one (air-gapped Gitea/Forgejo runners, self-tests).'
required: false
default: ''
download_url:
description: 'Base URL of the release store, for mirrors.'
required: false
default: 'https://github.com/orieg/discipline/releases'
install_only:
description: 'Install discipline and put it on PATH for the steps that follow, without running a check. For `.github/workflows/copilot-setup-steps.yml`, so the hooks of Copilot cloud agent find the binary.'
required: false
default: 'false'
baseline_file:
description: 'Path to grandfathering baseline file (defaults to discipline-baseline.toml if present).'
required: false
default: ''
no_baseline:
description: 'Ignore grandfathering baseline even if present.'
required: false
default: 'false'
ci_context:
description: 'Rollup job only: `toJson(needs)` of the rollup job (inline JSON, or a path to a file holding it) for the ci-skip-set gate. Empty: the gate reports "not evaluated".'
required: false
default: ''
test_report:
description: 'Path to test report XML file relative to repo root (JUnit XML) for test-floor identity ratcheting.'
required: false
default: ''
test_base_report:
description: 'In-job base test report XML file for test-floor identity ratcheting in dual-report mode.'
required: false
default: ''
test_head_report:
description: 'In-job head test report XML file for test-floor identity ratcheting in dual-report mode.'
required: false
default: ''
outputs:
status:
description: 'pass, fail, or error'
value: ${{ steps.run.outputs.status }}
errors:
description: 'Number of blocking violations'
value: ${{ steps.run.outputs.errors }}
warnings:
description: 'Number of non-blocking violations'
value: ${{ steps.run.outputs.warnings }}
failed_gates:
description: 'Comma separated ids of the gates that reported a violation'
value: ${{ steps.run.outputs.failed_gates }}
overrides:
description: 'Number of applied override directives'
value: ${{ steps.run.outputs.overrides }}
baselined:
description: 'Number of grandfathered findings matching baseline (not blocking)'
value: ${{ steps.run.outputs.baselined }}
overridden_gates:
description: 'Comma separated ids of the gates that had an override applied'
value: ${{ steps.run.outputs.overridden_gates }}
passed_gates:
description: 'Number of passing gates'
value: ${{ steps.run.outputs.passed_gates }}
examined_items:
description: 'Total number of items examined across all enabled gates'
value: ${{ steps.run.outputs.examined_items }}
report:
description: 'Path of the JSON report'
value: ${{ steps.run.outputs.report }}
install_error:
description: 'Why installing the binary failed (empty on success)'
value: ${{ steps.install.outputs.install_error }}
bin:
description: 'Path to the installed discipline executable'
value: ${{ steps.install.outputs.bin }}
runs:
using: 'composite'
steps:
- name: Install discipline
id: install
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_BINARY_PATH: ${{ inputs.binary_path }}
INPUT_DOWNLOAD_URL: ${{ inputs.download_url }}
ACTION_REF: ${{ github.action_ref }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
fail() {
echo "::error title=discipline install::$*"
# Lets a caller assert on *why* the install failed.
echo "install_error=$*" >> "${GITHUB_OUTPUT}"
exit 1
}
if [ -n "${INPUT_BINARY_PATH}" ]; then
[ -x "${INPUT_BINARY_PATH}" ] || fail "binary_path '${INPUT_BINARY_PATH}' is not an executable file"
bin="$(cd "$(dirname "${INPUT_BINARY_PATH}")" && pwd)/$(basename "${INPUT_BINARY_PATH}")"
else
case "$(uname -m)" in
x86_64|amd64) arch="x86_64" ;;
aarch64|arm64) arch="aarch64" ;;
*) fail "unsupported architecture: $(uname -m)" ;;
esac
case "$(uname -s)" in
Linux) triple="${arch}-unknown-linux-musl" ;;
Darwin) triple="${arch}-apple-darwin" ;;
*) fail "unsupported OS: $(uname -s) (pass binary_path to use your own build)" ;;
esac
# version-resolution:begin (tests/test_action_version.rs runs these lines)
version="${INPUT_VERSION}"
if [ -z "${version}" ] && [[ "${ACTION_REF}" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)* ]]; then
version="${ACTION_REF}"
fi
# Any other ref (a major tag `@v0`, a commit SHA, a branch) runs the binary of the
# release the action's own Cargo.toml names, never `latest`: `latest` can move
# before the major tag does, and a SHA-pinned action must not run an unpinned binary.
if [ -z "${version}" ] && [ -f "${GITHUB_ACTION_PATH:-}/Cargo.toml" ]; then
pinned="$(sed -n 's/^version = "\(.*\)"/\1/p' "${GITHUB_ACTION_PATH}/Cargo.toml" | head -n 1)"
[ -n "${pinned}" ] && version="v${pinned}"
fi
# version-resolution:end
if [ -n "${version}" ]; then
base="${INPUT_DOWNLOAD_URL}/download/${version}"
else
base="${INPUT_DOWNLOAD_URL}/latest/download"
fi
tmp_root="${FORGEJO_TEMP:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}}"
dir="$(umask 077 && mktemp -d "${tmp_root%/}/discipline-${version:-latest}.XXXXXXXXXX")"
archive="discipline-${triple}.tar.gz"
fetch() {
if command -v curl >/dev/null 2>&1; then
case "$1" in
https://*)
curl --proto '=https' --tlsv1.2 --max-redirs 3 --fail --silent --show-error --location --retry 3 --output "$2" "$1"
;;
*)
curl --fail --silent --show-error --location --retry 3 --output "$2" "$1"
;;
esac
elif command -v wget >/dev/null 2>&1; then
wget --quiet --tries=3 --output-document="$2" "$1"
else
fail "neither curl nor wget is available"
fi
}
fetch "${base}/${archive}" "${dir}/${archive}" || fail "could not download ${base}/${archive}"
fetch "${base}/SHA256SUMS" "${dir}/SHA256SUMS" || fail "could not download ${base}/SHA256SUMS"
# No opt-out: an unverified binary deciding whether CI passes is
# worse than no gate.
expected="$(awk -v f="${archive}" '$2 == f || $2 == "*" f { print $1 }' "${dir}/SHA256SUMS")"
[ -n "${expected}" ] || fail "${archive} is not listed in SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "${dir}/${archive}" | awk '{ print $1 }')"
else
actual="$(shasum -a 256 "${dir}/${archive}" | awk '{ print $1 }')"
fi
[ "${expected}" = "${actual}" ] || fail "checksum mismatch for ${archive}: expected ${expected}, got ${actual}"
# provenance:begin (tests/test_action_version.rs runs these lines)
# SHA256SUMS comes from the same release as the archive, so replacing both
# passes the checksum. The build provenance attestation cannot be replaced
# that way: it must be signed by this repository's release workflow, for the
# requested tag. Checked on github.com with the default release store and
# `gh` on the runner; elsewhere the checksum is the check, and a notice says so.
if [ "${GITHUB_SERVER_URL:-}" = "https://github.com" ] \
&& [ "${INPUT_DOWNLOAD_URL}" = "https://github.com/orieg/discipline/releases" ] \
&& [ -n "${GH_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then
attest=(gh attestation verify "${dir}/${archive}" --repo orieg/discipline
--signer-workflow orieg/discipline/.github/workflows/release.yml)
if [ -n "${version}" ]; then
attest+=(--source-ref "refs/tags/${version}")
fi
"${attest[@]}" > /dev/null 2> "${dir}/attestation.err" \
|| fail "build provenance of ${archive} (${version:-latest}) did not verify: $(tail -n 1 "${dir}/attestation.err")"
else
echo "::notice title=discipline install::build provenance not verified (needs github.com, the default download_url and gh); checked against SHA256SUMS only"
fi
# provenance:end
tar --no-same-owner --no-same-permissions -xzf "${dir}/${archive}" -C "${dir}"
bin="${dir}/discipline"
[ -x "${bin}" ] || fail "archive did not contain an executable 'discipline'"
fi
"${bin}" --version
echo "bin=${bin}" >> "${GITHUB_OUTPUT}"
dirname "${bin}" >> "${GITHUB_PATH}"
- name: Resolve base ref
id: base
if: inputs.install_only != 'true'
shell: bash
working-directory: ${{ inputs.working_directory }}
env:
INPUT_BASE_REF: ${{ inputs.base_ref }}
PR_BASE_REF: ${{ github.base_ref }}
MERGE_GROUP_BASE_REF: ${{ github.event.merge_group.base_ref }}
PUSH_BEFORE: ${{ github.event.before }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
FORGEJO_BASE_REF: ${{ env.FORGEJO_BASE_REF }}
GITEA_BASE_REF: ${{ env.GITEA_BASE_REF }}
run: |
set -euo pipefail
base="${INPUT_BASE_REF:-${PR_BASE_REF:-${MERGE_GROUP_BASE_REF:-${FORGEJO_BASE_REF:-${GITEA_BASE_REF}}}}}"
if [ -z "${base}" ] && [ -n "${PUSH_BEFORE}" ] && ! [[ "${PUSH_BEFORE}" =~ ^0+$ ]]; then
base="${PUSH_BEFORE}"
fi
base="${base:-${DEFAULT_BRANCH:-main}}"
resolves() {
git rev-parse --verify --quiet "$1^{commit}" >/dev/null \
|| git rev-parse --verify --quiet "origin/$1^{commit}" >/dev/null
}
git_fetch=(git -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.sshCommand=false -c credential.helper=)
case "$(git remote get-url origin 2>/dev/null || true)" in
file://*|/*) ;;
*) git_fetch+=(-c protocol.file.allow=never) ;;
esac
# Best effort only. The binary is the arbiter: if the base still does
# not resolve it exits 2, it never falls back to an empty diff.
if ! resolves "${base}"; then
echo "base '${base}' is not available locally; fetching it"
"${git_fetch[@]}" fetch --no-tags origin "+refs/heads/${base}:refs/remotes/origin/${base}" \
|| "${git_fetch[@]}" fetch --no-tags origin "${base}" || true
fi
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "shallow clone; fetching history so a merge base exists"
"${git_fetch[@]}" fetch --no-tags --unshallow origin || true
fi
echo "ref=${base}" >> "${GITHUB_OUTPUT}"
- name: Run discipline gates
id: run
if: inputs.install_only != 'true'
shell: bash
working-directory: ${{ inputs.working_directory }}
env:
DISCIPLINE_BIN: ${{ steps.install.outputs.bin }}
DISCIPLINE_CONFIG: ${{ inputs.config }}
DISCIPLINE_BASE_REF: ${{ steps.base.outputs.ref }}
DISCIPLINE_ENABLE: ${{ inputs.enable }}
DISCIPLINE_DISABLE: ${{ inputs.disable }}
DISCIPLINE_CONFIG_OVERRIDE: ${{ inputs.config_override }}
DISCIPLINE_HOSTNAME_DENYLIST: ${{ inputs.hostname_denylist }}
DISCIPLINE_FAIL_ON_WARNINGS: ${{ inputs.fail_on_warnings == 'true' && 'true' || 'false' }}
DISCIPLINE_FAIL_ON_OVERRIDES: ${{ inputs.fail_on_overrides == 'true' && 'true' || 'false' }}
DISCIPLINE_ADVISORY: ${{ inputs.advisory == 'true' && 'true' || 'false' }}
DISCIPLINE_COMMENT: ${{ inputs.comment == 'true' && 'true' || 'false' }}
DISCIPLINE_FORGE_TOKEN: ${{ inputs.token }}
DISCIPLINE_POLICY_FROM: ${{ inputs.policy_from || 'head' }}
DISCIPLINE_ACTOR: ${{ inputs.actor || github.event.pull_request.user.login || github.actor }}
DISCIPLINE_DIRECTIVE_SOURCES: ${{ inputs.directive_sources }}
DISCIPLINE_BASELINE: ${{ inputs.baseline_file }}
DISCIPLINE_NO_BASELINE: ${{ inputs.no_baseline == 'true' && 'true' || 'false' }}
DISCIPLINE_CI_CONTEXT: ${{ inputs.ci_context }}
DISCIPLINE_TEST_REPORT: ${{ inputs.test_report }}
DISCIPLINE_TEST_BASE_REPORT: ${{ inputs.test_base_report }}
DISCIPLINE_TEST_HEAD_REPORT: ${{ inputs.test_head_report }}
INPUT_SUITE: ${{ inputs.suite }}
PR_BODY: ${{ inputs.pr_body }}
PR_TITLE: ${{ inputs.pr_title }}
run: |
set -euo pipefail
run_id="${GITHUB_RUN_ID:-${FORGEJO_RUN_ID:-0}}"
action_id="${GITHUB_ACTION:-${FORGEJO_ACTION:-step}}"
tmp_root="${FORGEJO_TEMP:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}}"
report_dir="$(umask 077 && mktemp -d "${tmp_root%/}/discipline-report-${run_id}-${action_id}.XXXXXXXXXX")"
report="${report_dir}/report.json"
echo "report=${report}" >> "${GITHUB_OUTPUT}"
set +e
"${DISCIPLINE_BIN}" check \
--suite "${INPUT_SUITE}" \
--format github-summary \
--json-out "${report}"
rc=$?
set -e
if [ "${rc}" -eq 2 ]; then
echo "status=error" >> "${GITHUB_OUTPUT}"
exit 2
fi
exit "${rc}"