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
124 changes: 124 additions & 0 deletions .github/actions/squabble/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: CC-BY-SA-4.0
// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
= squabble action — install the pinned, attested squabble binary
:toc: preamble

A composite action that installs one exact squabble release binary in a
workflow and, optionally, runs it. It refuses any file whose sha256 is not
the pinned digest, and any file whose build provenance does not trace back
to this repository's release workflow at the pinned tag and source commit.
Nothing is executed until both checks pass.

== Usage

Pin the action to a full commit SHA of this repository, never to a branch or
tag:

[source,yaml]
----
# Install only: squabble is on PATH from the next step on.
- uses: hyperpolymath/cicd-squabbler/.github/actions/squabble@<40-hex sha>
id: squabble
- run: squabble --version
----

Run mode passes `args` to squabble, one argument per line, taken literally
(no shell parsing, no globbing; blank lines are dropped). The step fails
when squabble exits non-zero, and `run.sh` records the exit code as the
`exit-code` output (see <<_not_yet_tested>> for what CI has not yet shown).

[source,yaml]
----
- uses: hyperpolymath/cicd-squabbler/.github/actions/squabble@<40-hex sha>
with:
args: |
verify-satisfied
${{ github.repository }}
${{ github.event.pull_request.number }}
token: ${{ steps.app-token.outputs.token }}
----

=== Inputs and outputs

[cols="1,3"]
|===
| `args` (input) | squabble's arguments, one per line. Empty (the default) means install only.
| `token` (input) | `GH_TOKEN` for squabble in run mode. Defaults to `github.token`. `verify-satisfied` reads branch rules, which need *Administration: read*; `github.token` cannot hold that, so pass a GitHub App installation token for it.
| `path` (output) | Absolute path of the installed binary.
| `exit-code` (output) | squabble's exit code in run mode; empty when `args` is empty.
|===

The download and the attestation check always use the workflow's own
`github.token`, never `token`: an App token minted for the caller's
repository need not reach this one. This repository is public, so
`github.token` is enough for both.

== What is checked, in order

`install.sh` does all of this before the binary is made executable:

. The runner is Linux on x86_64. That is the only asset published.
. The release asset is downloaded into a fresh directory.
. Its sha256 must equal the pinned digest.
. `gh attestation verify --format json` must accept it with this repository,
the signer workflow `.github/workflows/release.yml`, the source ref
`refs/tags/<tag>`, the pinned source commit, the SLSA provenance v1
predicate, and `--deny-self-hosted-runners`. The JSON is then read
back: it must be a non-empty array in which every result names the asset
with the pinned digest. The JSON form is used because the plain-text output
has been seen empty, with exit 0, when stdout is not a terminal.
. Only then is the file made executable, and it must report the pinned
version (`squabble 0.1.0`).

== Pins

[cols="1,3"]
|===
| Release tag | `v0.1.0` (a locator, not a trust anchor)
| Asset | `squabble-x86_64-linux-musl`
| Asset sha256 | `6cbeb4577d83ccf2dea3405a3afb0d34b7fee422af73f2113d413ea7edba026c`
| Source commit | `b854d17abc0dce296719a349ebe856ff945cf03e`
| Signer workflow | `hyperpolymath/cicd-squabbler/.github/workflows/release.yml`
|===

== Bumping to a new release

Read every value from the release itself, never from a tag name alone.

. `gh release download <tag> --repo hyperpolymath/cicd-squabbler --pattern
squabble-x86_64-linux-musl --pattern SHA256SUMS`, then `sha256sum -c
SHA256SUMS --ignore-missing`.
. Find the commit the tag points to: `git rev-parse <tag>^{commit}`.
. Run `gh attestation verify` on the asset with the flags `install.sh` uses
and that commit as `--source-digest`. It must pass.
. Set `SQUABBLE_TAG`, `SQUABBLE_SHA256` and `SQUABBLE_SOURCE_DIGEST` in
`install.sh`, and the expected version string in
`.github/workflows/squabble-action.yml`.
. Run `bash tests/squabble_action_test.sh`. It must end with every planned
case run and none failed.

== How it is tested

