From d853b8c4915f8a4a7a9dced13df015ebecd46d0a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:02:57 +0100 Subject: [PATCH 1/2] feat(lock): regenerate actions.lock on Dependabot PRs estate-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot bumps workflow `uses:` refs but never actions.lock, so its PRs land red on "Governance / Actions lockfile verify" and every later PR in the repo inherits that red. scripts/regen-dependabot-locks.sh repairs the lock on the Dependabot branch before it can merge: - runs the estate repair order (gh actions-lock v0.1.6 → relock-sha-keys → complete-job-refs → close-lock → prune-stale) on a clone of the PR head; - restores every file except actions.lock (rewrite mode de-pins SHAs); - commits only if actions.lock is the sole change, holds no `$/` ref, every edge resolved, and update-actions-lock.sh --verify-local passes; - commits via createCommitOnBranch with expectedHeadOid as a GitHub App installation (Verified; refuses a moved branch). DRY_RUN=1 reports only. .github/workflows/dependabot-lock-regen.yml runs it every 30 min and on dispatch, using the existing non-bypass applier App slot (vars.APP_ID / secrets.APP_PRIVATE_KEY, owner ruling STD-R-3). With no App it warns and exits 0, naming what it did not examine. Test: scripts/tests/regen-dependabot-locks-test.sh, 27 cases; five mutants (no `$/` guard, restore, dirty check, composite refusal, or non-404 reporting) each turn red. Repos with .github/actions/ composites are refused (composite-unsupported): the chain drops their edges and the verifier accepts it (standards#1122). Non-404 API failures print api-error. Dry runs on wsl-compute-governor-dispatcher#22, proof-of-work#135 and sanctify-php#106 produced verifier-clean locks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016L7GFo3yGQ2vK9YgKL2wsP --- .github/workflows/actions.lock | 4 + .github/workflows/dependabot-lock-regen.yml | 94 ++++++++++ scripts/regen-dependabot-locks.sh | 185 +++++++++++++++++++ scripts/tests/regen-dependabot-locks-test.sh | 168 +++++++++++++++++ 4 files changed, 451 insertions(+) create mode 100644 .github/workflows/dependabot-lock-regen.yml create mode 100755 scripts/regen-dependabot-locks.sh create mode 100755 scripts/tests/regen-dependabot-locks-test.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index ebc802f9..4b140c82 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -44,6 +44,10 @@ workflows: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/deed-conformance.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/dependabot-lock-regen.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1' + - 'step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1' '.github/workflows/doc-format.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/dyadt-verify.yml': diff --git a/.github/workflows/dependabot-lock-regen.yml b/.github/workflows/dependabot-lock-regen.yml new file mode 100644 index 00000000..1d2a31ba --- /dev/null +++ b/.github/workflows/dependabot-lock-regen.yml @@ -0,0 +1,94 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# +# Regenerate actions.lock on open Dependabot PRs across the estate, so a +# github-actions bump can no longer land with a stale lock and redden every +# later PR's "Governance / Actions lockfile verify". Logic, guards and the +# reasoning live in scripts/regen-dependabot-locks.sh; this file only mints the +# credentials and runs it. +# +# CREDENTIAL: the estate's dedicated applier App — vars.APP_ID and +# secrets.APP_PRIVATE_KEY, the same slot apply-workflow-pins.yml and +# signed-push read (owner ruling STD-R-3: NOT OikosBot, which is a ruleset +# bypass actor). Needs Contents R/W, Workflows R/W, Pull requests R, and must +# be installed on BOTH owners. Until it exists the run warns and exits 0. +name: Dependabot lock regeneration + +on: + schedule: + - cron: '23,53 * * * *' + workflow_dispatch: + inputs: + repo: + description: 'Only this owner/repo (empty = every installed repo)' + required: false + default: '' + dry-run: + description: 'Regenerate and report, but commit nothing' + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + regen: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Harden runner (egress audit) + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # An installation token is scoped to ONE owner, so mint one per owner. + - name: Mint an App installation token for hyperpolymath + id: tok-user + if: vars.APP_ID != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + continue-on-error: true + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: hyperpolymath + - name: Mint an App installation token for metadatastician + id: tok-org + if: vars.APP_ID != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + continue-on-error: true + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: metadatastician + + - name: Install the lock tooling (same pin as the governance gate) + env: + GH_TOKEN: ${{ github.token }} + run: | + sudo apt-get install -y -qq gawk >/dev/null + gh extension install github/gh-actions-lock --pin v0.1.6 + + - name: Regenerate Dependabot PR lockfiles + env: + REGEN_TOKENS: >- + hyperpolymath=${{ steps.tok-user.outputs.token }} + metadatastician=${{ steps.tok-org.outputs.token }} + DRY_RUN: ${{ inputs.dry-run && '1' || '0' }} + ONLY_REPO: ${{ inputs.repo }} + run: | + # A token is an empty string when the App is absent or not installed + # on that owner; the script names each owner it did NOT examine. + bash scripts/regen-dependabot-locks.sh ${ONLY_REPO:+"$ONLY_REPO"} | tee "$RUNNER_TEMP/regen.txt" + { + echo '### Dependabot lock regeneration' + echo '```' + cat "$RUNNER_TEMP/regen.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/regen-dependabot-locks.sh b/scripts/regen-dependabot-locks.sh new file mode 100755 index 00000000..7a242337 --- /dev/null +++ b/scripts/regen-dependabot-locks.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# regen-dependabot-locks.sh — regenerate actions.lock on Dependabot PR branches. +# +# WHY THIS EXISTS +# --------------- +# Dependabot bumps the `uses:` refs in a workflow but never touches +# `.github/workflows/actions.lock`. The bump PR goes red on +# "Governance / Actions lockfile verify", is merged anyway (the check is not +# required), and main then drifts from its own lock — so every LATER pull +# request in that repository inherits a red it did not cause. Measured +# 2026-10-01 on sim-public-relations, cicd-squabbler, burble, stapeln and +# hypatia. This closes the loop at the source: the Dependabot PR is repaired +# before it can land. +# +# WHAT IT DOES, PER PULL REQUEST +# ------------------------------ +# Selects open PRs authored by dependabot[bot], from a same-repo branch, that +# touch .github/workflows/, in a repository that already carries actions.lock. +# On a clone of the PR head it runs the estate lock repair order (see +# complete-job-refs.sh): +# gh actions-lock → relock-sha-keys → complete-job-refs → close-lock → prune-stale +# then RESTORES every file except actions.lock, because `gh actions-lock` +# rewrite mode de-pins inline SHAs and can invent `uses: $/...` local refs. +# The result is committed only when: +# * the ONLY changed path is .github/workflows/actions.lock, +# * the lock names no `$/` ref and close-lock resolved every edge, and +# * update-actions-lock.sh --verify-local (the gate's own verifier) passes. +# Anything else is reported and left for a human; nothing partial is written. +# +# The commit goes through GraphQL createCommitOnBranch with expectedHeadOid, +# authenticated as a GitHub App installation, so it is Verified (satisfies +# required_signatures) and refuses to land if Dependabot moved the branch +# meanwhile. A second run finds the lock current and does nothing. +# +# CREDENTIALS +# ----------- +# One installation token per owner, passed as REGEN_TOKENS="owner=token ..." +# (the workflow mints them with actions/create-github-app-token). An empty set +# is reported as a warning and exits 0 — the job must not pretend it swept +# anything, and must not redden every schedule tick before the App exists. +# +# Usage: regen-dependabot-locks.sh [owner/repo] (default: every repo the +# installations can see). DRY_RUN=1 regenerates but never commits. +# Env: REGEN_TOKENS, DRY_RUN, GH_BIN (test stub), REGEN_TOOLS (dir holding +# the repair scripts; default: this script's directory). +set -uo pipefail + +GH_BIN="${GH_BIN:-gh}" +REGEN_TOOLS="${REGEN_TOOLS:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +LOCK_PATH=.github/workflows/actions.lock + +# Print one outcome token for the checkout in $1, leaving actions.lock modified +# only when the token is `changed`: no-lock | composite-unsupported | +# tool-failed | unresolvable | corrupt | dirty | unverified | current | changed. +# +# composite-unsupported: prune-stale reads only workflow files, so it drops the +# per-workflow edges a local composite action (.github/actions/*) contributes, +# and --verify-local still reports such a lock valid (measured 2026-10-02 on +# standards' signed-push-smoke.yml; standards#1122). Left for a human. +regen_lock() { + local dir="$1" changed + ( + cd "$dir" || exit 1 + [ -f "$LOCK_PATH" ] || { echo no-lock; exit 0; } + [ -d .github/actions ] && { echo composite-unsupported; exit 0; } + if ! "$GH_BIN" actions-lock >&2; then echo tool-failed; exit 0; fi + # Keep only the lock: undo every tool-authored workflow rewrite. + git checkout --quiet -- . ":(exclude)$LOCK_PATH" + git clean -fdq + bash "$REGEN_TOOLS/relock-sha-keys.sh" >&2 || { echo tool-failed; exit 0; } + bash "$REGEN_TOOLS/complete-job-refs.sh" >&2 || { echo tool-failed; exit 0; } + GH_BIN="$GH_BIN" bash "$REGEN_TOOLS/close-lock.sh" >&2 + case $? in 0) ;; 3) echo unresolvable; exit 0 ;; *) echo tool-failed; exit 0 ;; esac + bash "$REGEN_TOOLS/prune-stale.sh" >&2 || { echo tool-failed; exit 0; } + if grep -Fq '$/' "$LOCK_PATH"; then echo corrupt; exit 0; fi + changed="$(git status --porcelain --untracked-files=all)" + if [ -n "$changed" ] && [ "$changed" != " M $LOCK_PATH" ]; then echo dirty; exit 0; fi + if ! GH_BIN="$GH_BIN" bash "$REGEN_TOOLS/update-actions-lock.sh" --verify-local >&2; then + echo unverified; exit 0 + fi + if [ -z "$changed" ]; then echo current; else echo changed; fi + ) +} + +# Commit the checkout's actions.lock onto branch $2 of repo $1, only if the +# branch head is still $3. Prints the new commit oid. +commit_lock() { + local repo="$1" branch="$2" expected="$3" dir="$4" payload + payload="$(jq -n \ + --arg repo "$repo" --arg branch "$branch" --arg oid "$expected" \ + --arg path "$LOCK_PATH" --arg contents "$(base64 -w0 < "$dir/$LOCK_PATH")" \ + '{query: "mutation($in: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $in) { commit { oid } } }", + variables: {in: { + branch: {repositoryNameWithOwner: $repo, branchName: $branch}, + expectedHeadOid: $oid, + message: {headline: "chore(deps): regenerate actions.lock for this bump", + body: "Dependabot updated workflow refs without the lockfile. Regenerated with the estate repair order (scripts/regen-dependabot-locks.sh in hyperpolymath/standards) and verified with update-actions-lock.sh --verify-local."}, + fileChanges: {additions: [{path: $path, contents: $contents}]}}}}')" + printf '%s' "$payload" | "$GH_BIN" api graphql --input - --jq '.data.createCommitOnBranch.commit.oid' +} + +# Read a pulls-list JSON array on stdin; print "numberhead_refhead_sha" +# for each open, non-draft Dependabot PR whose head branch is in the same repo. +select_prs() { + jq -r '.[] + | select(.user.login == "dependabot[bot]" and (.draft | not) + and .head.repo.full_name == .base.repo.full_name) + | [.number, .head.ref, .head.sha] | @tsv' +} + +# Process every candidate PR of repo $1 with token $2; print one +# "repo#Noutcome" line per PR examined. +process_repo() { + local repo="$1" token="$2" num ref sha work outcome oid err files prs + export GH_TOKEN="$token" + # Only a 404 means "no lock here". A rate limit or 5xx must stay visible, or + # the repository is silently counted as examined. + if ! err="$("$GH_BIN" api "repos/$repo/contents/$LOCK_PATH" --silent 2>&1)"; then + case "$err" in *"HTTP 404"*) return 0 ;; esac + printf '%s\tapi-error\n' "$repo"; return 0 + fi + if ! prs="$("$GH_BIN" api --paginate "repos/$repo/pulls?state=open&per_page=100")"; then + printf '%s\tapi-error\n' "$repo"; return 0 + fi + while IFS=$'\t' read -r num ref sha; do + [ -n "$num" ] || continue + if ! files="$("$GH_BIN" api --paginate "repos/$repo/pulls/$num/files?per_page=100" --jq '.[].filename')"; then + printf '%s#%s\tapi-error\n' "$repo" "$num"; continue + fi + printf '%s\n' "$files" | grep -q '^\.github/workflows/' || continue + work="$(mktemp -d)" + if ! git clone --quiet --depth 1 --branch "$ref" \ + "https://x-access-token:$token@github.com/$repo.git" "$work/r" 2>/dev/null; then + printf '%s#%s\tclone-failed\n' "$repo" "$num"; rm -rf "$work"; continue + fi + if [ "$(git -C "$work/r" rev-parse HEAD)" != "$sha" ]; then + printf '%s#%s\tmoved\n' "$repo" "$num"; rm -rf "$work"; continue + fi + outcome="$(regen_lock "$work/r")" + if [ "$outcome" = changed ] && [ "${DRY_RUN:-0}" != 1 ]; then + if oid="$(commit_lock "$repo" "$ref" "$sha" "$work/r")" && printf '%s' "$oid" | grep -Eq '^[0-9a-f]{40}$'; then + outcome="committed $oid" + else + outcome="commit-refused" + fi + fi + printf '%s#%s\t%s\n' "$repo" "$num" "$outcome" + rm -rf "$work" + done < <(printf '%s' "$prs" | jq -s 'add // []' | select_prs) +} + +# Enumerate every repository visible to each owner's installation token (or +# only $1), process them, and print the denominator alongside the outcomes. +main() { + local only="${1:-}" pair owner token repos total=0 seen tokens="${REGEN_TOKENS:-}" + if [ -z "${tokens// /}" ]; then + echo "::warning::regen-dependabot-locks: no App installation token (vars.APP_ID / secrets.APP_PRIVATE_KEY unset or the App is not installed). Nothing was examined." + return 0 + fi + for pair in $tokens; do + owner="${pair%%=*}"; token="${pair#*=}" + [ -n "$token" ] || { echo "::warning::no installation token for $owner — its repositories were NOT examined"; continue; } + if [ -n "$only" ]; then + [ "${only%%/*}" = "$owner" ] || continue + repos="$only" + else + repos="$(GH_TOKEN="$token" "$GH_BIN" api --paginate 'installation/repositories?per_page=100' \ + --jq '.repositories[] | select(.archived | not) | .full_name')" || { + echo "::error::could not enumerate $owner's installation repositories"; return 1; } + fi + seen=$(printf '%s\n' "$repos" | grep -c .) + total=$((total + seen)) + echo "$owner: $seen repositories in scope" + while IFS= read -r r; do + [ -n "$r" ] && process_repo "$r" "$token" + done <<< "$repos" + done + echo "examined $total repositories" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/scripts/tests/regen-dependabot-locks-test.sh b/scripts/tests/regen-dependabot-locks-test.sh new file mode 100755 index 00000000..9a71949e --- /dev/null +++ b/scripts/tests/regen-dependabot-locks-test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# regen-dependabot-locks-test.sh — offline known-answer tests for +# scripts/regen-dependabot-locks.sh. +# +# The repair scripts it drives have their own suites, so they are stubbed here +# and each stub's verdict is set per case. What is under test is the bot's OWN +# logic: that it keeps only actions.lock, refuses every unsafe tree with a +# distinct verdict instead of committing it, filters PRs correctly, builds a +# commit that cannot land on a moved branch, and reports a missing credential +# rather than claiming a clean sweep. +# +# Run: bash scripts/tests/regen-dependabot-locks-test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TARGET="${TARGET:-$SCRIPT_DIR/../regen-dependabot-locks.sh}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +# Record a passing case named $1. +ok() { echo "PASS: $1"; pass=$((pass + 1)); } +# Record a failing case named $1. +bad() { echo "FAIL: $1"; fail=$((fail + 1)); } +# expect