Skip to content
Merged
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
35 changes: 34 additions & 1 deletion docs/AFFIRMATION-STANDARD.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
:toc-title: Contents
:icons: font
:doctype: article
Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> v1.0, 2026-09-09
Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> v1.1, 2026-10-07

[NOTE]
====
Expand Down Expand Up @@ -220,6 +220,36 @@ file. If the SHA in the anchor matches the parent of that commit and the commit
verifies, the affirmation is anchored. If they do not match, the file is a draft
and MUST be read as one.

[[linear-history]]
=== Landing in a linear-history repository

Many estate repositories enforce `required_linear_history`, or allow squash as
the only merge form. There, the owner's signed commit cannot reach the default
branch unchanged. A squash merge creates a new commit with a new SHA, signed by
the forge rather than by the owner. Rebase-merge is no better: it also rewrites
the commit, and the estate disables it.

In such a repository an affirmation landed by squash is *anchored* when all
four of these hold. Write `A` for the anchor SHA, `S` for the owner's signed
commit (the PR head), and `M` for the squash commit on the default branch.

. `S` verifies as signed by the owner, and its parent is `A`.
. `M` is on the default branch, and its first parent is `A`. Merge the PR only
while the default branch is still at `A`. If the branch has moved, re-anchor:
re-run the checks at the new head, update the anchor, and sign again.
. `M` and `S` have the same tree, so the squash changed nothing the owner
signed.
. `S` stays retrievable at `refs/pull/<N>/head`, which the forge keeps after the
PR branch is deleted.

The owner's signature then covers exactly the content on the default branch,
and the parent check ties it to the anchor. A repository that allows merge
commits SHOULD still land the signed commit itself with a merge commit; the
four conditions are the rule for repositories where that is impossible.

`scripts/verify-affirmation-anchor.sh <owner/repo> <PR>` checks all four
conditions and exits non-zero when any one fails.

== Required format

* AsciiDoc (`.adoc`) β€” not Markdown.
Expand Down Expand Up @@ -281,6 +311,9 @@ Do not::
conclusion.
* Write "all tests pass" without the command and the count.
* Record an anchor SHA that is not the commit the checks actually ran against.
* Squash an affirmation PR after the default branch has moved past its anchor.
The squash commit then has a parent the checks never ran against; see
<<linear-history>>.
* Quietly drop a claim that was refuted. Refutations belong in *Outstanding /
weak / refuted*; deleting them is the spin the genre exists to prevent.

Expand Down
78 changes: 78 additions & 0 deletions scripts/verify-affirmation-anchor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# Verify that a merged AFFIRMATION PR is anchored, per
# docs/AFFIRMATION-STANDARD.adoc <<linear-history>>.
#
# scripts/verify-affirmation-anchor.sh <owner/repo> <PR> [ANCHOR_SHA]
#
# A = anchor (read from the affirmation file's "Commit (HEAD)" row unless
# given), S = the PR head (the owner's signed commit), M = the commit the PR
# landed as. Checks:
# 1. S is verified-signed, authored by OWNER (default hyperpolymath), parent A
# 2. M is on the default branch and its first parent is A
# 3. tree(M) == tree(S)
# 4. refs/pull/<N>/head still resolves to S
# A merge commit (M has S as a parent) satisfies 2 and 3 trivially.
# Exits 0 when all hold, 1 when any fails, 2 on usage or API error.
set -uo pipefail