`tests/squabble_action_test.sh` exercises each guard on its own, with
planted negatives: the attestation JSON check against hand-built fixtures,
`run.sh` against a stand-in binary, the digest and attestation checks against
the real asset and a copy with one byte appended, and `install.sh` end to end
with a tampered download, which must be refused and must leave PATH and the
step outputs untouched. It also checks that `action.yml` runs exactly those
two scripts with exactly the tested environment, and refuses four planted
mutants of it. The run fails unless every planned case ran.

`.github/workflows/squabble-action.yml` runs that suite, then runs
`install.sh` and `run.sh` as the action's steps do on a real runner, and
checks that squabble is on PATH in a later step and that exit codes come
back as step outputs.

=== Not yet tested

GitHub's own evaluation of `action.yml` (composite inputs, outputs and the
`if:` on the run step) is not exercised here. This repository has an
`actions.lock`, and the lockfile does not support local-path actions, so a
workflow here cannot call the action as `uses: ./.github/actions/squabble`.
It is first exercised by a caller that pins the action at a merged commit of
`main`, which the lockfile does support. That caller should check in
particular that `exit-code` is readable when the run step fails.
68 changes: 68 additions & 0 deletions .github/actions/squabble/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# SPDX-License-Identifier: MPL-2.0
#
# squabble: install the pinned, attested squabble release binary and,
# optionally, run it. Call it pinned to a commit of this repository:
#
# uses: hyperpolymath/cicd-squabbler/.github/actions/squabble@<40-hex sha>
#
# The binary itself is pinned inside install.sh by sha256 and by its
# build-provenance attestation. See README.adoc next to this file.
#
# This file is KYAML (standards YAML-POLICY, D280). Regenerate it with
# standards/scripts/kyaml-format.sh after an edit; keep each run: to one line
# that calls a script, so the KYAML stays readable.
{
name: "squabble",
description: "Install the pinned squabble release (sha256 and build attestation checked), and optionally run it.",
author: "hyperpolymath",
inputs: {
args: {
description: "Arguments for squabble, one per line, taken literally. Empty means install only.",
required: false,
default: "",
},
token: {
description: "GH_TOKEN for squabble itself in run mode. verify-satisfied needs Administration:read, which github.token cannot hold, so pass an App token for it. Installing always uses github.token.",
required: false,
default: "${{ github.token }}",
},
},
outputs: {
path: {
description: "Absolute path of the installed squabble binary.",
value: "${{ steps.install.outputs.path }}",
},
exit-code: {
description: "squabble's exit code in run mode; empty when args is empty.",
value: "${{ steps.run.outputs.exit-code }}",
},
},
runs: {
using: "composite",
steps: [
# Install with the workflow's own token, never the caller's run-mode token:
# an App installation token for the caller's repo need not reach this one.
{
name: "Install pinned squabble (sha256 + attestation)",
id: "install",
shell: "bash",
env: {
GH_TOKEN: "${{ github.token }}",
},
run: "bash \"${GITHUB_ACTION_PATH}/install.sh\"",
},
{
name: "Run squabble",
id: "run",
if: "inputs.args != ''",
shell: "bash",
env: {
GH_TOKEN: "${{ inputs.token }}",
SQUABBLE_ARGS: "${{ inputs.args }}",
SQUABBLE_BIN: "${{ steps.install.outputs.path }}",
},
run: "bash \"${GITHUB_ACTION_PATH}/run.sh\"",
},
],
},
}
146 changes: 146 additions & 0 deletions .github/actions/squabble/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
#
# install.sh — install the pinned squabble release binary, or refuse.
#
# The order is the whole point of this file:
#
# 1. download the release asset into a fresh directory;
# 2. refuse it unless its sha256 equals SQUABBLE_SHA256, before anything
# else reads or runs the file;
# 3. refuse it unless `gh attestation verify` proves it was built by this
# repository's release.yml, from the pinned tag AND the pinned source
# commit, on a GitHub-hosted runner, and the attested subject for the
# asset carries that same digest;
# 4. only then make it executable, smoke-test its version and put it on PATH.
#
# The tag is a locator, not a trust anchor: the digest is the anchor, and the
# attestation ties that digest to the source commit. The plain-text output of
# `gh attestation verify` has been seen EMPTY with exit 0 when stdout is not a
# terminal (2026-10-09), so the JSON form is read and checked here instead.
#
# Bumping squabble means changing SQUABBLE_TAG, SQUABBLE_SHA256 and
# SQUABBLE_SOURCE_DIGEST to a new release's values, each read from that
# release itself, never copied from a tag name; README.adoc next to this file
# gives the steps.
#
# Sourcing this file defines the functions without running main, so
# tests/squabble_action_test.sh can exercise each guard on its own.

