diff --git a/.github/actions/affirmation-check/action.yml b/.github/actions/affirmation-check/action.yml new file mode 100644 index 000000000..4ec8893cc --- /dev/null +++ b/.github/actions/affirmation-check/action.yml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 +name: 'Affirmation Document Gate' +description: 'Validates an applicable AFFIRMATION snapshot without treating it as a universal or self-proving document.' +inputs: + required: + description: 'Set true only when the repository declares the governance-tier capability.' + required: false + default: 'false' + is-template: + description: >- + Set true for a template repository, whose AFFIRMATION.adoc is expected to + carry {{…}} placeholders. Defaults to the repository's own is_template + flag, so template repos are exempt without per-repo configuration. + required: false + default: ${{ github.event.repository.is_template }} +runs: + using: 'composite' + steps: + - name: Run Affirmation Check + shell: bash + env: + AFFIRMATION_REQUIRED: ${{ inputs.required }} + AFFIRMATION_IS_TEMPLATE: ${{ inputs.is-template }} + run: "${{ github.action_path }}/check.sh" diff --git a/.github/actions/affirmation-check/check.sh b/.github/actions/affirmation-check/check.sh new file mode 100755 index 000000000..cd93c99d6 --- /dev/null +++ b/.github/actions/affirmation-check/check.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Validates an AFFIRMATION snapshot against AFFIRMATION-STANDARD.adoc. +# +# This gate asks the question the standard asks. Three rules it must respect, +# each of which a previous revision got wrong: +# +# 1. The standard permits the affirmation at the repo ROOT *or* under docs/. +# The canonical skeleton in rsr-template-repo ships at docs/AFFIRMATION.adoc, +# so a root-only search reports a present affirmation as missing. +# 2. The standard BANS AFFIRMATION.md ("as README.md is banned"). Accepting it +# would make the guard permit what the standard forbids, so a .md file is +# an error naming the fix, not a silently-accepted candidate. +# 3. A template repository ships placeholders BY DESIGN. Without an exemption +# the gate fails exactly the repos whose job is to carry the template. + +set -euo pipefail + +root=${GITHUB_WORKSPACE:-.} +required=${AFFIRMATION_REQUIRED:-false} +is_template=${AFFIRMATION_IS_TEMPLATE:-false} +aff_file= + +# Rule 2: a banned .md spelling is a named error, never a fallback. +for banned in AFFIRMATION.md docs/AFFIRMATION.md AFFIRMATION; do + if [[ -f "$root/$banned" ]]; then + echo "::error::$banned is banned by AFFIRMATION-STANDARD.adoc (AsciiDoc only, as README.md is banned). Rename it to ${banned%.md}.adoc." + exit 1 + fi +done + +# Rule 1: root OR docs/, in the standard's stated order of preference. +for candidate in AFFIRMATION.adoc docs/AFFIRMATION.adoc; do + if [[ -f "$root/$candidate" ]]; then + aff_file=$candidate + break + fi +done + +if [[ -z "$aff_file" ]]; then + if [[ "$required" == "true" ]]; then + echo "::error::AFFIRMATION.adoc is required by the declared governance-tier capability (looked in ./ and ./docs/)." + exit 1 + fi + echo "::notice::AFFIRMATION is not applicable: governance-tier was not required." + exit 0 +fi + +path=$root/$aff_file +echo "Found AFFIRMATION document: $aff_file" + +substantive_lines=$(awk '!/^[[:space:]]*(#|\/\/|;|$)/ { count++ } END { print count + 0 }' "$path") +if (( substantive_lines < 5 )); then + echo "::error::$aff_file has only $substantive_lines substantive lines; it is a stub, not an affirmation." + exit 1 +fi + +# Rule 3: a template repo is MEANT to carry placeholders; everyone else is not. +placeholder_re='\{\{|TODO: update| anchor fields)." + exit 1 + fi +fi + +# The signature is the signature on the commit containing this content. Text +# such as "Signed:" inside the document proves nothing. +# +# Rule 4: `N` is a VERDICT, not a missing reading. `git log --format=%G?` +# returns `N` for "this commit carries no signature at all" -- which is a +# measured violation of the standard, whose own worked example is +# `git commit -S -s docs/AFFIRMATION.adoc`. A previous revision initialised +# this variable to `N` as its "nothing to report" sentinel, so the genuinely +# unsigned case shared an arm with the cases where no reading was possible and +# fell through to a `::notice::`. A notice cannot fail a job, so an unsigned +# affirmation passed a gate that exists to establish the affirmation is signed +# -- the self-refuting outcome the standard warns about. +# +# The three states are now distinct, because only one of them is unknowable: +# unavailable the working tree is not a Git repository at all +# "" Git is present but the file has no commit in the available +# history (a shallow checkout) -- honestly indeterminate +# N Git read the commit and it is unsigned -- a verdict, so fatal +signature=unavailable +last_update_ts= +if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + signature=$(git -C "$root" log -1 --format='%G?' -- "$aff_file" 2>/dev/null || true) + last_update_ts=$(git -C "$root" log -1 --format='%at' -- "$aff_file" 2>/dev/null || true) +fi + +case "$signature" in + G) echo "Affirmation commit signature verified with a trusted key." ;; + U) echo "::notice::Affirmation commit has a valid signature from an untrusted or locally unknown key." ;; + X|Y) echo "::warning::Affirmation commit signature is valid but the signature or its key has expired." ;; + B|R|E) echo "::error::Affirmation commit signature is bad, revoked, or failed verification."; exit 1 ;; + N) echo "::error::Affirmation commit is UNSIGNED. AFFIRMATION-STANDARD.adoc requires a signed commit (git commit -S -s). An unsigned affirmation attests to nothing."; exit 1 ;; + "") echo "::notice::Affirmation commit is not in the available Git history (shallow checkout), so its signature could not be read. This is a limitation of the checkout, not a verdict; fetch full history to verify." ;; + unavailable) echo "::notice::Not a Git repository, so no commit signature could be read." ;; + *) echo "::error::Unrecognised commit signature status: ${signature}. Failing closed: a governance gate must not pass a status it cannot interpret."; exit 1 ;; +esac + +# A dated affirmation is a frozen receipt, not a claim that remains current +# forever. Report age without invalidating historical evidence or forcing an +# empty monthly rewrite. +if [[ -n "$last_update_ts" ]]; then + current_ts=$(date +%s) + age_days=$(( (current_ts - last_update_ts) / 86400 )) + if (( age_days > 28 )); then + echo "::warning::$aff_file is a $age_days-day-old snapshot; verify its anchor before relying on it as current." + else + echo "$aff_file snapshot age: $age_days days." + fi +fi + +echo "AFFIRMATION document validation passed." diff --git a/.github/actions/affirmation-check/test.sh b/.github/actions/affirmation-check/test.sh new file mode 100755 index 000000000..9d49cbe4b --- /dev/null +++ b/.github/actions/affirmation-check/test.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Controls for the affirmation gate. Each control names the specific defect it +# would catch; a control that cannot fail against a broken gate proves nothing. + +set -euo pipefail + +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +fixture=$(mktemp -d) +trap 'rm -rf -- "$fixture"' EXIT + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n' "$1" >&2; exit 1; } + +# `if` context is used so that set -e does not fire on an expected non-zero exit. +expect_pass() { + local desc=$1; shift + if env GITHUB_WORKSPACE="$fixture" "$@" "$here/check.sh" >/dev/null 2>&1 + then pass "$desc"; else fail "$desc (expected exit 0)"; fi +} +expect_fail() { + local desc=$1; shift + if env GITHUB_WORKSPACE="$fixture" "$@" "$here/check.sh" >/dev/null 2>&1 + then fail "$desc (expected non-zero exit)"; else pass "$desc"; fi +} + +reset_fixture() { rm -rf -- "${fixture:?}"/* ; mkdir -p "$fixture/docs"; } + +good_affirmation() { + printf '%s\n' \ + '= AFFIRMATION — controlled fixture' \ + 'This snapshot makes a falsifiable claim.' \ + 'The claim is anchored to a named revision.' \ + 'Tests were run and their scope is stated.' \ + 'Unproved properties are not called proved.' \ + 'Later revisions must be assessed separately.' \ + > "$1" +} + +echo 'affirmation-check controls' + +# --- baseline behaviour ------------------------------------------------------ +reset_fixture +expect_pass 'absent + not required passes' AFFIRMATION_REQUIRED=false +expect_fail 'absent + required fails' AFFIRMATION_REQUIRED=true + +printf '= AFFIRMATION\n' > "$fixture/AFFIRMATION.adoc" +expect_fail 'stub fails' AFFIRMATION_REQUIRED=true + +reset_fixture +good_affirmation "$fixture/AFFIRMATION.adoc" +expect_pass 'valid affirmation at root passes' AFFIRMATION_REQUIRED=true + +# --- defect 1: the standard permits docs/, and the canonical rsr-template-repo +# skeleton ships at docs/AFFIRMATION.adoc. A root-only gate FAILS here. +reset_fixture +good_affirmation "$fixture/docs/AFFIRMATION.adoc" +expect_pass 'valid affirmation at docs/ passes' AFFIRMATION_REQUIRED=true + +# --- defect 2: AFFIRMATION.md is banned by the standard. A gate that lists it +# as a fallback candidate FAILS both of these. +reset_fixture +good_affirmation "$fixture/AFFIRMATION.md" +expect_fail 'AFFIRMATION.md is rejected as banned' AFFIRMATION_REQUIRED=false + +reset_fixture +good_affirmation "$fixture/docs/AFFIRMATION.md" +expect_fail 'docs/AFFIRMATION.md is rejected as banned' AFFIRMATION_REQUIRED=false + +# --- placeholders ------------------------------------------------------------ +reset_fixture +good_affirmation "$fixture/AFFIRMATION.adoc" +printf '= AFFIRMATION — {{PROJECT_NAME}}\n' >> "$fixture/AFFIRMATION.adoc" +expect_fail 'placeholders fail a normal repo' AFFIRMATION_REQUIRED=true + +# --- defect 3: a template repo ships placeholders by design. +# A gate with no exemption FAILS here. +expect_pass 'placeholders are exempt in a template repo' \ + AFFIRMATION_REQUIRED=true AFFIRMATION_IS_TEMPLATE=true + +# unreplaced anchor fields are placeholders too +reset_fixture +good_affirmation "$fixture/AFFIRMATION.adoc" +printf '| Commit (HEAD)\n| ``\n' >> "$fixture/AFFIRMATION.adoc" +expect_fail 'unreplaced anchor field fails' AFFIRMATION_REQUIRED=true + + +# --- defect 4: an UNSIGNED commit is a VERDICT, not a missing reading -------- +# A gate that initialises its signature variable to `N` as a "nothing to +# report" sentinel routes the genuinely-unsigned case into the same +# catch-all arm as the cases where no reading was possible, and emits a +# ::notice::. A notice cannot fail a job, so the previous revision PASSED +# an unsigned affirmation -- while existing on order to establish that the +# affirmation is signed. These controls separate the three states. +gitfix=$(mktemp -d) +trap 'rm -rf -- "$fixture" "$gitfix"' EXIT +mkdir -p "$gitfix/docs" +good_affirmation "$gitfix/docs/AFFIRMATION.adoc" +git -C "$gitfix" init -q +git -C "$gitfix" config user.name 'Control Fixture' +git -C "$gitfix" config user.email 'control@example.invalid' +git -C "$gitfix" config commit.gpgsign false +git -C "$gitfix" add -A + +# Git is available, but the file has no commit in the available history -- the +# shallow-checkout case. Genuinely indeterminate, so it must NOT be fatal. +expect_pass 'uncommitted affirmation is indeterminate, not fatal' \ + AFFIRMATION_REQUIRED=true GITHUB_WORKSPACE="$gitfix" + +# The mutant-killer. Against the previous revision this control PASSES the gate +# and therefore FAILS the suite, which is what makes it a control rather than a +# decoration. +git -C "$gitfix" commit -q --no-gpg-sign -m 'control: unsigned affirmation' +expect_fail 'UNSIGNED affirmation commit is rejected' \ + AFFIRMATION_REQUIRED=true GITHUB_WORKSPACE="$gitfix" + +# --- positive control: a genuinely signed commit must still pass ------------- +# A suite that only proves the gate can FAIL would be satisfied by a gate +# that rejects everything. The signing key is generated by this test, so +# the control needs no external secret and no key material in the repo. +keydir="$gitfix/.control-keys" +mkdir -p "$keydir" +ssh-keygen -q -t ed25519 -N '' -C 'affirmation-control' -f "$keydir/id" "$keydir/allowed_signers" +git -C "$gitfix" config gpg.format ssh +git -C "$gitfix" config user.signingkey "$keydir/id.pub" +git -C "$gitfix" config gpg.ssh.allowedSignersFile "$keydir/allowed_signers" + +printf '\nA further controlled revision of the same claim.\n' >> "$gitfix/docs/AFFIRMATION.adoc" +git -C "$gitfix" add -A +git -C "$gitfix" commit -q -S -m 'control: signed affirmation' +# Assert the fixture really produced a trusted signature; otherwise the control +# below would pass for the wrong reason and prove nothing. +sig=$(git -C "$gitfix" log -1 --format='%G?' -- docs/AFFIRMATION.adoc) +[[ $sig == G ]] || fail "fixture did not produce a trusted signature (got '$sig')" +expect_pass 'signed affirmation commit passes' \ + AFFIRMATION_REQUIRED=true GITHUB_WORKSPACE="$gitfix" + +echo 'affirmation-check controls passed'