OWNER=${OWNER:-hyperpolymath}
[ $# -ge 2 ] || { echo "usage: $0 <owner/repo> <PR> [ANCHOR_SHA]" >&2; exit 2; }

Check failure on line 20 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNf&open=AaEeFjnWcKaHeYrM3CNf&pullRequest=1212
R=$1 N=$2 A=${3:-}
fail=0

# api PATH JQ -- one gh api call; exits 2 on an API error, so an error body
# can never be mistaken for a value.
api() {
local out
out=$(gh api "repos/$R/$1" -q "$2" 2>&1) || { echo "API error on $1: $out" >&2; exit 2; }
printf '%s' "$out"
}

# is_sha VALUE -- true when VALUE is a full 40-hex SHA.
is_sha() { [[ $1 =~ ^[0-9a-f]{40}$ ]]; }

Check warning on line 33 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNh&open=AaEeFjnWcKaHeYrM3CNh&pullRequest=1212

Check warning on line 33 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNg&open=AaEeFjnWcKaHeYrM3CNg&pullRequest=1212

# check LABEL CONDITION... -- print PASS/FAIL for one condition, record a fail.
check() {

Check warning on line 36 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNi&open=AaEeFjnWcKaHeYrM3CNi&pullRequest=1212
local label=$1; shift
if "$@"; then echo "PASS $label"; else echo "FAIL $label"; fail=1; fi
}

merged=$(api "pulls/$N" .merged)
[ "$merged" = true ] || { echo "FAIL PR #$N is not merged"; exit 1; }

Check failure on line 42 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNj&open=AaEeFjnWcKaHeYrM3CNj&pullRequest=1212
S=$(api "pulls/$N" .head.sha)
M=$(api "pulls/$N" .merge_commit_sha)
base=$(api "pulls/$N" .base.ref)
if ! is_sha "$S" || ! is_sha "$M"; then
echo "unexpected SHA shape: S=$S M=$M" >&2; exit 2
fi

if [ -z "$A" ]; then

Check failure on line 50 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNk&open=AaEeFjnWcKaHeYrM3CNk&pullRequest=1212
file=$(api "pulls/$N/files" '[.[].filename|select(test("AFFIRMATION";"i"))][0] // ""')
[ -n "$file" ] || { echo "no AFFIRMATION file in PR #$N; pass ANCHOR_SHA" >&2; exit 2; }

Check failure on line 52 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNl&open=AaEeFjnWcKaHeYrM3CNl&pullRequest=1212
A=$(gh api "repos/$R/contents/$file?ref=$M" -H 'Accept: application/vnd.github.raw' 2>/dev/null \
| grep -A2 -i 'Commit (HEAD)' | grep -oE '\b[0-9a-f]{40}\b' | head -1)
is_sha "$A" || { echo "could not read the anchor from $file; pass ANCHOR_SHA" >&2; exit 2; }
fi
echo "repo=$R pr=#$N anchor=$A head(S)=$S landed(M)=$M base=$base"

s_parent=$(api "commits/$S" '.parents[0].sha')
s_verified=$(api "commits/$S" .commit.verification.verified)
s_author=$(api "commits/$S" '.author.login // ""')
check "1 S signed (verified=$s_verified, author=$s_author)" \
test "$s_verified" = true -a "$s_author" = "$OWNER"
check "1 S parent is the anchor ($s_parent)" test "$s_parent" = "$A"

m_parent=$(api "commits/$M" '.parents[0].sha')
on_base=$(api "compare/$M...$base" .status)
check "2 M on $base (compare=$on_base)" test "$on_base" = ahead -o "$on_base" = identical
check "2 M first parent is the anchor ($m_parent)" test "$m_parent" = "$A"

check "3 tree(M) == tree(S)" \
test "$(api "commits/$M" .commit.tree.sha)" = "$(api "commits/$S" .commit.tree.sha)"

pull_head=$(api "git/ref/pull/$N/head" .object.sha)
check "4 refs/pull/$N/head is S" test "$pull_head" = "$S"

[ $fail = 0 ] && echo "ANCHORED" || echo "DRAFT: the affirmation must be read as a draft"

Check failure on line 77 in scripts/verify-affirmation-anchor.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEeFjnWcKaHeYrM3CNm&open=AaEeFjnWcKaHeYrM3CNm&pullRequest=1212
exit $fail
Loading