set -euo pipefail

readonly SQUABBLE_REPO="hyperpolymath/cicd-squabbler"
readonly SQUABBLE_TAG="v0.1.0"
readonly SQUABBLE_ASSET="squabble-x86_64-linux-musl"
readonly SQUABBLE_SHA256="6cbeb4577d83ccf2dea3405a3afb0d34b7fee422af73f2113d413ea7edba026c"
readonly SQUABBLE_SOURCE_DIGEST="b854d17abc0dce296719a349ebe856ff945cf03e"
readonly SQUABBLE_SIGNER_WORKFLOW="hyperpolymath/cicd-squabbler/.github/workflows/release.yml"

# die MESSAGE... — print MESSAGE as a GitHub Actions error annotation and exit 1.
die() {
printf '::error::%s\n' "$*" >&2
exit 1
}

# require_platform — refuse any machine that cannot execute the x86_64 Linux asset.
require_platform() {
local os arch
os="$(uname -s)"
arch="$(uname -m)"
if [[ "$os" != "Linux" || "$arch" != "x86_64" ]]; then
die "squabble action supports only x86_64 Linux runners (this is ${os}/${arch})"
fi
}

# verify_digest FILE — succeed only if FILE exists and its sha256 equals SQUABBLE_SHA256.
verify_digest() {
local file="$1" actual
if [[ ! -f "$file" ]]; then
printf '::error::%s does not exist\n' "$file" >&2
return 1
fi
actual="$(sha256sum -- "$file")"
actual="${actual%% *}"
if [[ "$actual" != "$SQUABBLE_SHA256" ]]; then
printf '::error::%s has sha256 %s, expected %s; refusing it\n' \
"$file" "$actual" "$SQUABBLE_SHA256" >&2
return 1
fi
}

# check_attestation_json FILE — succeed only if FILE holds a non-empty JSON array
# of verification results, each of which attests SQUABBLE_ASSET, and only with
# the digest SQUABBLE_SHA256. Other subjects in the same statement (the release
# also attests SHA256SUMS) are allowed; an empty array is a refusal, not a pass.
check_attestation_json() {
local json="$1"
jq -e --arg name "$SQUABBLE_ASSET" --arg sha "$SQUABBLE_SHA256" '
type == "array" and length > 0 and all(.[];
[.verificationResult.statement.subject[]?
| select(.name == $name) | .digest.sha256] as $d
| ($d | length) > 0 and all($d[]; . == $sha))
' "$json" >/dev/null
}

# verify_attestation FILE — succeed only if GitHub's build-provenance attestation
# for FILE was signed by SQUABBLE_SIGNER_WORKFLOW at SQUABBLE_TAG, built from
# SQUABBLE_SOURCE_DIGEST on a GitHub-hosted runner, and names the pinned digest.
verify_attestation() {
local file="$1" json rc=0
json="${file}.attestation.json"
gh attestation verify "$file" \
--repo "$SQUABBLE_REPO" \
--signer-workflow "$SQUABBLE_SIGNER_WORKFLOW" \
--source-ref "refs/tags/${SQUABBLE_TAG}" \
--source-digest "$SQUABBLE_SOURCE_DIGEST" \
--predicate-type "https://slsa.dev/provenance/v1" \
--deny-self-hosted-runners \
--format json >"$json" || rc=$?
if (( rc != 0 )); then
printf '::error::gh attestation verify refused %s (exit %d)\n' "$file" "$rc" >&2
return 1
fi
if ! check_attestation_json "$json"; then
printf '::error::the attestation for %s does not name %s with sha256 %s\n' \
"$file" "$SQUABBLE_ASSET" "$SQUABBLE_SHA256" >&2
return 1
fi
}

