diff --git a/.github/actions/ci-status/action.yml b/.github/actions/ci-status/action.yml index 2a767652..1f5b561a 100644 --- a/.github/actions/ci-status/action.yml +++ b/.github/actions/ci-status/action.yml @@ -1,14 +1,18 @@ name: ci-status description: >- Aggregate lane results into one pass/fail gate. Fails naming the first result - that does not pass; whether `skipped` passes is the caller's policy. + that does not pass; whether `skipped` passes is the caller's policy. Records + the verdict as a commit status so a contract-only pull-request event + (`edited`, `labeled`, `unlabeled`) can carry it forward without re-running the + lanes. inputs: results: description: >- Whitespace-separated job results, one per aggregated lane — the caller builds this from `needs..result`. Empty fails: a gate with nothing - to aggregate is a miswired `needs` list, not a pass. + to aggregate is a miswired `needs` list, not a pass. Ignored when + `contract-only` is true, where every lane is `skipped` by construction. required: true treat-skipped-as: description: >- @@ -17,6 +21,47 @@ inputs: — for example a runner selector that fell back and left downstream lanes skipped, which must go red rather than green. default: pass + contract-only: + description: >- + `true` when this run's lanes were gated off because only the pull-request + contract could have changed, so aggregation is skipped and the recorded + commit status decides instead. The default is the contract-only predicate + itself, so a caller passes nothing; it must stay identical to the + expression the caller's lane jobs are gated on, or the two disagree about + whether the lanes ran. + default: >- + ${{ github.event.pull_request.head.repo.full_name == github.repository && + (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || + (github.event.action == 'edited' && !github.event.changes.base)) }} + same-repo: + description: >- + `false` for a fork pull request, whose token is read-only on + `pull_request` whatever `permissions:` requests. A fork run aggregates and + reports the lanes verdict but records no commit status, because it cannot; + it therefore runs the full workflow on every event, as before. + default: >- + ${{ !github.event.pull_request || + github.event.pull_request.head.repo.full_name == github.repository }} + status-context: + description: >- + Commit-status context this action writes in full mode and reads in + carry-forward mode. Distinct from the `ci-status` check-run name on + purpose: a check run cannot say which event produced it, so only a status + only full runs write can stop a chain of contract-only runs from + self-certifying. + default: ci-lanes + token: + description: >- + Token used to write and read the commit status. The calling job needs + `statuses: write`; on a same-repository run the status write is + load-bearing and a run whose write is refused fails. + default: ${{ github.token }} + repository: + description: Target repository identity OWNER/REPO (defaults to this repo). + default: ${{ github.repository }} + sha: + description: Commit SHA the status is written to and read from. + default: ${{ github.event.pull_request.head.sha || github.sha }} runs: using: composite @@ -26,38 +71,10 @@ runs: env: RESULTS: ${{ inputs.results }} TREAT_SKIPPED_AS: ${{ inputs.treat-skipped-as }} - run: | - set -euo pipefail - # Reject an unrecognised policy rather than silently defaulting: a typo - # such as `Fail` would otherwise resolve to the laxer branch and quietly - # weaken the gate it was written to tighten. - case "$TREAT_SKIPPED_AS" in - pass|fail) ;; - *) echo "::error::treat-skipped-as must be 'pass' or 'fail', got: ${TREAT_SKIPPED_AS}"; exit 1 ;; - esac - # Unquoted expansion word-splits on all of IFS (space, tab, newline), so - # a YAML block scalar spanning lines is parsed in full. `read` would stop - # at the first newline and silently skip every later lane. - # shellcheck disable=SC2206 - results=($RESULTS) - # Checked after splitting: whitespace-only input yields no elements, and - # an empty loop would otherwise report success with nothing aggregated. - if [[ ${#results[@]} -eq 0 ]]; then - echo '::error::results is required.'; exit 1 - fi - for r in "${results[@]}"; do - case "$r" in - success) ;; - skipped) - if [[ "$TREAT_SKIPPED_AS" == fail ]]; then - echo "A lane did not pass (result: $r)."; exit 1 - fi - ;; - *) echo "A lane did not pass (result: $r)."; exit 1 ;; - esac - done - if [[ "$TREAT_SKIPPED_AS" == fail ]]; then - echo "All lanes passed." - else - echo "All lanes passed or were skipped." - fi + CONTRACT_ONLY: ${{ inputs.contract-only }} + SAME_REPO: ${{ inputs.same-repo }} + STATUS_CONTEXT: ${{ inputs.status-context }} + GH_TOKEN: ${{ inputs.token }} + REPOSITORY: ${{ inputs.repository }} + SHA: ${{ inputs.sha }} + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/.github/actions/ci-status/run.sh b/.github/actions/ci-status/run.sh new file mode 100755 index 00000000..c748da3f --- /dev/null +++ b/.github/actions/ci-status/run.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Aggregate lane results into the single required gate check, and carry that +# verdict forward to contract-only pull-request events. +# +# Full mode (`contract-only` false): aggregate `results` exactly as before, then +# record the verdict as a commit status on the head SHA under `status-context`. +# That status is the only signal a contract-only run can trust, because a check +# run cannot say which event produced it — a chain of contract-only runs could +# otherwise self-certify. +# +# Carry-forward mode (`contract-only` true): the lanes were gated off by +# construction, so aggregation is skipped and the combined commit status for +# `status-context` on the same SHA decides. The combined-status endpoint returns +# the latest state per context, so a later full-run failure on the same SHA +# overrides an earlier success. +# +# `same-repo` false is a fork pull request. Its token is read-only on +# `pull_request` whatever `permissions:` requests, so it cannot record lane +# state; it aggregates, reports the lanes verdict, and writes nothing. The +# caller's contract-only predicate is false for a fork on every event, so a fork +# always runs the full workflow and never needs a carried verdict. +set -euo pipefail + +: "${TREAT_SKIPPED_AS:?TREAT_SKIPPED_AS is required}" + +RESULTS="${RESULTS:-}" +CONTRACT_ONLY="${CONTRACT_ONLY:-}" +SAME_REPO="${SAME_REPO:-}" +STATUS_CONTEXT="${STATUS_CONTEXT:-ci-lanes}" +REPOSITORY="${REPOSITORY:-}" +SHA="${SHA:-}" +# Retries are 1s, 2s, 4s in CI; the harness sets 0 so a nine-second sleep does +# not ride on every refused-write case. +STATUS_RETRY_BASE_DELAY="${STATUS_RETRY_BASE_DELAY:-1}" +# The login a `GITHUB_TOKEN`-authored commit status carries. Overridable only so +# the harness can exercise the check; a caller minting statuses with a GitHub +# App token would need its own value and takes on proving that identity itself. +STATUS_CREATOR="${STATUS_CREATOR:-github-actions[bot]}" + +# Reject an unrecognised policy rather than silently defaulting: a typo such as +# `Fail` would otherwise resolve to the laxer branch and quietly weaken the gate +# it was written to tighten. +case "$TREAT_SKIPPED_AS" in +pass | fail) ;; +*) + echo "::error::treat-skipped-as must be 'pass' or 'fail', got: ${TREAT_SKIPPED_AS}" + exit 1 + ;; +esac + +scratch="$(mktemp -d)" +trap 'rm -rf -- "$scratch"' EXIT +gh_stdout="$scratch/gh-stdout" +gh_stderr="$scratch/gh-stderr" + +GH_HTTP_STATUS="" +gh_api() { + local method="$1" path="$2" + shift 2 + local status=0 + : >"$gh_stdout" + : >"$gh_stderr" + GH_HTTP_STATUS="" + set +e + gh api -X "$method" "$path" "$@" >"$gh_stdout" 2>"$gh_stderr" + status=$? + set -e + if [[ "$status" -ne 0 ]]; then + GH_HTTP_STATUS="$(sed -n 's/.*(HTTP \([0-9][0-9]*\)).*/\1/p' "$gh_stderr" | head -n1)" + fi + return "$status" +} + +# A GitHub expression renders as the literal `true`/`false`. Empty means the +# caller left the input unset, which takes the safer reading of each flag: +# aggregate rather than carry forward, and record rather than silently skip. +# Anything else is a miswired caller and fails rather than resolving to a +# branch it did not ask for. +read_boolean() { + local name="$1" value="$2" fallback="$3" + case "$value" in + true) echo true ;; + false) echo false ;; + '') echo "$fallback" ;; + *) + echo "::error::${name} must be 'true' or 'false', got: ${value}" >&2 + return 1 + ;; + esac +} + +# shellcheck disable=SC2310 # read_boolean reports a bad value through its status; the caller exits on it. +if ! contract_only="$(read_boolean contract-only "$CONTRACT_ONLY" false)"; then + exit 1 +fi +# shellcheck disable=SC2310 # read_boolean reports a bad value through its status; the caller exits on it. +if ! same_repo="$(read_boolean same-repo "$SAME_REPO" true)"; then + exit 1 +fi + +# GitHub's documented escaping for workflow-command data, so a value echoed back +# in an annotation cannot close it and inject a second command. `%` first, or the +# escapes introduced by the others get double-escaped. +escape_annotation() { + local text="$1" + text="${text//'%'/%25}" + text="${text//$'\r'/%0D}" + text="${text//$'\n'/%0A}" + printf '%s' "$text" +} + +# The two values interpolated into a `gh api` path. Validate before the first +# call rather than trusting the caller's expression: a `repository` or `sha` +# carrying `../` or a query separator would address a different resource than +# the one named. `status-context` is deliberately NOT validated — a context like +# `CI Lanes` is legal, and it only ever travels through `jq --arg` into a +# comparison or a JSON body, never into a path. +require_pattern() { + local name="$1" value="$2" pattern="$3" shape="$4" + if [[ ! "$value" =~ $pattern ]]; then + echo "::error::${name} must be ${shape}, got: $(escape_annotation "$value")" + exit 1 + fi +} + +require_pattern repository "$REPOSITORY" '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' 'OWNER/REPO' +require_pattern sha "$SHA" '^[0-9a-f]{40}$' 'a full 40-character lowercase commit SHA' + +# --------------------------------------------------------------------------- +# Carry-forward mode. Branched on first, before `same-repo`: the caller's +# predicate makes `contract-only` false for every fork event, so the +# true/false combination is unreachable from the defaults — but a caller that +# overrides `contract-only` owns the claim that the lanes did not run, and the +# runner honours it rather than second-guessing it into an aggregation over +# results that are all `skipped`. +# --------------------------------------------------------------------------- +if [[ "$contract_only" == true ]]; then + echo "Contract-only event: reading the ${STATUS_CONTEXT} status on ${SHA} instead of aggregating skipped lanes." + # The LIST endpoint, not the combined one: `commits//status` collapses to + # one entry per context and exposes no author, so any collaborator with write + # could POST a forged `ci-lanes=success` and then flip a label to turn the + # sole required check green over failing lanes. The list carries `.creator`, + # newest first, so the gate can insist the newest entry for this context was + # written by the Actions bot and ignore anything a human pushed. + # shellcheck disable=SC2310 # gh_api handles its own errexit; the caller classifies the status. + if ! gh_api GET "repos/${REPOSITORY}/commits/${SHA}/statuses?per_page=100" --paginate; then + cat "$gh_stderr" >&2 + echo "::error::no successful ${STATUS_CONTEXT} status on ${SHA}; re-run the full workflow" + exit 1 + fi + # Highest id wins, not first element: status ids are monotonic, so `max_by` + # states the intent directly instead of depending on the documented + # newest-first ordering. A later bot failure on the same SHA therefore + # overrides an earlier bot success, and a later forged success by a user + # account is skipped rather than shadowing the bot's real verdict. + state="$(jq -r --arg context "$STATUS_CONTEXT" --arg creator "$STATUS_CREATOR" \ + '[ .[] | select(.context == $context and (.creator.login // "") == $creator and (.creator.type // "") == "Bot") ] | (max_by(.id).state // "")' \ + <"$gh_stdout")" + if [[ "$state" == success ]]; then + echo "Carried forward: ${STATUS_CONTEXT} is success on ${SHA} (recorded by ${STATUS_CREATOR})." + exit 0 + fi + echo "::error::no successful ${STATUS_CONTEXT} status on ${SHA}; re-run the full workflow" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Full mode — aggregate exactly as before. +# --------------------------------------------------------------------------- +# Unquoted expansion word-splits on all of IFS (space, tab, newline), so a YAML +# block scalar spanning lines is parsed in full. `read` would stop at the first +# newline and silently skip every later lane. +# shellcheck disable=SC2206 +results=($RESULTS) +# Checked after splitting: whitespace-only input yields no elements, and an +# empty loop would otherwise report success with nothing aggregated. +if [[ ${#results[@]} -eq 0 ]]; then + echo '::error::results is required.' + exit 1 +fi + +lanes_state=success +lanes_description="" +lane_number=0 +for r in "${results[@]}"; do + lane_number=$((lane_number + 1)) + case "$r" in + success) ;; + skipped) + if [[ "$TREAT_SKIPPED_AS" == fail ]]; then + lanes_state=failure + fi + ;; + *) lanes_state=failure ;; + esac + if [[ "$lanes_state" == failure ]]; then + echo "A lane did not pass (result: $r)." + # `results` carries no lane names — the caller builds it from + # `needs..result` — so the description names the failing lane by its + # position in that list, which is the most the input allows. + lanes_description="lane ${lane_number} of ${#results[@]} did not pass (result: ${r})" + break + fi +done + +if [[ "$lanes_state" == success ]]; then + if [[ "$TREAT_SKIPPED_AS" == fail ]]; then + lanes_description='All lanes passed.' + else + lanes_description='All lanes passed or were skipped.' + fi + echo "$lanes_description" +fi + +# --------------------------------------------------------------------------- +# Record the verdict as a commit status. Load-bearing on a same-repository run, +# not best-effort: the carry-forward branch reads nothing else, so a silently +# missing status turns every later contract-only run red with no way to tell a +# refused write from a genuinely failing lane. +# +# A fork pull request is the one exception: its token cannot write a status at +# all, and nothing will ever read one for it, so the run reports the lanes +# verdict and stops. +# --------------------------------------------------------------------------- +if [[ "$same_repo" != true ]]; then + echo "::notice::fork pull request: lane state is not recorded; every event runs the full workflow" + if [[ "$lanes_state" == failure ]]; then + exit 1 + fi + exit 0 +fi + +target_url="${GITHUB_SERVER_URL:-https://github.com}/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID:-0}" +jq -n \ + --arg state "$lanes_state" \ + --arg context "$STATUS_CONTEXT" \ + --arg description "$lanes_description" \ + --arg target_url "$target_url" \ + '{state: $state, context: $context, description: $description, target_url: $target_url}' \ + >"$scratch/status-payload.json" + +status_written=false +for attempt in 1 2 3 4; do + # shellcheck disable=SC2310 # gh_api handles its own errexit; the retry loop classifies the status. + if gh_api POST "repos/${REPOSITORY}/statuses/${SHA}" --input "$scratch/status-payload.json"; then + status_written=true + break + fi + if [[ "$attempt" -lt 4 ]]; then + delay=$((STATUS_RETRY_BASE_DELAY * (1 << (attempt - 1)))) + echo "::warning::could not record ${STATUS_CONTEXT} on ${SHA} (HTTP ${GH_HTTP_STATUS:-unknown}); retrying in ${delay}s" + sleep "$delay" + fi +done + +if [[ "$status_written" != true ]]; then + cat "$gh_stderr" >&2 + echo "::error::could not record ${STATUS_CONTEXT} on ${SHA} (${GH_HTTP_STATUS:-unknown}); the ci-status job needs statuses: write" + exit 1 +fi + +echo "Recorded ${STATUS_CONTEXT}=${lanes_state} on ${SHA}." + +if [[ "$lanes_state" == failure ]]; then + exit 1 +fi diff --git a/.github/actions/ci-status/run.test.sh b/.github/actions/ci-status/run.test.sh new file mode 100755 index 00000000..36eab97f --- /dev/null +++ b/.github/actions/ci-status/run.test.sh @@ -0,0 +1,482 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Fixture harness for the ci-status runner: lane aggregation, the ci-lanes +# commit-status write, and the carry-forward branch that reads it back. +# +# Every case names, in a comment, the check it would pass without. A case that +# still passes with its check removed proves nothing and does not belong here. +set -euo pipefail + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +temporary_directory="$(mktemp -d)" +trap 'rm -rf -- "$temporary_directory"' EXIT + +failures=0 +log_file="$temporary_directory/log" +gh_log="$temporary_directory/gh-calls.log" +fixtures="$temporary_directory/fixtures" +calls="$temporary_directory/calls" +shim_directory="$temporary_directory/bin" +mkdir -p "$fixtures" "$calls" "$shim_directory" + +sha=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef +repository=melodic-software/ci-workflows + +# A `gh` shim first on PATH: it serves fixture JSON keyed by method plus API +# path, records every call so the harness can assert on writes that did and did +# not happen, and can fail a keyed call a fixed number of times before +# succeeding (the retry case). +cat >"$shim_directory/gh" <<'SHIM' +#!/usr/bin/env bash +set -uo pipefail + +printf '%s\n' "$*" >>"$GH_LOG" + +method=GET +path="" +input="" +seen_api=false +while [[ $# -gt 0 ]]; do + case "$1" in + api) + seen_api=true + shift + ;; + -X) + method="$2" + shift 2 + ;; + --input) + input="$2" + shift 2 + ;; + --paginate | --silent) + shift + ;; + -*) + shift + ;; + *) + if [[ "$seen_api" == true && -z "$path" ]]; then + path="$1" + fi + shift + ;; + esac +done + +# Fixture keys ignore the query string, so `?per_page=100` does not need a +# fixture of its own. +path="${path%%\?*}" +key="${method}_${path//\//_}" +if [[ -n "$input" && -f "$input" ]]; then + cp -- "$input" "$GH_CALLS/${key}.input.json" +elif [[ "$input" == "-" ]]; then + cat >"$GH_CALLS/${key}.input.json" +fi + +fail_times="$GH_FIXTURES/${key}.fail-times" +if [[ -f "$fail_times" ]]; then + remaining="$(cat "$fail_times")" + if [[ "$remaining" -gt 0 ]]; then + printf '%s\n' "$((remaining - 1))" >"$fail_times" + echo "gh: Internal Server Error (HTTP 500)" >&2 + exit 1 + fi +fi + +if [[ -f "$GH_FIXTURES/${key}.err" ]]; then + cat "$GH_FIXTURES/${key}.err" >&2 + exit 1 +fi + +if [[ -f "$GH_FIXTURES/${key}.json" ]]; then + cat "$GH_FIXTURES/${key}.json" + exit 0 +fi + +echo '{}' +SHIM +chmod +x "$shim_directory/gh" + +# run_case [same-repo] [NAME=VALUE ...] +# Trailing NAME=VALUE pairs are appended to the `env` invocation, so they +# override the defaults set below (later assignments win). +run_case() { + local expected_status="$1" results="$2" treat_skipped_as="$3" contract_only="$4" + local same_repo="${5-true}" + shift $(($# > 5 ? 5 : $#)) + local actual_status + : >"$gh_log" + rm -rf -- "$calls" + mkdir -p "$calls" + set +e + env \ + PATH="$shim_directory:$PATH" \ + GH_LOG="$gh_log" \ + GH_FIXTURES="$fixtures" \ + GH_CALLS="$calls" \ + GH_TOKEN=fixture-token \ + RESULTS="$results" \ + TREAT_SKIPPED_AS="$treat_skipped_as" \ + CONTRACT_ONLY="$contract_only" \ + SAME_REPO="$same_repo" \ + STATUS_CONTEXT=ci-lanes \ + REPOSITORY="$repository" \ + SHA="$sha" \ + STATUS_RETRY_BASE_DELAY=0 \ + GITHUB_SERVER_URL=https://github.com \ + GITHUB_RUN_ID=4242 \ + "$@" \ + bash "$script_directory/run.sh" >"$log_file" 2>&1 + actual_status=$? + set -e + if [[ "$actual_status" -ne "$expected_status" ]]; then + echo "FAIL: expected exit $expected_status, got $actual_status" + cat "$log_file" + # Record and continue: the suite accumulates failures and reports them all. + failures=$((failures + 1)) + fi +} + +expect_log() { + local expected="$1" + if ! grep -qF -- "$expected" "$log_file"; then + echo "FAIL: expected log to contain '$expected', got:" + cat "$log_file" + failures=$((failures + 1)) + fi +} + +expect_no_log() { + local unexpected="$1" + if grep -qF -- "$unexpected" "$log_file"; then + echo "FAIL: expected log NOT to contain '$unexpected', got:" + cat "$log_file" + failures=$((failures + 1)) + fi +} + +expect_gh_call() { + local expected="$1" + if ! grep -qF -- "$expected" "$gh_log"; then + echo "FAIL: expected a gh call matching '$expected', got:" + cat "$gh_log" + failures=$((failures + 1)) + fi +} + +expect_no_gh_call() { + local unexpected="$1" + if grep -qF -- "$unexpected" "$gh_log"; then + echo "FAIL: expected NO gh call matching '$unexpected', got:" + cat "$gh_log" + failures=$((failures + 1)) + fi +} + +# The shim logs `$*`, which never contains the literal `gh api` — it starts at +# `api -X GET …` — so asserting on that string could never fire. Assert the log +# is empty instead. +expect_no_gh_calls_at_all() { + if [[ -s "$gh_log" ]]; then + echo 'FAIL: expected NO gh calls at all, got:' + cat "$gh_log" + failures=$((failures + 1)) + fi +} + +expect_status_payload() { + local expected="$1" + local payload="$calls/POST_repos_melodic-software_ci-workflows_statuses_${sha}.input.json" + if [[ ! -f "$payload" ]]; then + echo "FAIL: expected a recorded status payload, none written" + cat "$gh_log" + failures=$((failures + 1)) + return + fi + if ! grep -qF -- "$expected" "$payload"; then + echo "FAIL: expected status payload to contain '$expected', got:" + cat "$payload" + failures=$((failures + 1)) + fi +} + +# status_list +# The LIST endpoint, newest entry first, as GitHub returns it. +status_list() { + printf '%s' "$1" >"$fixtures/GET_repos_melodic-software_ci-workflows_commits_${sha}_statuses.json" +} + +# bot_status / user_status +# The id is what `max_by(.id)` orders on; a higher id is a newer status. +bot_status() { + printf '{"id":%s,"context":"ci-lanes","state":"%s","creator":{"login":"github-actions[bot]","type":"Bot"}}' "$1" "$2" +} + +user_status() { + printf '{"id":%s,"context":"ci-lanes","state":"%s","creator":{"login":"a-collaborator","type":"User"}}' "$1" "$2" +} + +# --- full mode ------------------------------------------------------------- + +# Without the lane-aggregation loop this passes anyway; without the status +# write it fails on the missing payload assertion. +echo 'case: full mode aggregates green lanes and records ci-lanes success' +run_case 0 'success success success' pass '' +expect_log 'All lanes passed or were skipped.' +expect_gh_call "POST repos/${repository}/statuses/${sha}" +expect_status_payload '"state": "success"' + +# Without the failing-lane branch the run exits 0 and records success. +echo 'case: full mode records ci-lanes failure naming the failing lane position' +run_case 1 'success success failure success' pass '' +expect_log 'A lane did not pass (result: failure).' +expect_status_payload '"state": "failure"' +expect_status_payload 'lane 3 of 4 did not pass (result: failure)' + +# Without the load-bearing status write (best-effort instead) this exits 0. +echo 'case: full mode fails the run when the status write is refused after retries' +printf '%s\n' 99 >"$fixtures/POST_repos_melodic-software_ci-workflows_statuses_${sha}.fail-times" +run_case 1 'success success' pass '' +expect_log 'All lanes passed or were skipped.' +expect_log "::error::could not record ci-lanes on ${sha} (500); the ci-status job needs statuses: write" +rm -f -- "$fixtures/POST_repos_melodic-software_ci-workflows_statuses_${sha}.fail-times" + +# Without the retry loop the first 500 fails the run. +echo 'case: full mode passes when the status write succeeds on the second attempt' +printf '%s\n' 1 >"$fixtures/POST_repos_melodic-software_ci-workflows_statuses_${sha}.fail-times" +run_case 0 'success success' pass '' +expect_log 'retrying in 0s' +expect_log "Recorded ci-lanes=success on ${sha}." +rm -f -- "$fixtures/POST_repos_melodic-software_ci-workflows_statuses_${sha}.fail-times" + +# Without the treat-skipped-as branch a skipped lane fails here. +echo 'case: treat-skipped-as pass lets a skipped lane through' +run_case 0 'success skipped success' pass '' +expect_log 'All lanes passed or were skipped.' +expect_status_payload '"state": "success"' + +# Without the treat-skipped-as branch a skipped lane passes here. +echo 'case: treat-skipped-as fail rejects a skipped lane' +run_case 1 'success skipped success' fail '' +expect_log 'A lane did not pass (result: skipped).' +expect_status_payload '"state": "failure"' + +# Without the policy validation an unrecognised value silently takes the laxer +# branch and this exits 0. +echo 'case: an unrecognised treat-skipped-as value is rejected' +run_case 1 'success' Fail '' +expect_log "::error::treat-skipped-as must be 'pass' or 'fail', got: Fail" +expect_no_gh_call 'statuses/' + +# Without the post-split emptiness check an empty results string aggregates +# nothing and reports success. +echo 'case: empty results fails closed' +run_case 1 ' ' pass '' +expect_log '::error::results is required.' + +# Without the contract-only branch test this would read a status instead of +# aggregating, and no fixture status exists for it. This is also the +# `edited`-with-`changes.base` shape: the caller's predicate is false, so a base +# change runs the full workflow and records a fresh verdict. +echo 'case: contract-only false aggregates and records normally' +run_case 1 'success failure' pass false +expect_log 'A lane did not pass (result: failure).' +expect_status_payload '"state": "failure"' +expect_no_gh_call "commits/${sha}/status" + +# Without the empty-input fallback a push run, where the caller's expression +# renders empty, would take neither branch cleanly. +echo 'case: an empty contract-only input (push) aggregates normally' +run_case 0 'success success' pass '' +expect_log 'All lanes passed or were skipped.' +expect_no_gh_call "commits/${sha}/status" + +# Without the boolean validation a typo resolves to a branch the caller did not +# ask for — the same failure mode treat-skipped-as validation exists to prevent. +echo 'case: an unrecognised contract-only value is rejected' +run_case 1 'success' pass True +expect_log "::error::contract-only must be 'true' or 'false', got: True" +expect_no_gh_call 'statuses/' + +echo 'case: an unrecognised same-repo value is rejected' +run_case 1 'success' pass false yes +expect_log "::error::same-repo must be 'true' or 'false', got: yes" +expect_no_gh_call 'statuses/' + +# --- fork pull requests ---------------------------------------------------- + +# Without the same-repo branch the write is attempted, the fork's read-only +# token refuses it, and the load-bearing failure turns every fork PR red. +echo 'case: same-repo false skips the status write and passes on the lanes verdict' +run_case 0 'success success' pass false false +expect_log 'All lanes passed or were skipped.' +expect_log '::notice::fork pull request: lane state is not recorded; every event runs the full workflow' +expect_no_gh_call 'statuses/' + +# Without the lanes verdict surviving the fork branch, a fork PR would pass +# whatever its lanes did. +echo 'case: same-repo false still fails on a failing lane' +run_case 1 'success failure' pass false false +expect_log 'A lane did not pass (result: failure).' +expect_no_gh_call 'statuses/' + +# Unreachable from the shipped defaults (the predicate is false for every fork +# event), but a caller that overrides contract-only owns the claim that the +# lanes did not run; branching on contract-only FIRST honours it instead of +# aggregating over results that are all `skipped`. +echo 'case: contract-only true with same-repo false is still carry-forward' +status_list "[$(bot_status 100 success)]" +run_case 0 'skipped skipped' fail true false +expect_log "Carried forward: ci-lanes is success on ${sha}" +expect_no_gh_call 'statuses/' + +# --- carry-forward mode ---------------------------------------------------- + +# Without the carry-forward branch, `skipped skipped` under treat-skipped-as +# fail would go red. +echo 'case: carry-forward passes on a recorded ci-lanes success without aggregating' +status_list "[$(bot_status 100 success)]" +run_case 0 'skipped skipped' fail true +expect_log "Carried forward: ci-lanes is success on ${sha}" +expect_gh_call "commits/${sha}/statuses?per_page=100" +expect_no_gh_call "POST repos/${repository}/statuses/${sha}" +expect_no_log 'All lanes passed' + +# Without reading the per-context state, any 200 response would pass. +echo 'case: carry-forward fails on a recorded ci-lanes failure' +status_list "[$(bot_status 100 failure)]" +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without the explicit `== success` test, a pending status would ride through. +echo 'case: carry-forward fails on a pending ci-lanes status' +status_list "[$(bot_status 100 pending)]" +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without the context filter, another context's success would satisfy the gate. +echo 'case: carry-forward fails when no entry carries the ci-lanes context' +status_list '[{"context":"other-lane","state":"success","creator":{"login":"github-actions[bot]","type":"Bot"}}]' +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without the context filter, the FIRST entry (a failure under another context) +# would decide. +echo 'case: carry-forward selects the ci-lanes entry regardless of its position' +status_list "[{\"context\":\"other-lane\",\"state\":\"failure\",\"creator\":{\"login\":\"github-actions[bot]\",\"type\":\"Bot\"}},$(bot_status 100 success)]" +run_case 0 'skipped skipped' pass true +expect_log "Carried forward: ci-lanes is success on ${sha}" + +# Without the API-failure branch a 404 would be read as an empty state and the +# error message would be the same, but the run must still fail rather than +# aborting under errexit inside the command substitution. +echo 'case: carry-forward fails closed when the status list cannot be read' +rm -f -- "$fixtures/GET_repos_melodic-software_ci-workflows_commits_${sha}_statuses.json" +printf '%s\n' 'gh: Not Found (HTTP 404)' >"$fixtures/GET_repos_melodic-software_ci-workflows_commits_${sha}_statuses.err" +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" +rm -f -- "$fixtures/GET_repos_melodic-software_ci-workflows_commits_${sha}_statuses.err" + +# --- carry-forward: forged statuses ---------------------------------------- +# +# Any collaborator with write can POST a commit status. Without the creator +# filter, one forged `ci-lanes=success` plus a label flip turns the sole +# required check green over failing lanes. + +# Without the creator filter the newest entry is the user's success and passes. +echo 'case: a forged success by a user account does not satisfy the carry-forward' +status_list "[$(user_status 200 success),$(bot_status 100 failure)]" +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without newest-first selection an older user failure would shadow the bot's +# real success. +echo 'case: a bot success newer than a user failure passes' +status_list "[$(bot_status 200 success),$(user_status 300 failure)]" +run_case 0 'skipped skipped' pass true +expect_log "Carried forward: ci-lanes is success on ${sha}" + +# Without first-match-wins a later bot failure would be ignored in favour of the +# earlier success — a re-run that went red could then be carried forward green. +echo 'case: a bot failure newer than a bot success fails' +status_list "[$(bot_status 200 failure),$(bot_status 100 success)]" +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without the empty-list guard an absent status would read as an empty state. +echo 'case: an empty status list fails the carry-forward' +status_list '[]' +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without the creator filter, a context that ONLY a user ever wrote satisfies +# the gate — the plant-then-label attack in its simplest form. +echo 'case: the ci-lanes context present only from a user account fails' +status_list "[$(user_status 100 success)]" +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without the Bot type check, an account merely NAMED like the bot passes. +echo 'case: a user account impersonating the bot login fails' +status_list '[{"context":"ci-lanes","state":"success","creator":{"login":"github-actions[bot]","type":"User"}}]' +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +# Without `max_by(.id)` the selection depends on the array order the API +# happens to return; an oldest-first list would then hand back the stale +# success and carry a superseded verdict forward. +echo 'case: an oldest-first status list still selects the newest bot entry' +status_list '[{"id":10,"context":"ci-lanes","state":"success","creator":{"login":"github-actions[bot]","type":"Bot"}},{"id":20,"context":"ci-lanes","state":"failure","creator":{"login":"github-actions[bot]","type":"Bot"}}]' +run_case 1 'skipped skipped' pass true +expect_log "::error::no successful ci-lanes status on ${sha}; re-run the full workflow" + +echo 'case: an oldest-first status list still carries a newer bot success forward' +status_list '[{"id":10,"context":"ci-lanes","state":"failure","creator":{"login":"github-actions[bot]","type":"Bot"}},{"id":20,"context":"ci-lanes","state":"success","creator":{"login":"github-actions[bot]","type":"Bot"}}]' +run_case 0 'skipped skipped' pass true +expect_log "Carried forward: ci-lanes is success on ${sha}" + +# --- input validation ------------------------------------------------------ +# +# Every one of these values is interpolated into a `gh api` path. + +echo 'case: a malformed repository is rejected before any API call' +run_case 1 'success' pass false true REPOSITORY='melodic-software/ci-workflows/../other' +expect_log '::error::repository must be OWNER/REPO' +expect_no_gh_calls_at_all + +echo 'case: a malformed sha is rejected before any API call' +run_case 1 'success' pass false true SHA='HEAD' +expect_log '::error::sha must be a full 40-character lowercase commit SHA' +expect_no_gh_calls_at_all + +# `status-context` is deliberately NOT validated: a context carrying a space is +# legal and never reaches a path. Without that decision this run goes red. +echo 'case: a status-context carrying a space is accepted and used verbatim' +run_case 0 'success success' pass false true STATUS_CONTEXT='CI Lanes' +expect_log 'Recorded CI Lanes=success' +expect_status_payload '"context": "CI Lanes"' + +# --- metadata contract ----------------------------------------------------- + +echo 'case: action.yml still wires every input this harness exercises' +action_metadata="$script_directory/action.yml" +for input_name in results treat-skipped-as contract-only same-repo status-context token repository sha; do + if ! grep -qE "^ ${input_name}:" "$action_metadata"; then + echo "FAIL: action.yml declares no '${input_name}' input" + failures=$((failures + 1)) + fi +done +for environment_name in RESULTS TREAT_SKIPPED_AS CONTRACT_ONLY SAME_REPO STATUS_CONTEXT GH_TOKEN REPOSITORY SHA; do + if ! grep -qF " ${environment_name}: " "$action_metadata"; then + echo "FAIL: action.yml does not pass '${environment_name}' to run.sh" + failures=$((failures + 1)) + fi +done + +if [[ "$failures" -gt 0 ]]; then + echo "$failures test(s) failed." + exit 1 +fi +echo 'All ci-status tests passed.' diff --git a/.github/actions/pr-contract/README.md b/.github/actions/pr-contract/README.md new file mode 100644 index 00000000..40176ae2 --- /dev/null +++ b/.github/actions/pr-contract/README.md @@ -0,0 +1,142 @@ +# pr-contract + +One composite step carrying the whole pull-request contract, so a consumer needs +one required status check (`ci-status`) instead of four. + +| Check | Outcome | +|---|---| +| Conventional Commits title | Fails the step. | +| `do-not-merge` label present | Fails the step. | +| Issue linkage (closing keyword plus the four contract sections) | Advisory by default: a warning, one upserted marker comment, and a label. `linkage-mode: enforce` fails the step instead. | + +The three checks come from the `semantic-pr`, `do-not-merge-gate` and +`pr-issue-linkage` reusable workflows; the semantics are ported, not redesigned. +Those reusables stay in place until the callers are retired. + +## Why one step + +A title edit, a label change, or a body edit changes the answer to a required +check without changing a commit. Splitting those answers across three +`pull_request_target` workflows meant three checks, three runner allocations, +and three required contexts per pull request. Folding them into the `ci-status` +job means the contract re-evaluates on `edited`, `labeled` and `unlabeled` while +every file-lint lane stays gated off. + +## Inputs + +Every input's meaning and default is documented inline in +[`action.yml`](action.yml). The two worth calling out here: + +- `types` defaults to the twelve types + `components/pr-convention-policy/policy.json` declares in + `melodic-software/standards`: the eleven Conventional Commits defaults plus + `security`. The `semantic-pr` reusable's action default is the eleven, so a + `security:` title that policy allows fails that older gate and passes this one. +- `exempt-authors` is exact-login equality, never a `*[bot]` pattern, so an + unknown future bot is not silently skipped. The default empty string exempts + no one. + +## Outputs + +`title`, `do-not-merge`, and `linkage`. Every check runs before the step exits, +so all three are always set: `pass`, `fail`, `skipped` (no pull request in this +event), and additionally `exempt` for `linkage` when the author is exempt. + +## Permissions + +The calling job needs `pull-requests: write` for the advisory comment and label. +Every write is best-effort: a refused or missing write prints a `::notice::` and +leaves the exit code alone, so a read-only token degrades the composite to +reporting rather than breaking the gate. + +**`token` must be `GITHUB_TOKEN` or a GitHub App token.** The advisory comment is +upserted by finding a previous comment whose author is a `Bot`; a classic +personal access token comments as a `User`, so the upsert would never find its +own comment and would post a new one on every failing run. + +## Behaviour worth knowing + +- The pull request is read once from `repos///pulls/`, not + from the event payload, so `labeled` and `edited` runs see current state and a + suppressed follow-up event cannot leave a stale answer green. +- An empty `pr-number` (a `push`, `schedule` or `workflow_dispatch` run) reports + every output as `skipped` and exits 0. This is a pull-request gate; a push run + must not fail on it. +- The advisory comment carries the HTML marker ``. + The step edits that comment rather than posting a second one, and on a linkage + pass it rewrites the comment to say the body conforms rather than deleting it. + Only a **bot-authored** comment carrying the marker is a candidate, and the + newest one wins: on a public repository anyone can comment, so a stranger who + planted the marker would otherwise capture the upsert and the gate's guidance + would never appear. +- Attacker-controlled text (the title, body-derived quotes, the author login) is + escaped to GitHub's workflow-command rules (`%` → `%25`, CR → `%0D`, LF → + `%0A`) before it appears in an annotation, so a title carrying a newline cannot + close the annotation and inject a second workflow command. +- `repository`, `pr-number` and both label inputs are validated against strict + patterns before the first `gh api` call, and the label is percent-encoded in + the label-removal path. +- The issue-linkage analyzer masks rendered HTML comments, fenced and indented + code blocks, and inline code spans before matching, so a PR template's + commented-out `Closes #N` example cannot satisfy the gate. +- A negated closing reference (`does not close #N`) fails outright and is never + excused by a valid marker elsewhere in the body: GitHub's own linkage parser + ignores the surrounding words and closes the issue on merge regardless of the + disclaimer. Use `Refs: #N` or `Relates to: #N` on its own line instead. + +## Consumer wiring + +Both steps go in the `ci-status` job, in this order: + +```yaml + ci-status: + if: ${{ !cancelled() }} + needs: [lane-a, lane-b] + permissions: + contents: read + pull-requests: write + statuses: write + runs-on: ubuntu-24.04 + steps: + - uses: melodic-software/ci-workflows/.github/actions/pr-contract@ # vX.Y.Z + - name: Aggregate lane results + # `!cancelled()` so a failing pr-contract step does not skip the + # aggregation: the job still fails on the contract step's exit code, and + # the ci-lanes status lands on the SHA for the next contract-only run. + if: ${{ !cancelled() }} + uses: melodic-software/ci-workflows/.github/actions/ci-status@ # vX.Y.Z + with: + results: ${{ needs.lane-a.result }} ${{ needs.lane-b.result }} +``` + +The caller's workflow must add `edited`, `labeled` and `unlabeled` to +`on.pull_request.types` and gate every other job with `if: ${{ !() }}` +where `` is, verbatim: + +```text +github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base)) +``` + +The same negated predicate goes in `cancel-in-progress`, ANDed with any existing +condition there. Two exclusions in it are load-bearing: an `edited` event +carrying `changes.base` changed the base branch, so the merge commit the lanes +test changed with it and the run must be full; and a fork pull request is never +contract-only, because its token cannot record the lane state a later +carry-forward would read. + +`ci-status` reads the same predicate through its own `contract-only` input, +whose default is that expression, so the caller passes nothing. A caller that +overrides `contract-only` owns the claim that the lanes did not run: the runner +branches on it before it looks at `same-repo`, so `contract-only: true` with +`same-repo: false` is treated as a carry-forward even though the shipped +defaults never produce that combination. + +This repository's own `.github/workflows/ci.yml` is the reference wiring. + +## Tests + +`run.test.sh` drives `run.sh` against fixture JSON served by a `gh` shim placed +first on `PATH`, which also logs every API call so the harness can assert on the +writes that did and did not happen. Run it with +`bash .github/actions/pr-contract/run.test.sh`; the `selector-contract` lane runs +it in CI. diff --git a/.github/actions/pr-contract/action.yml b/.github/actions/pr-contract/action.yml new file mode 100644 index 00000000..b29e76da --- /dev/null +++ b/.github/actions/pr-contract/action.yml @@ -0,0 +1,88 @@ +name: pr-contract +description: >- + One step carrying the whole pull-request contract: Conventional Commits title, + do-not-merge label, and issue linkage. The title and label checks gate; the + linkage check is advisory by default (a marker comment plus a label) so a body + edit never re-runs the file-lint lanes. + +inputs: + token: + description: >- + Token used for every API read and write. Needs `pull-requests: write` on + the calling job for the advisory comment and label; with a read-only token + the writes degrade to `::notice::` and the exit code is unchanged. Must be + `GITHUB_TOKEN` or a GitHub App token: the advisory comment is upserted by + finding a previous comment whose author is a `Bot`, so a classic PAT posts + a new comment on every failing run instead of editing its own. + default: ${{ github.token }} + repository: + description: Target repository identity OWNER/REPO (defaults to this repo). + default: ${{ github.repository }} + pr-number: + description: >- + Pull request number. Empty (a push, schedule, or workflow_dispatch run) + makes the step a no-op that reports every output as `skipped` — this is a + pull-request gate, and a push run must not fail on it. + default: ${{ github.event.pull_request.number }} + types: + description: >- + Comma-separated allowed Conventional Commits types. The default is the + twelve types melodic-software/standards + `components/pr-convention-policy/policy.json` declares: the eleven spec + defaults plus `security`. + default: build,chore,ci,docs,feat,fix,perf,refactor,revert,security,style,test + require-scope: + description: Require a scope to always be present in the title (`true`/`false`). + default: 'false' + do-not-merge-label: + description: Label whose presence blocks the merge. + default: do-not-merge + exempt-authors: + description: >- + Comma-separated exact PR-author logins that skip the issue-linkage check + (e.g. `dependabot[bot]`). Matched by exact equality against the PR author + login — never a `*[bot]` pattern, so an unknown future bot is not silently + skipped. Fail-closed: the default empty string exempts no one. + default: '' + linkage-label: + description: >- + Label added while the body fails the issue-linkage contract and removed + once it passes. + default: needs-issue-linkage + linkage-mode: + description: >- + `advisory` (default) warns, upserts one marker comment, adds the label, and + exits 0. `enforce` fails the step instead, so a repository can opt back in + to a hard gate without a composite change. + default: advisory + +outputs: + title: + description: '`pass`, `fail`, or `skipped` (no pull request in this event).' + value: ${{ steps.contract.outputs.title }} + do-not-merge: + description: '`pass`, `fail`, or `skipped` (no pull request in this event).' + value: ${{ steps.contract.outputs.do-not-merge }} + linkage: + description: >- + `pass`, `fail`, `exempt` (an exempt author), or `skipped` (no pull request + in this event). + value: ${{ steps.contract.outputs.linkage }} + +runs: + using: composite + steps: + - name: Check the pull-request contract + id: contract + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + REPOSITORY: ${{ inputs.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + TYPES: ${{ inputs.types }} + REQUIRE_SCOPE: ${{ inputs.require-scope }} + DO_NOT_MERGE_LABEL: ${{ inputs.do-not-merge-label }} + EXEMPT_AUTHORS: ${{ inputs.exempt-authors }} + LINKAGE_LABEL: ${{ inputs.linkage-label }} + LINKAGE_MODE: ${{ inputs.linkage-mode }} + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/.github/actions/pr-contract/run.sh b/.github/actions/pr-contract/run.sh new file mode 100755 index 00000000..e9fd7990 --- /dev/null +++ b/.github/actions/pr-contract/run.sh @@ -0,0 +1,634 @@ +#!/usr/bin/env bash +# Single-run pull-request contract: title, do-not-merge label, issue linkage. +# +# Ports the semantics of the three standalone reusables (semantic-pr.yml, +# do-not-merge-gate.yml, pr-issue-linkage.yml) into one composite step so a +# consumer needs exactly one required check (`ci-status`) instead of four. +# Every check reads the LIVE pull request from the API rather than the event +# payload, so `edited`, `labeled` and `unlabeled` runs see current state. +set -euo pipefail + +: "${REPOSITORY:?REPOSITORY is required}" +: "${TYPES:?TYPES is required}" + +PR_NUMBER="${PR_NUMBER:-}" +REQUIRE_SCOPE="${REQUIRE_SCOPE:-false}" +DO_NOT_MERGE_LABEL="${DO_NOT_MERGE_LABEL:-do-not-merge}" +EXEMPT_AUTHORS="${EXEMPT_AUTHORS:-}" +LINKAGE_LABEL="${LINKAGE_LABEL:-needs-issue-linkage}" +LINKAGE_MODE="${LINKAGE_MODE:-advisory}" + +# Stable HTML marker on the advisory comment: the upsert finds its own previous +# comment by this string, so a re-run edits rather than posting a second one. +LINKAGE_MARKER='' + +scratch="$(mktemp -d)" +trap 'rm -rf -- "$scratch"' EXIT +gh_stdout="$scratch/gh-stdout" +gh_stderr="$scratch/gh-stderr" + +emit_output() { + local name="$1" value="$2" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "${name}=${value}" >>"$GITHUB_OUTPUT" + fi +} + +# Every annotation below quotes attacker-controlled text: the PR title, the PR +# body, the author login. GitHub's documented escaping for workflow-command data +# is `%` -> `%25`, CR -> `%0D`, LF -> `%0A`; without it a title carrying a +# newline can close the annotation and inject a second workflow command. +# `%` first, or the escapes introduced by the others get double-escaped. +escape_annotation() { + local text="$1" + text="${text//'%'/%25}" + text="${text//$'\r'/%0D}" + text="${text//$'\n'/%0A}" + printf '%s' "$text" +} + +# Every value below is interpolated into a `gh api` path. Validate before the +# first call rather than trusting the caller's expression. +require_pattern() { + local name="$1" value="$2" pattern="$3" shape="$4" + if [[ ! "$value" =~ $pattern ]]; then + echo "::error::pr-contract: ${name} must be ${shape}, got: $(escape_annotation "$value")" + exit 1 + fi +} + +# Percent-encode a label so it reaches the DELETE path as one segment. `@uri` +# rather than a bash character loop: `printf '%%%02X' "'$c"` emits the code +# point, so a label carrying an emoji or any non-ASCII character would be +# mis-encoded. `@uri` percent-encodes the UTF-8 bytes, which is what the API +# expects. +# shellcheck disable=SC2329 # invoked from remove_linkage_label, itself reached through best_effort. +url_encode() { + jq -rn --arg text "$1" '$text|@uri' +} + +case "$LINKAGE_MODE" in +advisory | enforce) ;; +*) + echo "::error::pr-contract: linkage-mode must be 'advisory' or 'enforce', got: ${LINKAGE_MODE}" + exit 1 + ;; +esac + +case "$REQUIRE_SCOPE" in +true | false) ;; +*) + echo "::error::pr-contract: require-scope must be 'true' or 'false', got: ${REQUIRE_SCOPE}" + exit 1 + ;; +esac + +# Step 0 — no pull request in this event. The composite is a pull-request gate; +# a push, schedule or workflow_dispatch run must report skipped, not fail. +if [[ -z "${PR_NUMBER//[[:space:]]/}" ]]; then + echo '::notice::pr-contract: no pull request in this event; nothing to check' + emit_output title skipped + emit_output do-not-merge skipped + emit_output linkage skipped + exit 0 +fi + +# Only the two values that reach an API path unencoded. The labels are +# deliberately NOT validated: GitHub labels may legally contain spaces and +# colons (`do not merge`, `status: blocked`), and neither one is ever +# interpolated raw — the blocking label is compared with `grep -qxF`, the +# linkage label travels in a JSON body built by `jq --arg`, and the only path +# it reaches is percent-encoded below. +require_pattern repository "$REPOSITORY" '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' 'OWNER/REPO' +require_pattern pr-number "$PR_NUMBER" '^[0-9]+$' 'a positive integer' + +# gh_api [extra args...] +# Captures stdout and stderr; sets GH_HTTP_STATUS from gh's "(HTTP nnn)" tail +# when the call failed. Returns gh's exit status. +GH_HTTP_STATUS="" +gh_api() { + local method="$1" path="$2" + shift 2 + local status=0 + : >"$gh_stdout" + : >"$gh_stderr" + GH_HTTP_STATUS="" + # `set +e` around the call: a failed API read is classified by the caller, + # not aborted by errexit. + set +e + gh api -X "$method" "$path" "$@" >"$gh_stdout" 2>"$gh_stderr" + status=$? + set -e + if [[ "$status" -ne 0 ]]; then + GH_HTTP_STATUS="$(sed -n 's/.*(HTTP \([0-9][0-9]*\)).*/\1/p' "$gh_stderr" | head -n1)" + fi + return "$status" +} + +# Best-effort write: a refused or missing write surfaces as a ::notice:: and +# never changes the exit code. The advisory comment and label are convenience, +# not the gate. +best_effort() { + local description="$1" + shift + if "$@"; then + return 0 + fi + local detail="${GH_HTTP_STATUS:-unknown}" + echo "::notice::pr-contract: ${description} was refused (HTTP ${detail}); continuing without it" + return 0 +} + +# --------------------------------------------------------------------------- +# Step 1 — fetch the pull request once. +# --------------------------------------------------------------------------- +# shellcheck disable=SC2310 # gh_api handles its own errexit; the caller classifies the status. +if ! gh_api GET "repos/${REPOSITORY}/pulls/${PR_NUMBER}"; then + cat "$gh_stderr" >&2 + echo "::error::pr-contract: could not read repos/${REPOSITORY}/pulls/${PR_NUMBER} (HTTP ${GH_HTTP_STATUS:-unknown})" + exit 1 +fi +pr_json="$scratch/pr.json" +cp -- "$gh_stdout" "$pr_json" + +pr_title="$(jq -r '.title // ""' <"$pr_json")" +pr_author="$(jq -r '.user.login // ""' <"$pr_json")" +jq -r '.body // ""' <"$pr_json" >"$scratch/body.txt" +jq -r '(.labels // []) | .[].name' <"$pr_json" >"$scratch/labels.txt" + +# --------------------------------------------------------------------------- +# Step 2 — title against Conventional Commits with the caller's type list. +# --------------------------------------------------------------------------- +types_list="$(printf '%s' "$TYPES" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$' || true)" +if [[ -z "$types_list" ]]; then + echo "::error::pr-contract: types is empty; list at least one allowed Conventional Commits type" + exit 1 +fi +types_alternation="$(printf '%s' "$types_list" | paste -sd '|' -)" +types_human="${types_alternation//|/, }" + +# `: +`, not `: `: the conventional-commits parser behind semantic-pr.yml +# tolerates more than one space after the colon, so requiring exactly one would +# fail titles that pass the gate this composite replaces. +# shellcheck disable=SC2016 # the backticks are Markdown code spans in a message, not command substitution. +if [[ "$REQUIRE_SCOPE" == true ]]; then + title_regex="^(${types_alternation})\([^)]+\)!?: +[^[:space:]]" + scope_hint='a scope is REQUIRED, e.g. `feat(api): add the widget endpoint`' +else + title_regex="^(${types_alternation})(\([^)]+\))?!?: +[^[:space:]]" + scope_hint='an optional scope may follow the type, e.g. `feat(api): add the widget endpoint`' +fi + +title_result=pass +if [[ ! "$pr_title" =~ $title_regex ]]; then + title_result=fail + echo "::error::pr-contract: the PR title does not follow Conventional Commits: \"$(escape_annotation "$pr_title")\". Expected \": \" with a non-empty subject; allowed types: ${types_human}. ${scope_hint} A breaking change marks the type with \"!\"." +fi + +# --------------------------------------------------------------------------- +# Step 3 — do-not-merge label. +# --------------------------------------------------------------------------- +do_not_merge_result=pass +if grep -qxF -- "$DO_NOT_MERGE_LABEL" "$scratch/labels.txt"; then + do_not_merge_result=fail + echo "::error::pr-contract: this PR carries the '${DO_NOT_MERGE_LABEL}' label; remove it to merge." +fi + +# --------------------------------------------------------------------------- +# Step 4 — issue linkage. +# +# Ported from pr-issue-linkage.yml (ci-workflows#153, #521, #544): the body must +# carry a native closing keyword, an explicit non-closing `Refs:`/`Relates to:` +# marker, or a no-issue opt-out, AND four non-empty contract sections. A negated +# closing reference fails outright and is never excused by a valid marker +# elsewhere, because GitHub's own parser is negation-blind and closes the issue +# on merge regardless of the disclaimer. +# +# The analyzer masks rendered HTML comments, fenced and indented code blocks, +# and inline code spans before matching, so a PR template's commented-out +# example cannot satisfy the gate. +# --------------------------------------------------------------------------- +analyze_body() { + awk ' +# Whitespace includes newlines: section content is joined before trimming, so a +# section holding only blank lines must trim to the empty string. +function trim(s) { + sub(/^[ \t\r\n]+/, "", s) + sub(/[ \t\r\n]+$/, "", s) + return s +} + +function run_length(s, ch, n) { + n = 0 + while (substr(s, n + 1, 1) == ch) n++ + return n +} + +# A backtick run of the same length later on this line closes an inline span. +# The upstream implementation also scans following lines; a multi-line inline +# span in a PR body is not a shape this gate needs to model. +function has_closing_run(s, start, ticks, col, stop) { + col = start + while (col <= length(s)) { + if (substr(s, col, 1) != "`") { + col++ + continue + } + stop = col + 1 + while (substr(s, stop, 1) == "`") stop++ + if (stop - col == ticks) return 1 + col = stop + } + return 0 +} + +function negation_trigger(line, keyword_index, preceding, cut, i, ch, tail, count, words, first, word, lower) { + preceding = substr(line, 1, keyword_index - 1) + cut = 0 + for (i = length(preceding); i >= 1; i--) { + ch = substr(preceding, i, 1) + if (ch == "." || ch == "!" || ch == "?" || ch == ";" || ch == ",") { + cut = i + break + } + } + tail = substr(preceding, cut + 1) + # U+2019 RIGHT SINGLE QUOTATION MARK, written as its UTF-8 bytes so the source + # of this action stays plain ASCII: normalize a typographic apostrophe to the + # straight one so "doesnt close #N" is detected like "doesn'"'"'t". + gsub("\342\200\231", "'"'"'", tail) + count = 0 + while (match(tail, /[A-Za-z][A-Za-z'"'"']*/)) { + count++ + words[count] = substr(tail, RSTART, RLENGTH) + tail = substr(tail, RSTART + RLENGTH) + } + first = (count > 5) ? count - 4 : 1 + for (i = first; i <= count; i++) { + word = words[i] + lower = tolower(word) + # Correlative "not only ... but" is affirmative, not a disclaimer. + if (lower == "not" && i < count && tolower(words[i + 1]) == "only") continue + if (lower == "not" || lower == "never" || lower == "no" || lower == "without" || + lower == "deliberately" || lower == "intentionally") return word + if (tolower(substr(word, length(word) - 2)) == "n'"'"'t") return word + } + return "" +} + +function scan_line(line, indent, rest, lower, offset, chunk, start, len, before, after, text, trigger) { + indent = 0 + while (substr(line, indent + 1, 1) == " ") indent++ + if (indent <= 3) { + rest = tolower(substr(line, indent + 1)) + if (rest ~ /^(refs|relates[ \t]+to):[ \t]*([a-z0-9_.-]+\/[a-z0-9_.-]+)?#[0-9]+[ \t]*$/) { + has_non_closing = 1 + } + } + lower = tolower(line) + offset = 0 + while (1) { + chunk = substr(lower, offset + 1) + if (!match(chunk, /(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[ \t]*:?[ \t]*([a-z0-9_.-]+\/[a-z0-9_.-]+)?#[0-9]+/)) break + start = offset + RSTART + len = RLENGTH + offset = start + len - 1 + before = (start > 1) ? substr(lower, start - 1, 1) : "" + after = substr(lower, start + len, 1) + # Word-boundary equivalent of the upstream /\b...\b/ anchors. + if (before ~ /[a-z0-9_]/) continue + if (after ~ /[a-z0-9_]/) continue + text = substr(line, start, len) + trigger = negation_trigger(line, start) + if (trigger != "") { + if (!(text in negated_trigger)) { + negated_count++ + negated_order[negated_count] = text + negated_trigger[text] = trigger + } + } else { + has_closing = 1 + } + } +} + +function section_report(name, i, trimmed, start, hashes, next_char, tail, content) { + start = 0 + for (i = 1; i <= line_count; i++) { + trimmed = tolower(trim(masked[i])) + if (trimmed ~ ("^##[ \t]+" tolower(name) "$")) { + start = i + break + } + } + if (start == 0) { + print "section-missing\t" name + return + } + content = "" + for (i = start + 1; i <= line_count; i++) { + trimmed = trim(masked[i]) + hashes = run_length(trimmed, "#") + if (hashes >= 1 && hashes <= 6) { + next_char = substr(trimmed, hashes + 1, 1) + tail = trim(substr(trimmed, hashes + 1)) + # Only a heading at the same or higher level ends the section, so a + # nested subsection counts as this section content. + if ((next_char == " " || next_char == "\t") && tail != "" && hashes <= 2) break + } + content = content masked[i] "\n" + } + if (trim(content) == "") print "section-empty\t" name +} + +BEGIN { + line_count = 0 + comment_open = 0 + inline_ticks = 0 + fence_char = "" + fence_len = 0 + has_closing = 0 + has_non_closing = 0 + negated_count = 0 +} + +{ + line = $0 + sub(/\r$/, "", line) + + if (!comment_open && inline_ticks == 0) { + indent = 0 + while (substr(line, indent + 1, 1) == " ") indent++ + rest = substr(line, indent + 1) + marker_char = substr(rest, 1, 1) + marker_run = 0 + if (marker_char == "`" || marker_char == "~") marker_run = run_length(rest, marker_char) + is_marker = (indent <= 3 && marker_run >= 3) + info = is_marker ? substr(rest, marker_run + 1) : "" + + if (fence_char != "") { + masked[++line_count] = "" + if (is_marker && marker_char == fence_char && marker_run >= fence_len && info ~ /^[ \t]*$/) { + fence_char = "" + fence_len = 0 + } + next + } + if (is_marker && !(marker_char == "`" && index(info, "`") > 0)) { + fence_char = marker_char + fence_len = marker_run + masked[++line_count] = "" + next + } + if (substr(line, 1, 4) == " " || substr(line, 1, 1) == "\t") { + masked[++line_count] = "" + next + } + } + + rendered = "" + index_position = 1 + line_length = length(line) + while (index_position <= line_length) { + if (comment_open) { + close_at = index(substr(line, index_position), "-->") + if (close_at == 0) { + index_position = line_length + 1 + continue + } + comment_open = 0 + index_position = index_position + close_at - 1 + 3 + continue + } + character = substr(line, index_position, 1) + if (character == "`") { + stop = index_position + 1 + while (substr(line, stop, 1) == "`") stop++ + ticks = stop - index_position + was_inline = (inline_ticks != 0) + if (!was_inline && has_closing_run(line, stop, ticks)) { + inline_ticks = ticks + } else if (inline_ticks == ticks) { + inline_ticks = 0 + } + if (!was_inline && inline_ticks == 0) rendered = rendered substr(line, index_position, stop - index_position) + index_position = stop + continue + } + if (inline_ticks == 0 && substr(line, index_position, 4) == ""$'\n\n## Summary\n\ns\n\n## Fix\n\nf\n\n## Verification\n\nv\n\n## Related\n\nr\n' someone '' +run_case 0 +expect_output 'linkage=fail' +expect_log 'Missing a native closing keyword' + +# Without fenced-code masking an example in a code block satisfies the gate. +echo 'case: a closing keyword only inside a fenced code block does not satisfy linkage' +set_pull 'feat: add pr-contract' '```'$'\nCloses #12\n''```'$'\n\n## Summary\n\ns\n\n## Fix\n\nf\n\n## Verification\n\nv\n\n## Related\n\nr\n' someone '' +run_case 0 +expect_output 'linkage=fail' + +# Without inline-code masking a code span holding a closing reference satisfies +# the gate. +echo 'case: a closing keyword only inside an inline code span does not satisfy linkage' +# shellcheck disable=SC2016 # the backticks are the Markdown code span under test. +set_pull 'feat: add pr-contract' 'Write `Closes #12` in the body.'$'\n\n## Summary\n\ns\n\n## Fix\n\nf\n\n## Verification\n\nv\n\n## Related\n\nr\n' someone '' +run_case 0 +expect_output 'linkage=fail' + +# --- linkage: contract sections -------------------------------------------- + +# Without the four-section check a body with only linkage passes. +echo 'case: each missing contract section is reported by name' +for section in Summary Fix Verification Related; do + body='Closes #12'$'\n' + for present in Summary Fix Verification Related; do + if [[ "$present" == "$section" ]]; then + continue + fi + body+=$'\n## '"$present"$'\n\ncontent\n' + done + set_pull 'feat: add pr-contract' "$body" someone '' + run_case 0 + expect_output 'linkage=fail' + expect_log "Missing a \"## ${section}\" section." +done + +# Without the non-empty check a heading with no content passes. +echo 'case: an empty contract section is reported as empty, not missing' +set_pull 'feat: add pr-contract' 'Closes #12'$'\n\n## Summary\n\n## Fix\n\nf\n\n## Verification\n\nv\n\n## Related\n\nr\n' someone '' +run_case 0 +expect_output 'linkage=fail' +expect_log 'The "## Summary" section is empty.' + +# Without the same-or-higher-level end rule a nested subsection would close the +# section early and Summary would read as empty. +echo 'case: a nested subsection counts as its parent section content' +set_pull 'feat: add pr-contract' 'Closes #12'$'\n\n## Summary\n\n### Detail\n\nnested content\n\n## Fix\n\nf\n\n## Verification\n\nv\n\n## Related\n\nr\n' someone '' +run_case 0 +expect_output 'linkage=pass' + +# --- linkage: exempt authors ---------------------------------------------- + +# Without the exempt-author short circuit a bot body fails linkage. +echo 'case: an exempt author skips the linkage check entirely' +set_pull 'build: bump a dependency' 'Bumps a thing.' 'dependabot[bot]' '' +run_case 0 'EXEMPT_AUTHORS=dependabot[bot]' +expect_output 'linkage=exempt' +expect_log 'matches an exempt-authors entry' + +# Without exact-login equality a `*[bot]` style match would exempt this author. +echo 'case: a non-listed author is not exempt' +set_pull 'build: bump a dependency' 'Bumps a thing.' 'renovate[bot]' '' +run_case 0 'EXEMPT_AUTHORS=dependabot[bot]' +expect_output 'linkage=fail' + +# Without the trailing-newline fix on the list split the last entry is dropped. +echo 'case: the last entry of a multi-author exempt list still matches' +set_pull 'build: bump a dependency' 'Bumps a thing.' 'renovate[bot]' '' +run_case 0 'EXEMPT_AUTHORS=dependabot[bot],renovate[bot]' +expect_output 'linkage=exempt' + +# --- advisory outcome ------------------------------------------------------ + +# Without the create branch no comment is posted on the first failing run. +echo 'case: an advisory failure creates the marker comment and adds the label' +set_comments '[]' +set_pull 'feat: add pr-contract' 'nothing here' someone '' +run_case 0 +expect_output 'linkage=fail' +expect_gh_call "POST repos/${repository}/issues/${pr_number}/comments" +expect_comment_body "POST_${comments_key}" '' +expect_comment_body "POST_${comments_key}" 'Missing a "## Summary" section.' +expect_gh_call "POST repos/${repository}/issues/${pr_number}/labels" +expect_log '::warning::pr-contract: Missing a native closing keyword' + +# Without the marker lookup a second run posts a second comment. +echo 'case: a second advisory failure edits the existing marker comment' +set_comments "[$(bot_comment 1001 '\nstale text')]" +set_pull 'feat: add pr-contract' 'nothing here' someone '' +run_case 0 +expect_gh_call "PATCH repos/${repository}/issues/comments/1001" +expect_no_gh_call "POST repos/${repository}/issues/${pr_number}/comments" + +# Without the best-effort wrapper a refused comment write fails the step. +echo 'case: a 403 on the comment write leaves the exit code unchanged' +set_comments '[]' +printf '%s\n' 'gh: Forbidden (HTTP 403)' >"$fixtures/POST_${comments_key}.err" +set_pull 'feat: add pr-contract' 'nothing here' someone '' +run_case 0 +expect_log '::notice::pr-contract: upserting the linkage comment was refused (HTTP 403); continuing without it' +rm -f -- "$fixtures/POST_${comments_key}.err" + +# Without the best-effort wrapper a refused label write fails the step. +echo 'case: a 403 on the label write leaves the exit code unchanged' +printf '%s\n' 'gh: Forbidden (HTTP 403)' >"$fixtures/POST_${labels_key}.err" +set_pull 'feat: add pr-contract' 'nothing here' someone '' +run_case 0 +expect_log '::notice::pr-contract: adding the linkage label was refused (HTTP 403); continuing without it' +rm -f -- "$fixtures/POST_${labels_key}.err" + +# Without the pass branch the label stays on a body that now conforms. +echo 'case: a linkage pass removes the label and rewrites the marker comment' +set_comments "[$(bot_comment 1001 '\nstale failure text')]" +set_pull 'feat: add pr-contract' "$conforming_body" someone 'needs-issue-linkage' +run_case 0 +expect_output 'linkage=pass' +expect_gh_call "PATCH repos/${repository}/issues/comments/1001" +expect_comment_body 'PATCH_repos_melodic-software_ci-workflows_issues_comments_1001' 'conforms to the issue-linkage contract' +expect_gh_call "DELETE repos/${repository}/issues/${pr_number}/labels/needs-issue-linkage" + +# Without the label-presence guard a DELETE fires on every passing run. +echo 'case: a linkage pass with no label present issues no label delete' +set_pull 'feat: add pr-contract' "$conforming_body" someone '' +run_case 0 +expect_no_gh_call 'DELETE' + +# Without the enforce branch a linkage failure exits 0 here too. +echo 'case: linkage-mode enforce fails the step on a linkage failure' +set_comments '[]' +set_pull 'feat: add pr-contract' 'nothing here' someone '' +run_case 1 LINKAGE_MODE=enforce +expect_output 'linkage=fail' +expect_log '::error::pr-contract: Missing a native closing keyword' + +# Without computing every check before exiting, a title failure would leave the +# linkage output unset for the caller. +echo 'case: all three outputs are emitted even when the title check fails' +set_pull 'wip: no type here' "$conforming_body" someone 'do-not-merge' +run_case 1 +expect_output 'title=fail' +expect_output 'do-not-merge=fail' +expect_output 'linkage=pass' + +# Without the fetch-failure branch an unreadable PR would be read as an empty +# title and body and reported as a contract violation instead of an outage. +echo 'case: an unreadable pull request fails loudly' +mv -- "$fixtures/${pull_key}.json" "$fixtures/${pull_key}.json.bak" +printf '%s\n' 'gh: Not Found (HTTP 404)' >"$fixtures/${pull_key}.err" +run_case 1 +expect_log "::error::pr-contract: could not read repos/${repository}/pulls/${pr_number} (HTTP 404)" +rm -f -- "$fixtures/${pull_key}.err" +mv -- "$fixtures/${pull_key}.json.bak" "$fixtures/${pull_key}.json" + +# --- comment-upsert capture ------------------------------------------------ + +# Without the `.user.type == "Bot"` filter the gate edits a stranger's planted +# comment instead of posting its own, and its guidance is never shown. +echo 'case: a planted marker comment from a user account does not capture the upsert' +set_comments "[$(user_comment 900 '\nplanted by a stranger')]" +set_pull 'feat: add pr-contract' 'nothing here' someone '' +run_case 0 +expect_gh_call "POST repos/${repository}/issues/${pr_number}/comments" +expect_no_gh_call 'PATCH repos/' + +# Without newest-first selection an older bot comment would be edited and the +# newest one left carrying stale text. +echo 'case: the newest bot marker comment is the one edited' +set_comments "[$(bot_comment 900 '\nold'),$(user_comment 950 '\nplanted'),$(bot_comment 1200 '\nnewer')]" +set_pull 'feat: add pr-contract' 'nothing here' someone '' +run_case 0 +expect_gh_call "PATCH repos/${repository}/issues/comments/1200" + +# --- annotation escaping --------------------------------------------------- + +# Without escaping, a title carrying a newline closes the annotation and the +# rest of the title is interpreted as a second workflow command. +echo 'case: an attacker-controlled title is escaped inside the annotation' +set_comments '[]' +set_pull 'wip: 100% broken'$'\n''::set-output name=x::y' "$conforming_body" someone '' +run_case 1 +expect_output 'title=fail' +# Asserted as two independent facts: a CRLF title escapes to `%0D%0A`, so +# pinning the pair would make this case platform-dependent. +expect_log '100%25 broken' +expect_log '%0A::set-output' + +# --- input validation ------------------------------------------------------ +# +# Every one of these values is interpolated into a `gh api` path. + +echo 'case: a malformed repository is rejected before any API call' +run_case 1 REPOSITORY='melodic-software/ci-workflows/../other' +expect_log '::error::pr-contract: repository must be OWNER/REPO' +expect_no_gh_calls_at_all + +echo 'case: a non-numeric pr-number is rejected before any API call' +run_case 1 PR_NUMBER='7/../8' +expect_log '::error::pr-contract: pr-number must be a positive integer' +expect_no_gh_calls_at_all + +# --- labels are not validated ---------------------------------------------- +# +# GitHub labels may legally contain spaces and colons. Neither label reaches an +# API path unencoded, so rejecting those characters would break real consumers +# for no gain. + +# Without dropping the label validation this run is rejected outright. +echo 'case: a blocking label containing spaces is accepted and matched exactly' +set_comments '[]' +set_pull 'feat: add pr-contract' "$conforming_body" someone 'do not merge' +run_case 1 'DO_NOT_MERGE_LABEL=do not merge' +expect_output 'do-not-merge=fail' +expect_log "::error::pr-contract: this PR carries the 'do not merge' label; remove it to merge." + +echo 'case: a similar label containing spaces does not block' +set_pull 'feat: add pr-contract' "$conforming_body" someone 'do not merge yet' +run_case 0 'DO_NOT_MERGE_LABEL=do not merge' +expect_output 'do-not-merge=pass' + +# Without `@uri` the encoder emitted code points, so a non-ASCII label reached +# the DELETE path mis-encoded and the label was never removed. +echo 'case: a non-ASCII linkage label is percent-encoded as UTF-8 in the DELETE path' +set_comments '[]' +set_pull 'feat: add pr-contract' "$conforming_body" someone 'needs-issue-linkage-é' +run_case 0 'LINKAGE_LABEL=needs-issue-linkage-é' +expect_output 'linkage=pass' +expect_gh_call "DELETE repos/${repository}/issues/${pr_number}/labels/needs-issue-linkage-%C3%A9" + +# --- metadata contract ----------------------------------------------------- + +echo 'case: action.yml still wires every input this harness exercises' +action_metadata="$script_directory/action.yml" +for input_name in token repository pr-number types require-scope do-not-merge-label exempt-authors linkage-label linkage-mode; do + if ! grep -qE "^ ${input_name}:" "$action_metadata"; then + echo "FAIL: action.yml declares no '${input_name}' input" + failures=$((failures + 1)) + fi +done +for environment_name in GH_TOKEN REPOSITORY PR_NUMBER TYPES REQUIRE_SCOPE DO_NOT_MERGE_LABEL EXEMPT_AUTHORS LINKAGE_LABEL LINKAGE_MODE; do + if ! grep -qF " ${environment_name}: " "$action_metadata"; then + echo "FAIL: action.yml does not pass '${environment_name}' to run.sh" + failures=$((failures + 1)) + fi +done +for output_name in title do-not-merge linkage; do + if ! grep -qE "^ ${output_name}:" "$action_metadata"; then + echo "FAIL: action.yml declares no '${output_name}' output" + failures=$((failures + 1)) + fi +done + +if [[ "$failures" -gt 0 ]]; then + echo "$failures test(s) failed." + exit 1 +fi +echo 'All pr-contract tests passed.' diff --git a/.github/scripts/ci-fanout-consolidation.test.cjs b/.github/scripts/ci-fanout-consolidation.test.cjs index 784cdf90..53fa9abb 100644 --- a/.github/scripts/ci-fanout-consolidation.test.cjs +++ b/.github/scripts/ci-fanout-consolidation.test.cjs @@ -26,18 +26,170 @@ const adrPath = path.join( "ADR.md", ); +const ciStatusActionPath = path.join( + repositoryRoot, + ".github", + "actions", + "ci-status", + "action.yml", +); + const ciWorkflow = fs.readFileSync(ciWorkflowPath, "utf8"); const selectorConformance = fs.readFileSync(selectorConformancePath, "utf8"); const adr = fs.readFileSync(adrPath, "utf8"); +const ciStatusAction = fs.readFileSync(ciStatusActionPath, "utf8"); + +// Strip the `${{ }}` wrapper, an outer `!( )`, and every run of whitespace, so +// a folded YAML block scalar and a single workflow line compare equal. +function normalizeExpression(text) { + let expression = text.trim(); + const wrapper = /^\$\{\{(?[\s\S]*)\}\}$/u.exec(expression); + if (wrapper !== null) { + expression = wrapper.groups.inner.trim(); + } + const negation = /^!\((?[\s\S]*)\)$/u.exec(expression); + if (negation !== null) { + expression = negation.groups.inner.trim(); + } + return expression.replace(/\s+/gu, " "); +} + +// The contract-only predicate, repeated verbatim in `cancel-in-progress` and in +// every job's `if:`. True only for a SAME-REPOSITORY pull request on a label +// flip, or on an `edited` event that did not change the base branch — a base +// change moves the merge commit the lanes test, and a fork cannot record lane +// state, so both run the full workflow (Phase 3.1 of the ci-perf program). +const CONTRACT_ONLY_PREDICATE = + "github.event.pull_request.head.repo.full_name == github.repository && " + + '(contains(fromJSON(\'["labeled","unlabeled"]\'), github.event.action) || ' + + "(github.event.action == 'edited' && !github.event.changes.base))"; +const CONTRACT_ONLY_GATE = `!(${CONTRACT_ONLY_PREDICATE})`; test("ci.yml uses main-push burst collapse concurrency (#122)", () => { + // The `github.event_name == 'pull_request'` guard is ANDed with, not replaced + // by, the negated predicate: on a push there is no `github.event.pull_request` + // so the predicate is false and `!(predicate)` alone would be true, re-arming + // the burst collapse #122 disarmed. assert.match( ciWorkflow, - /^concurrency:\n {2}group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.ref \}\}\n {2}cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}$/mu, + /^concurrency:\n {2}group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.ref \}\}\n {2}cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' && (?.+) \}\}$/mu, ); + const cancelGate = + /^ {2}cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' && (?.+) \}\}$/mu.exec( + ciWorkflow, + )?.groups?.gate; + assert.equal(cancelGate, CONTRACT_ONLY_GATE); assert.doesNotMatch(ciWorkflow, /pull_request\.number \|\| github\.run_id/u); }); +test("the contract-only predicate excludes forks and base changes", () => { + // Both exclusions are load-bearing and both are easy to drop by accident, so + // pin each clause rather than only the assembled string. + assert.ok( + CONTRACT_ONLY_PREDICATE.startsWith( + "github.event.pull_request.head.repo.full_name == github.repository &&", + ), + ); + assert.ok( + CONTRACT_ONLY_PREDICATE.includes( + "github.event.action == 'edited' && !github.event.changes.base", + ), + ); + // `edited` is never contract-only on its own — only when the base is unchanged. + assert.doesNotMatch( + ciWorkflow, + /contains\(fromJSON\('\["edited","labeled","unlabeled"\]'\)/u, + ); +}); + +test("ci.yml re-runs on the contract-only pull_request actions", () => { + assert.match( + ciWorkflow, + /^ {4}types: \[opened, synchronize, reopened, edited, labeled, unlabeled\]$/mu, + ); +}); + +test("every job except ci-status carries the contract-only gate", () => { + // Job keys are the two-space-indented mapping keys under `jobs:`; the `if:` + // that follows a job key before the next one is that job's condition. + const jobsSection = ciWorkflow.slice(ciWorkflow.search(/^jobs:$/mu)); + const jobBlocks = jobsSection.split(/^ {2}(?=[a-z0-9-]+:$)/mu).slice(1); + const ungated = []; + for (const block of jobBlocks) { + const name = /^([a-z0-9-]+):$/mu.exec(block)?.[1]; + if (name === undefined || name === "ci-status") { + continue; + } + const condition = /^ {4}if: (.*)$/mu.exec(block)?.[1] ?? ""; + if (!condition.includes(CONTRACT_ONLY_GATE)) { + ungated.push(name); + } + } + assert.deepEqual(ungated, []); + // The gate must never reach ci-status itself: that job IS the carry-forward. + const ciStatusBlock = jobBlocks.find((block) => /^ci-status:$/mu.test(block)); + assert.ok(ciStatusBlock !== undefined); + assert.ok(!ciStatusBlock.includes(CONTRACT_ONLY_GATE)); +}); + +test("the ci-status contract-only default matches every job gate", () => { + // A drifted copy is silently catastrophic rather than noisy: if the workflow + // gates lanes off on an event where the composite's `contract-only` resolves + // false, ci-status aggregates all-`skipped` results, passes them under + // `treat-skipped-as: pass`, and records `ci-lanes=success` for a run in which + // nothing executed. Nothing else in CI would notice. + const defaultBlock = + /^ {2}contract-only:[\s\S]*?^ {4}default: >-\n(?(?: {6}.*\n)+)/mu.exec( + ciStatusAction, + ); + assert.ok( + defaultBlock !== null, + "ci-status action.yml has no folded contract-only default", + ); + const actionDefault = normalizeExpression(defaultBlock.groups.value); + assert.equal(actionDefault, normalizeExpression(CONTRACT_ONLY_PREDICATE)); + + const jobsSection = ciWorkflow.slice(ciWorkflow.search(/^jobs:$/mu)); + const jobBlocks = jobsSection.split(/^ {2}(?=[a-z0-9-]+:$)/mu).slice(1); + let compared = 0; + for (const block of jobBlocks) { + const name = /^([a-z0-9-]+):$/mu.exec(block)?.[1]; + if (name === undefined || name === "ci-status") { + continue; + } + const condition = /^ {4}if: \$\{\{ (?.*) \}\}$/mu.exec(block)?.groups + ?.body; + assert.ok(condition !== undefined, `job ${name} has no inline if:`); + // The gate is the leading term; anything after it is the job's own + // condition, ANDed on. + const gate = condition.startsWith(CONTRACT_ONLY_GATE) + ? CONTRACT_ONLY_GATE + : condition; + assert.equal( + normalizeExpression(gate), + actionDefault, + `job ${name} gates on an expression the composite default does not match`, + ); + compared += 1; + } + assert.ok(compared > 0); +}); + +test("the ci-status job runs pr-contract before the aggregation", () => { + const ciStatusJob = ciWorkflow.slice(ciWorkflow.search(/^ {2}ci-status:$/mu)); + assert.match(ciStatusJob, /^ {6}statuses: write$/mu); + assert.match(ciStatusJob, /^ {6}pull-requests: write$/mu); + const contractStep = ciStatusJob.indexOf("./.github/actions/pr-contract"); + const aggregateStep = ciStatusJob.indexOf("./.github/actions/ci-status"); + assert.ok(contractStep !== -1 && aggregateStep !== -1); + assert.ok(contractStep < aggregateStep); + // `!cancelled()` so a failing contract step does not skip the status write. + assert.match( + ciStatusJob.slice(contractStep), + /^ {8}if: \$\{\{ !cancelled\(\) \}\}$/mu, + ); +}); + test("selector-conformance.yml matches the same concurrency pattern", () => { assert.match( selectorConformance, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd5817ce..32fff314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,23 @@ on: push: branches: [main] pull_request: + # `edited`, `labeled` and `unlabeled` can change the pr-title / + # do-not-merge / issue-linkage answers without a new commit, so the required + # check has to re-evaluate. Whether such an event is CONTRACT-ONLY (lanes + # gated off, ci-status carrying the recorded `ci-lanes` verdict forward) is + # decided by one predicate, repeated verbatim in `cancel-in-progress` and in + # every job's `if:`: + # + # github.event.pull_request.head.repo.full_name == github.repository && + # (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || + # (github.event.action == 'edited' && !github.event.changes.base)) + # + # An `edited` event carrying `changes.base` is a BASE-BRANCH change: the + # merge commit the lanes test changes with it, so that runs in full. A fork + # pull request is never contract-only — its token is read-only on + # `pull_request` whatever `permissions:` requests, so it cannot record lane + # state — and runs the full workflow on every event exactly as before. + types: [opened, synchronize, reopened, edited, labeled, unlabeled] permissions: contents: read @@ -14,9 +31,18 @@ permissions: # superseded *pending* runs while the in-progress run finishes (main-push burst # collapse; #122). Keying PRs on the number (not head_ref) keeps fork PRs that # share a branch name from cancelling each other's required runs. +# +# A contract-only event never cancels: it must queue behind an in-flight full +# run so the `ci-lanes` status it reads is already written. GitHub keeps one +# running and one pending run per group and the newest pending wins, so the +# carry-forward always executes after the full run it depends on. The +# `github.event_name == 'pull_request'` guard stays and is ANDed with the +# negated predicate: on a push there is no `github.event.pull_request`, so the +# predicate is false and `!(predicate)` alone would be true, re-arming the +# main-push burst collapse #122 disarmed. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} # Dogfood: this repo is the first consumer of its own composite actions. Each # lane checks out this repo and runs a lane action against either a root-native @@ -40,6 +66,7 @@ concurrency: # ungated — see its job comment. jobs: changes: + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -145,7 +172,7 @@ jobs: markdown: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['markdown'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['markdown'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -158,7 +185,7 @@ jobs: powershell: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['powershell'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['powershell'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -178,6 +205,7 @@ jobs: run: pwsh -File .github/scripts/Invoke-CompositeRunPssa.test.ps1 links: + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -190,7 +218,7 @@ jobs: reference-integrity: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['reference-integrity'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['reference-integrity'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -207,7 +235,7 @@ jobs: ruff: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['python'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['python'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -223,7 +251,7 @@ jobs: pyright: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['python'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['python'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -238,7 +266,7 @@ jobs: biome: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['typescript'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['typescript'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -256,7 +284,7 @@ jobs: tsc: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['typescript'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['typescript'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -273,7 +301,7 @@ jobs: dotnet-build: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['dotnet'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['dotnet'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -290,7 +318,7 @@ jobs: dotnet-format: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['dotnet'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['dotnet'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -304,6 +332,7 @@ jobs: project: fixtures/dotnet/good/good.csproj typos: + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -315,6 +344,7 @@ jobs: uses: ./.github/actions/typos gitleaks: + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -327,7 +357,7 @@ jobs: actionlint: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['actionlint'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['actionlint'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -368,7 +398,7 @@ jobs: lefthook-validate: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['lefthook-validate'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['lefthook-validate'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -385,7 +415,7 @@ jobs: jsonschema: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['jsonschema'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['jsonschema'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -410,6 +440,7 @@ jobs: files: .github/actions/*/action.yml action-metadata-filename: + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -426,6 +457,7 @@ jobs: # / toolchain dogfood jobs stay separate so composite contracts keep a # dedicated failure surface. hygiene: + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} runs-on: ubuntu-24.04 # Sequential steps replace five formerly-parallel 15m jobs; 20m keeps # margin as the cheapest scans grow with the tree. @@ -508,7 +540,7 @@ jobs: # ci-status yet — promote after a clean soak. Exempts standards-sync labelled / # melodic-standards-sync[bot] PRs. managed-files-guard: - if: ${{ github.event_name == 'pull_request' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event_name == 'pull_request' }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: @@ -527,7 +559,7 @@ jobs: shellcheck: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['shell'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['shell'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -542,7 +574,7 @@ jobs: shfmt: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['shell'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['shell'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -556,11 +588,11 @@ jobs: # generated security boundary cannot bypass the required formatter. uses: ./.github/actions/shfmt with: - paths: fixtures/shell/good .github/scripts .github/actions/pulumi-deploy-guard .github/actions/change-detection + paths: fixtures/shell/good .github/scripts .github/actions/pulumi-deploy-guard .github/actions/change-detection .github/actions/pr-contract .github/actions/ci-status selector-contract: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['selector-contract'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['selector-contract'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -591,6 +623,10 @@ jobs: run: bash .github/scripts/release-tag-drift.test.sh - name: Test Gitleaks scan policy guard run: bash .github/actions/gitleaks/scan.test.sh + - name: Test the pull-request contract runner + run: bash .github/actions/pr-contract/run.test.sh + - name: Test the ci-status aggregation and carry-forward runner + run: bash .github/actions/ci-status/run.test.sh - name: Test Pulumi deployment guard run: bash .github/actions/pulumi-deploy-guard/guard.test.sh - name: Test govulncheck SARIF classification @@ -598,7 +634,7 @@ jobs: zizmor: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['zizmor'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['zizmor'] != 'false' }} permissions: contents: read # Reusable zizmor.yml always declares security-events: write (expressions @@ -618,6 +654,11 @@ jobs: # format OSV supports, and a filter enumerating that set would drift as # upstream adds formats — an unlisted new lockfile type would then skip # the scan that covers it. Running always is the conservative posture. + # + # The contract-only gate is the one exception: on `edited`, `labeled` and + # `unlabeled` no file changed, so there is nothing new to scan and the + # carry-forward branch of ci-status supplies the verdict. + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} permissions: contents: read uses: ./.github/workflows/osv-scanner.yml @@ -633,7 +674,7 @@ jobs: pester: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['pester'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['pester'] != 'false' }} permissions: contents: read # Dogfood the reusable Pester workflow on a minimal fixture suite — proves @@ -645,7 +686,7 @@ jobs: go-quality-dogfood: needs: changes - if: ${{ !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['go'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['go'] != 'false' }} permissions: contents: read # A local reusable-workflow reference executes the workflow from this exact @@ -666,12 +707,25 @@ jobs: needs: [changes, markdown, powershell, links, reference-integrity, ruff, pyright, biome, tsc, dotnet-build, dotnet-format, typos, gitleaks, shellcheck, shfmt, selector-contract, actionlint, lefthook-validate, jsonschema, action-metadata-filename, hygiene, pester, go-quality-dogfood, zizmor, osv-scanner] runs-on: ubuntu-24.04 timeout-minutes: 15 + permissions: + contents: read + # pr-contract upserts the advisory linkage comment and moves the linkage + # label; ci-status records the `ci-lanes` commit status the carry-forward + # branch reads back on the next contract-only event. + pull-requests: write + statuses: write steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check the pull-request contract + uses: ./.github/actions/pr-contract - name: Aggregate lane results + # `!cancelled()` so a failing pr-contract step does not skip the + # aggregation: the job still fails on the contract step's exit code, and + # the `ci-lanes` status is on the SHA for the next contract-only run. + if: ${{ !cancelled() }} uses: ./.github/actions/ci-status with: results: ${{ needs.changes.result }} ${{ needs.markdown.result }} ${{ needs.powershell.result }} ${{ needs.links.result }} ${{ needs.reference-integrity.result }} ${{ needs.ruff.result }} ${{ needs.pyright.result }} ${{ needs.biome.result }} ${{ needs.tsc.result }} ${{ needs.dotnet-build.result }} ${{ needs.dotnet-format.result }} ${{ needs.typos.result }} ${{ needs.gitleaks.result }} ${{ needs.shellcheck.result }} ${{ needs.shfmt.result }} ${{ needs.selector-contract.result }} ${{ needs.actionlint.result }} ${{ needs.lefthook-validate.result }} ${{ needs.jsonschema.result }} ${{ needs.action-metadata-filename.result }} ${{ needs.hygiene.result }} ${{ needs.pester.result }} ${{ needs.go-quality-dogfood.result }} ${{ needs.zizmor.result }} ${{ needs.osv-scanner.result }} diff --git a/README.md b/README.md index 1bd60e65..b9fddd50 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,52 @@ consumer to audit it. that should have run did not, such as a runner selector falling back. An unrecognised policy value fails rather than defaulting. Empty input fails closed. GitHub offers no "all other jobs" selector, so the `needs` list and the - matching results string stay caller-owned. + matching results string stay caller-owned. `results` carries results, not lane + names, so the recorded status description names the failing lane by its + position in that list. + + It also carries the verdict forward across contract-only pull-request events. + With `contract-only` false (the default expression's value on any event that is + not a same-repository label flip or a base-preserving `edited`) it aggregates as + above and then records the verdict as a commit status on `sha` under + `status-context` (default `ci-lanes`). With `contract-only` true it skips + aggregation — the lanes are `skipped` by construction — and passes only when the + combined status for that context on that SHA is `success`. Both inputs default + to the expressions the caller's lane gates use, so a caller passes neither; + `contract-only` must stay identical to the caller's job gate or the two + disagree about whether the lanes ran — a drifted copy would gate the lanes off + while the composite aggregates their all-`skipped` results into a `success` + for a run in which nothing executed, so `ci-fanout-consolidation.test.cjs` + compares the two texts and fails on any difference. The carried verdict is + read from the per-context status LIST, not the combined endpoint, and only an + entry written by `github-actions[bot]` counts: any collaborator with write can + `POST` a commit status, and the combined endpoint exposes no author, so a + forged `ci-lanes=success` plus a label flip would otherwise turn the sole + required check green over failing lanes. **The status write is load-bearing, not + best-effort**: a write still refused after three retries fails the run naming + the missing permission, because the carry-forward branch reads nothing else and + a silently missing status turns every later contract-only run red with no way + to tell a refused write from a failing lane. **The calling job therefore needs + `statuses: write`** (plus `pull-requests: write` when it also runs + `pr-contract`). The exception is `same-repo` false, a fork pull request: its + token is read-only on `pull_request` whatever `permissions:` requests, so the + run reports the lanes verdict, prints a `::notice::`, and records nothing — + and, because the contract-only predicate is false for every fork event, a fork + runs the full workflow each time and never needs a carried verdict. A commit + status, not the `ci-status` check-run list, is the carried signal on purpose: a + check run cannot say which event produced it, so a chain of contract-only runs + could otherwise self-certify. +- `.github/actions/pr-contract` — the whole pull-request contract in one step: + Conventional Commits title and the `do-not-merge` label gate the step, and + issue linkage is advisory by default (a warning plus one upserted marker + comment plus a label, exit code unchanged; `linkage-mode: enforce` makes it + gate). Semantics are ported from the `semantic-pr`, `do-not-merge-gate` and + `pr-issue-linkage` reusables, which stay in place until their callers are + retired. The pull request is read from the API rather than the event payload, + so `edited` and `labeled` runs see current state. Needs `pull-requests: write` + for the comment and label; every write is best-effort and degrades to a + `::notice::` on a read-only token. See + [its README](.github/actions/pr-contract/README.md) for the consumer wiring. - `.github/actions/change-detection` — decides which CI lanes a pull request's changed files make relevant, so a caller can skip lanes at JOB level and stop paying for runs a change cannot affect. Checkout-free: the PR file