From 2b534469c4b92da4d65bdf8a5b0163ddb9fa9c5c Mon Sep 17 00:00:00 2001 From: Augusto Date: Mon, 27 Jul 2026 12:34:30 +0100 Subject: [PATCH] ci: check pull requests for an AI-use disclosure (SCPE level 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PR now gets a comment saying whether it carries an AI-use disclosure — an `Assisted-by:` commit trailer, or a ticked box in the new PR template. Using AI on this project is fine; the check is about saying so. Informational for now: `require: "false"` posts the result and never fails the check. Flipping it to "true" makes the disclosure a merge requirement. Nothing is installed on the runner and no key is involved at this level — the Action runs one stdlib-only Python file out of its own checkout at the pinned tag. Two files because a workflow that names itself in `workflow_run.workflows` cannot register with GitHub: scpe.yml runs contributor code with no secrets, scpe-seal.yml holds the write token and only posts the comment. https://github.com/augbastos/scpe --- .github/pull_request_template.md | 15 +++++++ .github/workflows/scpe-seal.yml | 73 ++++++++++++++++++++++++++++++++ .github/workflows/scpe.yml | 48 +++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/scpe-seal.yml create mode 100644 .github/workflows/scpe.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e0ea148 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## What this changes + + + +## AI use + +Tick exactly one. This is the signal the `scpe` check reads — an unticked box counts as +no disclosure at all. Using AI is fine here; not saying so is what this asks about. + +- [ ] I used generative AI +- [ ] I did not use generative AI + +Or put an `Assisted-by: ` trailer on a commit instead — same effect, and it +travels with the commit rather than the pull request. Checked by +[SCPE](https://github.com/augbastos/scpe) level 1. diff --git a/.github/workflows/scpe-seal.yml b/.github/workflows/scpe-seal.yml new file mode 100644 index 0000000..f277b67 --- /dev/null +++ b/.github/workflows/scpe-seal.yml @@ -0,0 +1,73 @@ +name: scpe-seal + +# Companion to scpe.yml — drop both in .github/workflows/. This is the TRUSTED half of +# the fork-safe split: it runs in the BASE repository with a write-scoped token, after +# the untrusted job finished, and its only job is to post what that job decided. +# +# It never checks out the contributor's code, never runs it, and installs nothing. The +# only binaries it touches are the runner's own pre-installed `python3` and `gh`. +# +# Why a separate file: a workflow cannot name itself in `workflow_run.workflows`. GitHub +# fails to register such a file — it shows the file path where the workflow name should +# be, and every run fails at the workflow level with zero jobs. So the trigger has to +# live in a second file that names the first. +# +# `workflow_run` only fires for a workflow file present on the DEFAULT branch. Merge +# both files to your default branch before expecting a seal on a pull request. +on: + workflow_run: + workflows: ["scpe"] + types: [completed] + +permissions: + contents: read + +jobs: + seal: # TRUSTED — write token; never runs untrusted code + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + # A job-level permissions block REPLACES the workflow default (it does not merge), + # so every unlisted scope is set to none. actions/download-artifact@v4 with run-id + # needs actions:read to reach a sibling run's artifact — without it the download + # fails and the gate never runs. + actions: read + steps: + - name: Download the verify job's results + uses: actions/download-artifact@v4 + with: + name: scpe-results + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Post the seal comment (or the gate failure) + env: + GH_TOKEN: ${{ github.token }} + run: | + pr="$(cat pr-number.txt)" + # The require DECISION was computed upstream, in the untrusted "verify" + # job (results.json's gate_pass) — this step only ever READS it, never + # recomputes it from raw contributor input. Missing key (require=false, + # the informational path) defaults to "pass": nothing changes. + gate_pass="$(python3 -c "import json; print(str(json.load(open('results.json')).get('gate_pass', True)).lower())")" + if [ "$gate_pass" = "false" ]; then + # results.json carries its own fail_message (naming the exact §8 status + # and the reason behind it, or level 1's missing disclosure) — use it + # verbatim; fall back to the generic message only for a results.json + # written by an older tag. Either way this is a READ, never a recompute. + msg="$(python3 -c "import json; d = json.load(open('results.json')); print(d.get('fail_message') or '❌ Not verifiable — this repository requires a signed SCPE contribution (spec scpe/0.1).')")" + gh pr comment "$pr" --repo "${{ github.repository }}" --body "$msg" + exit 1 + fi + # The comment is rendered upstream too, in the same untrusted job, and + # posted here byte-for-byte. That does NOT weaken the split: this job + # already posted attacker-influenced text before — every field in the seal + # comes from the contributor's PR — so moving the rendering does not + # change the trust level of the CONTENT, only where the string is built. + # What it does change is that the obligation to neutralize that content + # (fence escaping, length caps) now sits in one tested renderer instead of + # in this YAML. The trusted job stays free of checkouts, installs and + # contributor code. + msg="$(python3 -c "import json; print(json.load(open('results.json')).get('comment') or '')")" + [ -n "$msg" ] && gh pr comment "$pr" --repo "${{ github.repository }}" --body "$msg" diff --git a/.github/workflows/scpe.yml b/.github/workflows/scpe.yml new file mode 100644 index 0000000..11b84f6 --- /dev/null +++ b/.github/workflows/scpe.yml @@ -0,0 +1,48 @@ +name: scpe + +# SCPE level 1 — https://github.com/augbastos/scpe +# +# Checks that every pull request carries an AI-use disclosure: an `Assisted-by:` commit +# trailer, or a checked box in the PR template. Nothing is signed, no key is needed, and +# nothing is installed on the runner — the Action runs one stdlib-only Python file out of +# its own checkout at the tag pinned below. +# +# It is INFORMATIONAL right now: `require: "false"` posts the result as a comment and +# never fails the check. Flip it to "true" to make the disclosure a merge requirement. +# +# TWO FILES, NOT ONE. The companion scpe-seal.yml holds the write token and posts the +# comment; this file runs contributor code with no secrets. They cannot be merged: a +# workflow that names itself in `workflow_run.workflows` fails to register with GitHub. +on: + pull_request: + +permissions: + contents: read + +jobs: + verify: # UNTRUSTED — no secrets; safe to run contributor code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false # never write the token into .git/config + # Level 1 reads the commit messages in the PR range, and level 2 (if this repo + # ever moves up) recomputes `git diff BASE...HEAD` to check it against the + # signed digest. Neither is possible with the default shallow checkout. + fetch-depth: 0 + + - id: seal + uses: augbastos/scpe@v0.2.1 + with: + level: "1" # 1 = disclosure lint · 2 = signed envelope required + require: "false" # "true" makes a missing disclosure fail the check + + # Hand the verdict + PR number to the trusted job. The number MUST travel in the + # artifact: github.event.workflow_run.pull_requests is empty for fork PRs. + - run: echo "${{ github.event.pull_request.number }}" > pr-number.txt + - uses: actions/upload-artifact@v4 + with: + name: scpe-results + path: | + results.json + pr-number.txt