# main — download, verify and install squabble; on success append its directory
# to GITHUB_PATH and write `path=<binary>` to GITHUB_OUTPUT when those are set.
main() {
local work asset bin version
require_platform
work="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/squabble.XXXXXX")"
mkdir -p "${work}/bin"
gh release download "$SQUABBLE_TAG" \
--repo "$SQUABBLE_REPO" \
--pattern "$SQUABBLE_ASSET" \
--dir "${work}/download"
asset="${work}/download/${SQUABBLE_ASSET}"

verify_digest "$asset" || die "squabble ${SQUABBLE_TAG}: download does not match the pinned sha256"
verify_attestation "$asset" || die "squabble ${SQUABBLE_TAG}: attestation check failed"

bin="${work}/bin/squabble"
mv -- "$asset" "$bin"
chmod 0755 "$bin"
version="$("$bin" --version)"
if [[ "$version" != "squabble ${SQUABBLE_TAG#v}" ]]; then
die "installed binary reports '${version}', expected 'squabble ${SQUABBLE_TAG#v}'"
fi

if [[ -n "${GITHUB_PATH:-}" ]]; then
printf '%s\n' "${work}/bin" >>"$GITHUB_PATH"
fi
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
printf 'path=%s\n' "$bin" >>"$GITHUB_OUTPUT"
fi
printf 'installed %s at %s (sha256:%s, attestation verified)\n' \
"$version" "$bin" "$SQUABBLE_SHA256" >&2
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
39 changes: 39 additions & 0 deletions .github/actions/squabble/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
#
# run.sh — run the installed squabble with the arguments in SQUABBLE_ARGS,
# record its exit code as the step output `exit-code`, and exit with it.
#
# SQUABBLE_ARGS holds one argument per line, taken literally: no shell
# parsing, no globbing, no word splitting. Blank lines are dropped, so the
# trailing newline of a YAML block scalar adds no empty argument.
#
# The exit code is captured with `|| rc=$?`. The runner's shell is
# `bash -e`, under which `cmd; rc=$?` never reaches the assignment.

set -euo pipefail

# main — split SQUABBLE_ARGS on newlines, run SQUABBLE_BIN with them, write
# exit-code to GITHUB_OUTPUT when it is set, and exit with squabble's code.
main() {
local line rc=0
local -a args=()
if [[ ! -x "${SQUABBLE_BIN:-}" ]]; then
printf '::error::SQUABBLE_BIN (%s) is not an executable file\n' "${SQUABBLE_BIN:-}" >&2
exit 1
fi
while IFS= read -r line; do
if [[ -n "$line" ]]; then
args+=("$line")
fi
done <<<"${SQUABBLE_ARGS:-}"

"$SQUABBLE_BIN" "${args[@]}" || rc=$?

if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
printf 'exit-code=%d\n' "$rc" >>"$GITHUB_OUTPUT"
fi
exit "$rc"
}

main "$@"
1 change: 1 addition & 0 deletions .github/workflows/actions.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# This file is machine-generated by `gh actions-lock`.

Check failure on line 1 in .github/workflows/actions.lock

View workflow job for this annotation

GitHub Actions / Hypatia neurosymbolic scan

[hypatia] actions.lock failed closed: {:line, 254, {:duplicate_dependency, "swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6"}}

Check failure on line 1 in .github/workflows/actions.lock

View workflow job for this annotation

GitHub Actions / Hypatia neurosymbolic scan

[hypatia] Invalid .github/workflows/actions.lock: {:line, 254, {:duplicate_dependency, "swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6"}}. Regenerate and verify it with gh actions-lock.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.2'
Expand Down Expand Up @@ -58,6 +58,7 @@
- 'hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c'
'.github/workflows/security-policy.yml':
- 'actions/checkout@v7.0.1'
'.github/workflows/squabble-action.yml': []
'.github/workflows/standards-pipeline.yml':
- 'hyperpolymath/standards@ed5e3f651305dd1ce0d0b5d2d08b97a963634632'
'.github/workflows/static-analysis-gate.yml':
Expand Down
Loading
Loading