From 2edc515d3e23e69d017910d8e9146f5fd0e1ecc0 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:37:00 +0100 Subject: [PATCH] =?UTF-8?q?feat(action):=20squabble=20consumer=20action=20?= =?UTF-8?q?=E2=80=94=20pinned=20sha256=20+=20attestation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .github/actions/squabble, a composite action that installs the squabble v0.1.0 release binary for other workflows and optionally runs it. install.sh downloads the asset into a fresh directory and refuses it unless (1) its sha256 equals the pinned digest and (2) gh attestation verify --format json accepts it for this repo's release.yml at refs/tags/v0.1.0, source commit b854d17a, SLSA provenance v1, no self-hosted runner, and the JSON names the asset with that digest. Only then is it made executable, version-checked and put on PATH. run.sh passes args one per line, literally, and records exit-code. tests/squabble_action_test.sh (27 cases) checks action.yml runs exactly the tested scripts and env (4 planted mutants refused), the JSON check offline, run.sh against a stand-in binary, digest and attestation against the real asset and a one-byte tampered copy, and install.sh end to end with a shimmed tampered download (refused, PATH untouched). The new squabble-action.yml workflow runs the suite and then the two scripts as the composite does. It has no uses: at all: actions.lock does not support local-path actions (gh actions-lock --no-fix refuses `uses: ./`), so GitHub's evaluation of action.yml itself is first exercised by a caller pinning a merged main SHA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015bTuGfwCcvjrmNFejydTML --- .github/actions/squabble/README.adoc | 124 +++++++++++ .github/actions/squabble/action.yml | 68 ++++++ .github/actions/squabble/install.sh | 146 +++++++++++++ .github/actions/squabble/run.sh | 39 ++++ .github/workflows/actions.lock | 1 + .github/workflows/squabble-action.yml | 112 ++++++++++ CHANGELOG.adoc | 8 + tests/squabble_action_test.sh | 301 ++++++++++++++++++++++++++ 8 files changed, 799 insertions(+) create mode 100644 .github/actions/squabble/README.adoc create mode 100644 .github/actions/squabble/action.yml create mode 100755 .github/actions/squabble/install.sh create mode 100755 .github/actions/squabble/run.sh create mode 100644 .github/workflows/squabble-action.yml create mode 100755 tests/squabble_action_test.sh diff --git a/.github/actions/squabble/README.adoc b/.github/actions/squabble/README.adoc new file mode 100644 index 0000000..dc2763d --- /dev/null +++ b/.github/actions/squabble/README.adoc @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += 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/`, 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 --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 ^{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. diff --git a/.github/actions/squabble/action.yml b/.github/actions/squabble/action.yml new file mode 100644 index 0000000..a42a408 --- /dev/null +++ b/.github/actions/squabble/action.yml @@ -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\"", + }, + ], + }, +} diff --git a/.github/actions/squabble/install.sh b/.github/actions/squabble/install.sh new file mode 100755 index 0000000..8c9de84 --- /dev/null +++ b/.github/actions/squabble/install.sh @@ -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=` 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 diff --git a/.github/actions/squabble/run.sh b/.github/actions/squabble/run.sh new file mode 100755 index 0000000..339805f --- /dev/null +++ b/.github/actions/squabble/run.sh @@ -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 "$@" diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 39629f2..e8e0003 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -58,6 +58,7 @@ workflows: - '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': diff --git a/.github/workflows/squabble-action.yml b/.github/workflows/squabble-action.yml new file mode 100644 index 0000000..5393d82 --- /dev/null +++ b/.github/workflows/squabble-action.yml @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Squabble Consumer Action + +# Proves .github/actions/squabble installs ONLY the pinned, attested squabble +# binary. +# +# 1. tests/squabble_action_test.sh checks that action.yml calls exactly the +# scripts tested here, with the env tested here; then exercises each +# guard on its own, with planted negatives (the attestation JSON check, +# sha256 and attestation against the real asset and a one-byte tampered +# copy, install.sh end to end with a tampered download, run.sh argument +# splitting and exit codes). +# 2. The steps below run install.sh and run.sh exactly as the composite's +# steps do, on a real runner, so GITHUB_PATH and GITHUB_OUTPUT are real. +# PATH is checked in a LATER step, because GITHUB_PATH takes effect only +# after the step that writes it. +# +# NOT covered here: GitHub's own evaluation of action.yml (composite inputs, +# outputs and `if:`). This repository has an actions.lock, and the lockfile +# does not support local-path actions (`gh actions-lock --no-fix`: "workflow +# uses local path actions which are not supported"), so `uses: ./...` is not +# available. That is exercised by a follow-up that pins +# hyperpolymath/cicd-squabbler/.github/actions/squabble@ as a +# remote ref, which the lockfile does support. +# +# Like lock-sync-gate.yml, this file carries no `uses:` at all: it checks out +# with git in a run step, so its lock entry is `[]`. Keep it that way. There +# is no `paths:` filter, for the reason given in lock-sync-gate.yml. + +on: + workflow_dispatch: + pull_request: + push: + branches: [main] + +permissions: + contents: read + attestations: read + +concurrency: + group: squabble-action-${{ github.ref }} + cancel-in-progress: true + +jobs: + consumer-action: + name: squabble action installs only the pinned, attested binary + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out without actions/checkout + env: + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Header form, so the credential is never written into .git/config. + AUTH="AUTHORIZATION: basic $(printf 'x-access-token:%s' "${TOKEN}" | base64 -w0)" + git init -q . + git remote add origin "https://github.com/${REPO}.git" + git -c http.extraheader="${AUTH}" fetch -q --depth 1 origin "${SHA}" + git checkout -q FETCH_HEAD + echo "checked out ${SHA}" + + - name: action.yml contract, then each guard with planted negatives + env: + GH_TOKEN: ${{ github.token }} + run: bash tests/squabble_action_test.sh + + - name: Install, as the action's install step does + id: install + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/actions/squabble/install.sh + + - name: squabble is on PATH in a later step + env: + SQUABBLE_PATH: ${{ steps.install.outputs.path }} + run: | + set -euo pipefail + test -n "${SQUABBLE_PATH}" + test "$(command -v squabble)" = "${SQUABBLE_PATH}" + test "$(squabble --version)" = "squabble 0.1.0" + + - name: Run mode, exit 0, as the action's run step does + id: run-ok + env: + GH_TOKEN: ${{ github.token }} + SQUABBLE_ARGS: --version + SQUABBLE_BIN: ${{ steps.install.outputs.path }} + run: bash .github/actions/squabble/run.sh + + - name: Run mode, non-zero exit + id: run-bad + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + SQUABBLE_ARGS: --no-such-flag + SQUABBLE_BIN: ${{ steps.install.outputs.path }} + run: bash .github/actions/squabble/run.sh + + - name: Exit codes come back as step outputs + env: + OK_CODE: ${{ steps.run-ok.outputs.exit-code }} + BAD_CODE: ${{ steps.run-bad.outputs.exit-code }} + BAD_OUTCOME: ${{ steps.run-bad.outcome }} + run: | + set -euo pipefail + echo "run-ok exit-code='${OK_CODE}'; run-bad exit-code='${BAD_CODE}' outcome='${BAD_OUTCOME}'" + test "${OK_CODE}" = "0" + test "${BAD_OUTCOME}" = "failure" + test "${BAD_CODE}" = "2" diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 93bc5b4..eebf918 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,14 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. ==== Added +* `.github/actions/squabble` — a composite action that installs the + squabble v0.1.0 release binary for other workflows and, optionally, runs + it. It refuses any download whose sha256 is not the pinned digest, or whose + build attestation does not name this repository's `release.yml`, the + `v0.1.0` tag and its source commit, on a GitHub-hosted runner; nothing runs + until both pass. `tests/squabble_action_test.sh` (27 cases, with planted + tampered downloads and planted `action.yml` mutants) runs in the new + `squabble-action.yml` workflow. Linux x86_64 only. * `squabble chains` — read-only cross-repo CI dependency chains (Phase A of `docs/proposals/squabble-modes-and-app-layer.adoc`). Pure analysis in `squabble-core::chains`: cycles and dead upstreams (blocking), diamonds, diff --git a/tests/squabble_action_test.sh b/tests/squabble_action_test.sh new file mode 100755 index 0000000..13ea34e --- /dev/null +++ b/tests/squabble_action_test.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# squabble_action_test.sh — exercise each guard of .github/actions/squabble +# on its own, then install.sh end to end, with planted negatives. +# +# 0. action.yml calls exactly the scripts this suite tests, with exactly the +# env and conditions it tests, and nothing else; four planted mutants of +# it must each be refused. This repository has an actions.lock, which +# does not support local-path actions, so CI cannot run the action with +# `uses: ./`; this check ties what CI runs to what the action runs. +# 1. check_attestation_json, offline, against hand-built fixtures: the real +# shape passes; an empty array, a wrong digest, a missing subject, one bad +# result among good ones, a non-array and an empty file are refused. +# 2. run.sh, offline, against a stand-in binary: arguments are split on +# newlines only, taken literally, blank lines dropped; the exit code is +# passed through and recorded; a missing binary is refused. +# 3. verify_digest and verify_attestation against the real v0.1.0 asset +# (must pass) and a copy with one byte appended (must be refused). +# 4. install.sh end to end with `gh release download` shimmed to deliver the +# tampered copy: it must exit non-zero and write nothing to GITHUB_PATH +# or GITHUB_OUTPUT. Then the real download: it must install the binary +# and record both. +# +# Parts 3 and 4 need the network and a GitHub token (GH_TOKEN, or a gh login); +# part 0 needs mikefarah yq v4. The run ends by checking that every planned +# case ran, so a skipped section cannot pass silently. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ACTION_DIR="${ROOT}/.github/actions/squabble" +ACTION_YML="${ACTION_DIR}/action.yml" +INSTALL_SH="${ACTION_DIR}/install.sh" +RUN_SH="${ACTION_DIR}/run.sh" +EXPECTED_CASES=27 + +# shellcheck source-path=SCRIPTDIR source=../.github/actions/squabble/install.sh +source "$INSTALL_SH" + +WORK="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/squabble-test.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +passed=0 +failed=0 + +# expect_pass NAME CMD... — run CMD in a subshell and record a failure unless it succeeds. +expect_pass() { + local name="$1" + shift + if ("$@"); then + printf 'ok %s\n' "$name" + passed=$((passed + 1)) + else + printf 'FAIL %s (expected success)\n' "$name" + failed=$((failed + 1)) + fi +} + +# expect_fail NAME CMD... — run CMD in a subshell and record a failure unless it is refused. +expect_fail() { + local name="$1" + shift + if ("$@"); then + printf 'FAIL %s (expected refusal, got success)\n' "$name" + failed=$((failed + 1)) + else + printf 'ok %s (refused, as it must be)\n' "$name" + passed=$((passed + 1)) + fi +} + +# action_contract FILE — succeed only if the composite action in FILE has +# exactly the two steps this suite tests (install.sh, then run.sh only when +# args is set), with exactly their env, no `uses:`, and outputs wired to +# them; print the first mismatch. +action_contract() { + local file="$1" i got + command -v yq >/dev/null || { echo " yq (mikefarah v4) is required" >&2; return 1; } + # shellcheck disable=SC2016 # the ${{ }} and ${GITHUB_ACTION_PATH} are literal text + local -a checks=( + '.runs.using' 'composite' + '.runs.steps | length' '2' + '[.runs.steps[] | select(has("uses"))] | length' '0' + '.runs.steps[0].id' 'install' + '.runs.steps[0].shell' 'bash' + '.runs.steps[0].run' 'bash "${GITHUB_ACTION_PATH}/install.sh"' + '.runs.steps[0] | has("if")' 'false' + '.runs.steps[0].env | to_entries | sort_by(.key) | map(.key + "=" + .value) | join(",")' + 'GH_TOKEN=${{ github.token }}' + '.runs.steps[1].id' 'run' + '.runs.steps[1].shell' 'bash' + '.runs.steps[1].run' 'bash "${GITHUB_ACTION_PATH}/run.sh"' + '.runs.steps[1].if' "inputs.args != ''" + '.runs.steps[1].env | to_entries | sort_by(.key) | map(.key + "=" + .value) | join(",")' + 'GH_TOKEN=${{ inputs.token }},SQUABBLE_ARGS=${{ inputs.args }},SQUABBLE_BIN=${{ steps.install.outputs.path }}' + '.outputs.path.value' '${{ steps.install.outputs.path }}' + '.outputs."exit-code".value' '${{ steps.run.outputs.exit-code }}' + ) + for ((i = 0; i < ${#checks[@]}; i += 2)); do + got="$(yq -r "${checks[i]}" "$file")" || return 1 + if [[ "$got" != "${checks[i + 1]}" ]]; then + printf ' %s: got [%s], want [%s]\n' "${checks[i]}" "$got" "${checks[i + 1]}" >&2 + return 1 + fi + done +} + +# mutant NAME YQ_EXPR — write action.yml with YQ_EXPR applied under WORK and print its path. +mutant() { + yq "$2" "$ACTION_YML" >"${WORK}/mutant-$1.yml" + printf '%s\n' "${WORK}/mutant-$1.yml" +} + +# scripts_executable — succeed only if both scripts the action runs are executable files. +scripts_executable() { + [[ -f "$INSTALL_SH" && -x "$INSTALL_SH" && -f "$RUN_SH" && -x "$RUN_SH" ]] +} + +echo "== 0. action.yml runs exactly what this suite tests" +expect_pass "action.yml matches the tested wiring" action_contract "$ACTION_YML" +# shellcheck disable=SC2016 # literal ${{ }} in the yq expressions +{ + expect_fail "mutant: install step runs another script" action_contract \ + "$(mutant other-script '.runs.steps[0].run = "bash \"${GITHUB_ACTION_PATH}/other.sh\""')" + expect_fail "mutant: an extra step with uses:" action_contract \ + "$(mutant extra-uses '.runs.steps += [{"uses": "example/action@v1"}]')" + expect_fail "mutant: run step gets github.token" action_contract \ + "$(mutant token-swap '.runs.steps[1].env.GH_TOKEN = "${{ github.token }}"')" + expect_fail "mutant: run step loses its if:" action_contract \ + "$(mutant no-if 'del(.runs.steps[1].if)')" +} +expect_pass "install.sh and run.sh are executable" scripts_executable + +# fixture NAME JSON — write JSON to a fixture file under WORK and print its path. +fixture() { + printf '%s' "$2" >"${WORK}/$1.json" + printf '%s\n' "${WORK}/$1.json" +} + +# result SUBJECT_NAME DIGEST — print one verification result naming SHA256SUMS +# and SUBJECT_NAME with DIGEST, as `gh attestation verify --format json` does. +result() { + jq -cn --arg n "$1" --arg d "$2" '{verificationResult: {statement: {subject: [ + {name: "SHA256SUMS", digest: {sha256: "ddf35860797c"}}, + {name: $n, digest: {sha256: $d}}]}}}' +} + +echo "== 1. attestation JSON check (offline)" +good="$(result "$SQUABBLE_ASSET" "$SQUABBLE_SHA256")" +wrong="$(result "$SQUABBLE_ASSET" "$(printf '0%.0s' {1..64})")" +other="$(result "some-other-asset" "$SQUABBLE_SHA256")" +expect_pass "real-shaped result" check_attestation_json "$(fixture good "[${good}]")" +expect_pass "two good results" check_attestation_json "$(fixture good2 "[${good},${good}]")" +expect_fail "empty array" check_attestation_json "$(fixture empty '[]')" +expect_fail "wrong digest for the asset" check_attestation_json "$(fixture wrong "[${wrong}]")" +expect_fail "asset not among the subjects" check_attestation_json "$(fixture other "[${other}]")" +expect_fail "one bad result among good ones" check_attestation_json "$(fixture mixed "[${good},${wrong}]")" +expect_fail "an object, not an array" check_attestation_json "$(fixture object "${good}")" +expect_fail "empty file" check_attestation_json "$(fixture blank '')" + +echo "== 2. run.sh against a stand-in binary (offline)" +cat >"${WORK}/fake-squabble" <<'EOF' +#!/usr/bin/env bash +# Stand-in for squabble: print the argument count, then each argument on its +# own line, and exit with FAKE_RC. +printf '%s\n' "$#" +if (($# > 0)); then printf '%s\n' "$@"; fi +exit "${FAKE_RC:-0}" +EOF +chmod 0755 "${WORK}/fake-squabble" + +# run_with ARGS [RC] — run run.sh on the stand-in with SQUABBLE_ARGS=ARGS and +# the stand-in exiting RC; its stdout lands in run.out, GITHUB_OUTPUT in +# run.gh, and run.sh's exit code is returned. +run_with() { + : >"${WORK}/run.gh" + SQUABBLE_BIN="${WORK}/fake-squabble" SQUABBLE_ARGS="$1" FAKE_RC="${2:-0}" \ + GITHUB_OUTPUT="${WORK}/run.gh" bash "$RUN_SH" >"${WORK}/run.out" +} + +# ran_with RC STDOUT — succeed only if the last run_with exited RC, printed +# exactly STDOUT, and recorded exit-code=RC. +ran_with() { + [[ "$(cat "${WORK}/run.out")" == "$2" ]] || return 1 + [[ "$(cat "${WORK}/run.gh")" == "exit-code=$1" ]] +} + +# case_split — blank lines are dropped and each other line is one argument. +case_split() { + run_with $'--a\n\nb c\n' || return 1 + ran_with 0 $'2\n--a\nb c' +} + +# case_literal — no globbing, expansion or word splitting inside a line. +case_literal() { + # shellcheck disable=SC2016 # the $HOME and backticks must stay literal + local arg='--x=* $HOME `id` "q"' + run_with "$arg" || return 1 + ran_with 0 "1"$'\n'"$arg" +} + +# case_empty — empty SQUABBLE_ARGS runs the binary with no arguments. +case_empty() { + run_with '' || return 1 + ran_with 0 "0" +} + +# case_nonzero — a non-zero exit is passed through and recorded. +case_nonzero() { + local rc=0 + run_with --v 2 || rc=$? + [[ "$rc" == 2 ]] || return 1 + ran_with 2 $'1\n--v' +} + +# case_missing_bin — a missing binary exits 1 and records nothing. +case_missing_bin() { + local rc=0 + : >"${WORK}/run.gh" + SQUABBLE_BIN="${WORK}/absent" SQUABBLE_ARGS=--v GITHUB_OUTPUT="${WORK}/run.gh" \ + bash "$RUN_SH" 2>/dev/null || rc=$? + [[ "$rc" == 1 && ! -s "${WORK}/run.gh" ]] +} + +expect_pass "blank lines dropped, one argument per line" case_split +expect_pass "a line is taken literally" case_literal +expect_pass "empty args: no arguments" case_empty +expect_pass "non-zero exit passed through and recorded" case_nonzero +expect_pass "missing binary refused, nothing recorded" case_missing_bin + +echo "== 3. digest and attestation against the real asset and a tampered copy" +gh release download "$SQUABBLE_TAG" --repo "$SQUABBLE_REPO" \ + --pattern "$SQUABBLE_ASSET" --dir "${WORK}/real" +real="${WORK}/real/${SQUABBLE_ASSET}" +mkdir -p "${WORK}/tampered" +tampered="${WORK}/tampered/${SQUABBLE_ASSET}" +cp -- "$real" "$tampered" +printf '\0' >>"$tampered" +expect_pass "real asset: sha256 matches the pin" verify_digest "$real" +expect_fail "tampered copy: sha256" verify_digest "$tampered" +expect_pass "real asset: attestation" verify_attestation "$real" +expect_fail "tampered copy: attestation" verify_attestation "$tampered" + +echo "== 4. install.sh end to end" +real_gh="$(command -v gh)" +mkdir -p "${WORK}/shim" +cat >"${WORK}/shim/gh" <"${dir}/path" + : >"${dir}/output" + GITHUB_PATH="${dir}/path" GITHUB_OUTPUT="${dir}/output" RUNNER_TEMP="$dir" \ + PATH="${prefix:+${prefix}:}${PATH}" bash "$INSTALL_SH" +} + +# nothing_recorded DIR — succeed only if install_into DIR left both files empty. +nothing_recorded() { + [[ ! -s "${1}/path" && ! -s "${1}/output" ]] +} + +# installed_and_recorded DIR — succeed only if DIR's GITHUB_OUTPUT names an +# executable with the pinned digest that lives in the GITHUB_PATH directory. +installed_and_recorded() { + local dir="$1" bin + bin="$(sed -n 's/^path=//p' "${dir}/output")" + [[ -n "$bin" && -x "$bin" ]] || return 1 + [[ "$(cat "${dir}/path")" == "$(dirname "$bin")" ]] || return 1 + verify_digest "$bin" +} + +expect_fail "install.sh with a tampered download" install_into "${WORK}/e2e-bad" "${WORK}/shim" +expect_pass "tampered install recorded nothing" nothing_recorded "${WORK}/e2e-bad" +expect_pass "install.sh with the real download" install_into "${WORK}/e2e-good" +expect_pass "real install recorded PATH and output" installed_and_recorded "${WORK}/e2e-good" + +total=$((passed + failed)) +echo "== ${passed} passed, ${failed} failed, ${total} of ${EXPECTED_CASES} planned cases ran" +if (( failed != 0 || total != EXPECTED_CASES )); then + exit 1 +fi