Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/blast-radius-outcome.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Blast-radius outcome — appends the committed trace line to
# audit/blast-radius.jsonl after every successful gate run.
#
# Triggered by `workflow_run` of "Blast-radius pulse" (the gate). The
# write-back uses contents:write and commits with a [skip ci] marker; the
# gate workflow itself never has contents:write (least-privilege gate).
#
# Recursion-break guards (ADR-002 §5):
# 1. The gate workflow declares paths-ignore: ['audit/**'].
# 2. Every commit this workflow writes ends with [skip ci].
# 3. Author-identity guard at the head of the job exits early if the
# triggering commit is itself a github-actions[bot] audit/** commit.
name: Blast-radius outcome

on:
workflow_run:
workflows: ["Blast-radius pulse"]
types: [completed]

permissions:
contents: write # commit trace line
pull-requests: read # resolve PR metadata

concurrency:
group: blast-radius-trace
cancel-in-progress: false

jobs:
outcome:
name: Append blast-radius trace
# Run only on pull_request-triggered gate completions AND only when the
# gate-triggering actor is not github-actions[bot]. The latter is the
# author-identity recursion guard (3 of 3 per ADR-002 §5): if the gate
# run was authored by the bot, the trace write-back is suppressed.
if: >-
${{ github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.actor.login != 'github-actions[bot]'
&& github.event.workflow_run.actor.login != 'blast-radius-bot' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout (default branch HEAD)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Download gate trace artifact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: blast-radius-pulse-${{ github.event.workflow_run.id }}
path: .pulse-in
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

- name: Append trace line to audit/blast-radius.jsonl
run: |
set -euo pipefail
if [ ! -f .pulse-in/trace-fragment.jsonl ]; then
echo "no trace fragment in artifact — nothing to append"
exit 0
fi
mkdir -p audit
# Ensure newline termination on the existing trace before append.
if [ -s audit/blast-radius.jsonl ] && \
[ "$(tail -c1 audit/blast-radius.jsonl | od -An -c | tr -d ' ')" != "\\n" ]; then
printf '\n' >> audit/blast-radius.jsonl
fi
cat .pulse-in/trace-fragment.jsonl >> audit/blast-radius.jsonl

- name: Commit write-back (skip-ci marker, audit/** only)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if git diff --quiet -- audit/blast-radius.jsonl; then
echo "no trace changes to commit"
exit 0
fi
git config user.name "blast-radius-bot"
git config user.email "blast-radius-bot@users.noreply.github.com"
git add audit/blast-radius.jsonl
git commit -m "chore(blast-radius): append pulse trace [skip ci]"
# GITHUB_TOKEN push: GitHub does not re-trigger push-keyed workflows
# from this push, and the gate workflow's paths-ignore guards
# pull_request retriggering.
git push origin "HEAD:${GITHUB_REF_NAME:-main}"
165 changes: 165 additions & 0 deletions .github/workflows/blast-radius-pulse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Blast-radius pulse — evaluates a PR diff against conformance/blast_radius.rego
# and posts a sticky-comment + uploads the per-run trace artifact.
#
# Read-only by design. The committed write-back of audit/blast-radius.jsonl is
# performed by the sibling outcome workflow (blast-radius-outcome.yml) running
# in the trusted workflow_run context. This split keeps the gate from needing
# contents:write.
#
# Recursion-break guards (ADR-002 §5):
# 1. paths-ignore: ['audit/**'] on pull_request — a trace commit alone does
# not retrigger the gate.
# 2. The outcome workflow ends its write-back commit with [skip ci].
# 3. The outcome workflow is keyed on workflow_run of THIS workflow; the
# identity guard at audit/** + github-actions[bot] keeps it from running
# on its own previous commit.
name: Blast-radius pulse

on:
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:
- 'audit/**'

permissions:
contents: read # checkout only
pull-requests: write # sticky PR comment

concurrency:
group: blast-radius-pulse-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
pulse:
name: Blast-radius verdict
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Install OPA
uses: open-policy-agent/setup-opa@v2.4.0 # pin to SHA before release
with:
version: "1.14.1"

- name: Run pulse against PR diff
id: pulse
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
mkdir -p "${GITHUB_WORKSPACE}/.pulse-out"
# Ensure the base ref is fetched (actions/checkout with fetch-depth:0
# gives us full history but origin/<base_ref> is not always a local
# tracking ref — refresh it).
git fetch --no-tags --prune origin "$BASE_REF"
git log --pretty=format:'%B%n' "origin/${BASE_REF}..HEAD" > "${GITHUB_WORKSPACE}/.pulse-out/pr-messages.txt"
bash scripts/pulse.sh \
--mode audit \
--diff-range "origin/${BASE_REF}...HEAD" \
--output-dir "${GITHUB_WORKSPACE}/.pulse-out" \
--commit-msg-file "${GITHUB_WORKSPACE}/.pulse-out/pr-messages.txt" \
--json > "${GITHUB_WORKSPACE}/.pulse-out/result.json" || pulse_exit=$?
pulse_exit="${pulse_exit:-0}"
echo "exit_code=${pulse_exit}" >> "$GITHUB_OUTPUT"
# Surface verdict + counts for downstream steps.
verdict="$(jq -r '.verdict' .pulse-out/verdict.json)"
errors="$(jq -r '.errors' .pulse-out/verdict.json)"
warnings="$(jq -r '.warnings' .pulse-out/verdict.json)"
{
echo "verdict=${verdict}"
echo "errors=${errors}"
echo "warnings=${warnings}"
} >> "$GITHUB_OUTPUT"

- name: Compute policy digest
id: digest
run: |
set -euo pipefail
if command -v sha256sum >/dev/null 2>&1; then
d="$(sha256sum conformance/blast_radius.rego | awk '{print $1}')"
else
d="$(shasum -a 256 conformance/blast_radius.rego | awk '{print $1}')"
fi
echo "policy_digest=$d" >> "$GITHUB_OUTPUT"

- name: Emit trace fragment
env:
POLICY_DIGEST: ${{ steps.digest.outputs.policy_digest }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_ACTOR: ${{ github.actor }}
VERDICT: ${{ steps.pulse.outputs.verdict }}
ERRORS: ${{ steps.pulse.outputs.errors }}
WARNINGS: ${{ steps.pulse.outputs.warnings }}
run: |
set -euo pipefail
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
verdict_json="$(cat .pulse-out/verdict.json)"
jq -nc \
--arg ts "$ts" \
--arg trace_id "br-pulse-${GITHUB_RUN_ID}-${PR_NUMBER}" \
--arg event "pulse_verdict" \
--argjson pr "$PR_NUMBER" \
--arg actor "$PR_ACTOR" \
--argjson v "$verdict_json" \
--arg verdict "$VERDICT" \
--argjson errors "$ERRORS" \
--argjson warnings "$WARNINGS" \
--arg digest "$POLICY_DIGEST" \
--arg run_url "$run_url" \
'{
ts: $ts,
trace_id: $trace_id,
event: $event,
pr_number: $pr,
pr_actor: $actor,
git_sha: $v.git_sha,
mode: "audit",
changed_files: ($v.inputs.changed_files // []),
json_changes: ($v.inputs.json_changes // {}),
fired: ($v.fired // []),
verdict: $verdict,
errors: $errors,
warnings: $warnings,
rule_applied: $v.rule_applied,
alternatives: $v.alternatives,
rationale: $v.rationale,
opa_policy_digest: $digest,
run_url: $run_url
}' > .pulse-out/trace-fragment.jsonl

- name: Post PR comment
if: always() && github.event.pull_request.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ -f .pulse-out/pr-comment.md ]; then
gh pr comment "$PR_NUMBER" --body-file .pulse-out/pr-comment.md || true
fi

- name: Upload pulse trace artifact
if: always()
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: blast-radius-pulse-${{ github.run_id }}
path: |
.pulse-out/opa-input.json
.pulse-out/opa-eval.stdout
.pulse-out/opa-eval.stderr
.pulse-out/verdict.json
.pulse-out/pr-comment.md
.pulse-out/pulse-report.txt
.pulse-out/trace-fragment.jsonl
retention-days: 90

- name: Fail on blocked verdict
if: steps.pulse.outputs.verdict == 'blocked'
run: |
echo "::error::blast-radius pulse blocked the PR (errors=${{ steps.pulse.outputs.errors }})"
exit 1
Loading
Loading