From 3106fca2cc1ac5920c2ad7437423d022cc461578 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:58:58 +0000 Subject: [PATCH 1/7] fix(ci): repair secret scanner, lockfile, and workflow contracts Carry forward Dependabot PR #24 (smtp-notify-action v0.5.1), repoint the secret-scanner reusable to the canonical metadatastician/standards slug, remove unnecessary secrets inheritance, reconcile actions.lock and the inline pin, and add workflow-contract regression checks. Includes the D199 implementation handoff and the open-work audit. No admin bypass, force-push, or required-context removal. --- .github/scripts/check-workflow-contracts.sh | 85 +++++++++ .../scripts/tests/workflow-contracts-test.sh | 85 +++++++++ .github/settings.yml | 4 +- .github/workflows/actions-lock.closure.tsv | 5 + .github/workflows/actions.lock | 19 +- .github/workflows/label-triage.yml | 2 +- .github/workflows/labels.yml | 2 +- .github/workflows/push-email-notify.yml | 4 +- .github/workflows/secret-scanner.yml | 21 ++- .github/workflows/workflow-contracts.yml | 59 ++++++ CHANGELOG.adoc | 23 +++ docs/audits/2026-11-02-open-work-audit.adoc | 175 ++++++++++++++++++ .../D199-post-squash-stack-recovery.adoc | 157 ++++++++++++++++ mise.toml | 57 ------ 14 files changed, 620 insertions(+), 78 deletions(-) create mode 100644 .github/scripts/check-workflow-contracts.sh create mode 100644 .github/scripts/tests/workflow-contracts-test.sh create mode 100644 .github/workflows/actions-lock.closure.tsv create mode 100644 .github/workflows/workflow-contracts.yml create mode 100644 CHANGELOG.adoc create mode 100644 docs/audits/2026-11-02-open-work-audit.adoc create mode 100644 docs/handoffs/D199-post-squash-stack-recovery.adoc delete mode 100644 mise.toml diff --git a/.github/scripts/check-workflow-contracts.sh b/.github/scripts/check-workflow-contracts.sh new file mode 100644 index 0000000..269b010 --- /dev/null +++ b/.github/scripts/check-workflow-contracts.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Narrow, fail-closed contracts for this repository's workflow coordinates. +# This is not a replacement for `gh actions-lock --verify`; it catches the +# estate-specific regressions that a syntactically valid lock can still hide: +# moved reusable owners, non-immutable reusable pins, missing SPDX headers, +# and an inline notification-action SHA that disagrees with actions.lock. +set -euo pipefail + +ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +WF_DIR="$ROOT/.github/workflows" +LOCK="$WF_DIR/actions.lock" +failures=0 + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + failures=$((failures + 1)) +} + +[ -d "$WF_DIR" ] || { echo "no workflow directory: $WF_DIR" >&2; exit 2; } +[ -f "$LOCK" ] || { echo "no actions.lock: $LOCK" >&2; exit 2; } + +shopt -s nullglob +workflows=("$WF_DIR"/*.yml "$WF_DIR"/*.yaml) +[ "${#workflows[@]}" -gt 0 ] || { echo "no workflow files" >&2; exit 2; } + +for wf in "${workflows[@]}"; do + rel=".github/workflows/$(basename "$wf")" + + if [ "$(head -n 1 "$wf")" != '# SPDX-License-Identifier: MPL-2.0' ]; then + fail "$rel: SPDX-License-Identifier must be the first line" + fi + + if ! grep -Fq " '$rel':" "$LOCK"; then + fail "$rel: missing actions.lock workflows entry" + fi + + if grep -nE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*hyperpolymath/standards/' "$wf"; then + fail "$rel: pre-transfer hyperpolymath/standards reusable coordinate" + fi + + while IFS= read -r line; do + ref=$(printf '%s\n' "$line" | sed -E 's/^[^@]*@([^ #]+).*/\1/') + if [[ ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then + fail "$rel: remote reusable must be pinned to a 40-hex commit: $line" + fi + done < <(grep -E '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[^@[:space:]]+@' "$wf" || true) +done + +# The notification action is tag-pinned in YAML because actions.lock is the +# pin authority. Its inline comment must name the same commit as the lock. +notify="$WF_DIR/push-email-notify.yml" +if [ -f "$notify" ]; then + uses_line=$(grep -E '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*hyperpolymath/smtp-notify-action@' "$notify" | head -n 1 || true) + if [ -z "$uses_line" ]; then + fail "push-email-notify.yml: missing smtp-notify-action uses line" + else + tag=$(printf '%s\n' "$uses_line" | sed -E 's/^[^@]*@([^ #]+).*/\1/') + sha=$(printf '%s\n' "$uses_line" | sed -nE 's/.*sha1-([0-9a-f]{40}).*/\1/p') + case "$tag" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) fail "push-email-notify.yml: expected an exact release tag, got $tag" ;; + esac + if [ -z "$sha" ]; then + fail "push-email-notify.yml: missing inline sha1 pin comment" + fi + key=" 'hyperpolymath/smtp-notify-action@$tag':" + if ! grep -Fq "$key" "$LOCK"; then + fail "actions.lock: missing dependency key for smtp-notify-action@$tag" + fi + if ! grep -A4 -F "$key" "$LOCK" | grep -Fq "commit: 'sha1-$sha'"; then + fail "actions.lock: smtp-notify-action@$tag commit does not match inline sha1-$sha" + fi + if ! grep -Fq " - 'hyperpolymath/smtp-notify-action@$tag'" "$LOCK"; then + fail "actions.lock: push-email-notify.yml does not reference smtp-notify-action@$tag" + fi + fi +fi + +if [ "$failures" -gt 0 ]; then + printf '%s workflow contract failure(s)\n' "$failures" >&2 + exit 1 +fi +echo 'workflow contracts: OK' diff --git a/.github/scripts/tests/workflow-contracts-test.sh b/.github/scripts/tests/workflow-contracts-test.sh new file mode 100644 index 0000000..b2b7e1a --- /dev/null +++ b/.github/scripts/tests/workflow-contracts-test.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/check-workflow-contracts.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +pass=0 +fail=0 + +fixture() { + root="$WORK/$1" + mkdir -p "$root/.github/workflows" + cat > "$root/.github/workflows/actions.lock" <<'LOCK' +version: 'v0.0.3' +workflows: + '.github/workflows/push-email-notify.yml': + - 'hyperpolymath/smtp-notify-action@v0.5.1' + '.github/workflows/secret-scanner.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' +dependencies: + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + 'hyperpolymath/smtp-notify-action@v0.5.1': + ref: 'v0.5.1' + commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' +LOCK + cat > "$root/.github/workflows/push-email-notify.yml" <<'YAML' +# SPDX-License-Identifier: MPL-2.0 +name: Push email notification +jobs: + notify: + steps: + - uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-e384daf6167a7a7567642d6a62060106244d2413) +YAML + cat > "$root/.github/workflows/secret-scanner.yml" <<'YAML' +# SPDX-License-Identifier: MPL-2.0 +name: Secret Scanner +jobs: + scan: + uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc +YAML +} + +expect() { + name="$1"; want="$2"; root="$3" + set +e + bash "$SCRIPT" "$root" >"$WORK/out" 2>&1 + got=$? + set -e + if [ "$got" -eq "$want" ]; then + printf 'PASS %s\n' "$name"; pass=$((pass + 1)) + else + printf 'FAIL %s: wanted exit %s, got %s\n' "$name" "$want" "$got" >&2 + cat "$WORK/out" >&2 + fail=$((fail + 1)) + fi +} + +fixture valid +expect valid 0 "$WORK/valid" + +fixture stale_sha +sed -i 's/sha1-e384daf6167a7a7567642d6a62060106244d2413/sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7/' "$WORK/stale_sha/.github/workflows/push-email-notify.yml" +expect stale_inline_sha 1 "$WORK/stale_sha" + +fixture moved_owner +sed -i 's#metadatastician/standards/#hyperpolymath/standards/#' "$WORK/moved_owner/.github/workflows/secret-scanner.yml" +expect moved_reusable_owner 1 "$WORK/moved_owner" + +fixture floating_pin +sed -i 's/@8d1a2aab4211493cf42f147f2080f024605797dc/@main/' "$WORK/floating_pin/.github/workflows/secret-scanner.yml" +expect floating_reusable_pin 1 "$WORK/floating_pin" + +fixture missing_spdx +sed -i '1s/.*/# managed/' "$WORK/missing_spdx/.github/workflows/secret-scanner.yml" +expect missing_spdx_first_line 1 "$WORK/missing_spdx" + +fixture missing_lock_entry +sed -i "/secret-scanner.yml':/,+1d" "$WORK/missing_lock_entry/.github/workflows/actions.lock" +expect missing_lock_workflow_entry 1 "$WORK/missing_lock_entry" + +printf '%s passed, %s failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.github/settings.yml b/.github/settings.yml index 8441d10..b917055 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -4,8 +4,8 @@ repository: has_wiki: true default_branch: main allow_squash_merge: true - allow_merge_commit: true - allow_rebase_merge: true + allow_merge_commit: false + allow_rebase_merge: false allow_auto_merge: true delete_branch_on_merge: true allow_update_branch: true diff --git a/.github/workflows/actions-lock.closure.tsv b/.github/workflows/actions-lock.closure.tsv new file mode 100644 index 0000000..9fc5bf6 --- /dev/null +++ b/.github/workflows/actions-lock.closure.tsv @@ -0,0 +1,5 @@ +# Reviewed closure of remote reusable workflows (standards#968, #1290). +# Produced by scripts/check-reusable-closure.sh --write-baseline; a change +# here is a change to what this repository trusts, so review the diff. +# workflowreusable-callref reached at startup +.github/workflows/secret-scanner.yml metadatastician/standards@8d1a2aab4211493cf42f147f2080f024605797dc actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index fc419e2..2367e9c 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -1,21 +1,24 @@ # This file is machine-generated by `gh actions-lock`. # Do not edit by hand; run `gh actions-lock` to update. # Docs: https://gh.io/actions-lockfile -version: 'v0.0.2' +version: 'v0.0.3' workflows: '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/push-email-notify.yml': - - 'hyperpolymath/smtp-notify-action@v0.3.0' - '.github/workflows/secret-scanner.yml': [] + - 'hyperpolymath/smtp-notify-action@v0.5.1' + '.github/workflows/secret-scanner.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/workflow-contracts.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' dependencies: - 'hyperpolymath/smtp-notify-action@v0.3.0': - ref: 'v0.3.0' - commit: 'sha1-22e7bdb322c430c1d0dac6b3bb307f4bb139d0be' - owner_id: 6759885 - repo_id: 1352485172 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 + 'hyperpolymath/smtp-notify-action@v0.5.1': + ref: 'v0.5.1' + commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' + owner_id: 6759885 + repo_id: 1352485172 diff --git a/.github/workflows/label-triage.yml b/.github/workflows/label-triage.yml index 814a192..fc79947 100644 --- a/.github/workflows/label-triage.yml +++ b/.github/workflows/label-triage.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Label Triage # Classify newly-filed issues against the estate label taxonomy. diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 83ab941..af34c6b 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Labels # Applies the canonical estate label set from .github/labels.json. diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index a4c1ebc..e6f5c74 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -1,6 +1,6 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. +# This workflow is managed by gh actions-lock. # Dormant push-email notification. ARMED by setting the repo variable # PUSH_EMAIL_ENABLED=true (the single on/off switch). Addresses are pre-filled; # sending needs the org SMTP secrets (SMTP_HOST/PORT/USER/PASS). Inherited by @@ -41,7 +41,7 @@ jobs: timeout-minutes: 5 steps: - name: Send push notification email - uses: hyperpolymath/smtp-notify-action@v0.5.0 # NOSONAR — pin authority is actions.lock (sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7) + uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-e384daf6167a7a7567642d6a62060106244d2413) with: server_address: ${{ secrets.SMTP_HOST }} server_port: ${{ secrets.SMTP_PORT }} diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 3e3dcf2..1272c7a 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,11 +1,18 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Calls the estate's shared secret scanner (gitleaks + rust-secrets + -# shell-secrets). Added because this repository had NO leak scanning at all. +# shell-secrets). +# +# The reusable lives in metadatastician/standards. GitHub Actions does not +# follow the REST redirect from the pre-transfer hyperpolymath/standards slug +# for job-level `uses:`, so the old coordinate produced a zero-job startup +# failure and no `scan / gitleaks` check. Keep the canonical slug and an +# immutable 40-hex pin. # -# `secrets: inherit` is REQUIRED — without it the gitleaks action's inner -# secrets.GITHUB_TOKEN is empty and the scan silently degrades. +# The pinned reusable declares no workflow_call secrets and references no +# `secrets.*` context. Do not add `secrets: inherit`: it would forward every +# repository/organisation secret to a scanner that needs none. + name: "Secret Scanner" on: pull_request: @@ -15,9 +22,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: - actions: read contents: read jobs: scan: - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@bd0df9ead7faf0cdfe0e13e7966d91e28d0101d4 - secrets: inherit + permissions: + contents: read + uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc diff --git a/.github/workflows/workflow-contracts.yml b/.github/workflows/workflow-contracts.yml new file mode 100644 index 0000000..43d8dc2 --- /dev/null +++ b/.github/workflows/workflow-contracts.yml @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. +name: Workflow contracts + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + contracts: + name: Workflow and lock contracts + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Regression tests for the contract checker + run: bash .github/scripts/tests/workflow-contracts-test.sh + + - name: Repository workflow contracts + run: bash .github/scripts/check-workflow-contracts.sh + + - name: Install gh-actions-lock (pinned release, digest verified) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + repo=github/gh-actions-lock + tag=v0.4.0 + asset=linux-amd64 + gh release download "$tag" --repo "$repo" --pattern "$asset" --output "$RUNNER_TEMP/gh-actions-lock" + digest=$(gh api "repos/$repo/releases/tags/$tag" --jq ".assets[] | select(.name == \"$asset\") | .digest") + case "$digest" in + sha256:*) ;; + *) echo "missing sha256 digest for $repo $tag $asset" >&2; exit 1 ;; + esac + echo "${digest#sha256:} $RUNNER_TEMP/gh-actions-lock" | sha256sum -c - + install -m 0755 "$RUNNER_TEMP/gh-actions-lock" "$RUNNER_TEMP/gh-actions-lock-bin" + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + ln -sf "$RUNNER_TEMP/gh-actions-lock-bin" "$RUNNER_TEMP/gh-actions-lock" + + - name: Verify actions.lock against GitHub + env: + GH_TOKEN: ${{ github.token }} + run: gh-actions-lock --verify diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc new file mode 100644 index 0000000..63d107c --- /dev/null +++ b/CHANGELOG.adoc @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Changelog + +== Unreleased + +=== Dependencies + +* Update `hyperpolymath/smtp-notify-action` from v0.5.0 to v0.5.1 and reconcile + `actions.lock` and the inline pin comment. Carries forward Dependabot PR #24. + +=== Fixed + +* Point the Secret Scanner caller at `metadatastician/standards`; the + pre-transfer `hyperpolymath/standards` coordinate caused a zero-job startup + failure and left the required `scan / gitleaks` check absent. +* Stop inheriting all secrets into the secret scanner; the pinned reusable + declares no secrets. +* Add workflow contract checks for moved reusable coordinates, immutable + reusable pins, SPDX headers, lockfile coverage, and stale inline action SHAs. +* Align `.github/settings.yml` merge methods with the live squash-only, + signed, linear-history rulesets. +* Remove the stale `mise.toml` runtime/build template from this + community-health repository. diff --git a/docs/audits/2026-11-02-open-work-audit.adoc b/docs/audits/2026-11-02-open-work-audit.adoc new file mode 100644 index 0000000..a91195b --- /dev/null +++ b/docs/audits/2026-11-02-open-work-audit.adoc @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Open-work audit: hyperpolymath/.github +:toc: +:toclevels: 3 + +Date: 2026-11-02 (Europe/London). Session branch: `arena/1e24712e-github`. +This record distinguishes observed evidence, source changes, and work that +remains outside this repository or outside the integration's permissions. + +== 1. PR #24 and Secret Scanner run 41457289788 + +=== Observed cause + +PR #24 proposed `hyperpolymath/smtp-notify-action` v0.5.0 -> v0.5.1. Its +Secret Scanner run https://github.com/hyperpolymath/.github/actions/runs/41457289788[41457289788] +completed with `failure`, created zero jobs, and had no log archive. The run +page annotation is the decisive evidence: + +[source,text] +---- +Invalid workflow file: .github/workflows/secret-scanner.yml#L22 +error parsing called workflow +".github/workflows/secret-scanner.yml" +-> "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@bd0df9ead7faf0cdfe0e13e7966d91e28d0101d4" +: workflow was not found. +---- + +The REST API still redirects `hyperpolymath/standards` to +`metadatastician/standards`, and the pinned file exists at the canonical +repository. GitHub Actions does not follow that redirect for job-level +`uses:`. The required `scan / gitleaks` context was therefore absent, not +merely failing. This matches the canonical handoff in +`metadatastician/standards/docs/GOVERNANCE-SOURCE-HANDOFF.adoc` and +`docs/decisions/2026-10-27-recovered-session-decisions.adoc`. + +The lockfile was not the cause of this particular zero-job run. It was, +however, stale in two ways: `main` still locked smtp-notify-action v0.3.0 while +the workflow used v0.5.0, and PR #24's inline comment still named the v0.2.0 +commit `ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7`. + +=== Source repair + +This branch carries forward PR #24's dependency bump and repairs the source: + +* `.github/workflows/secret-scanner.yml` now calls + `metadatastician/standards/.github/workflows/secret-scanner-reusable.yml` + at `8d1a2aab4211493cf42f147f2080f024605797dc`. +* The caller grants only `contents: read` and no longer uses + `secrets: inherit`; the pinned reusable declares no secrets. +* `.github/workflows/actions.lock` records smtp-notify-action v0.5.1 at + `e384daf6167a7a7567642d6a62060106244d2413`, keeps the checkout pin, and + records the through-reusable checkout dependency. +* `.github/workflows/actions-lock.closure.tsv` records the reviewed remote + reusable closure. +* The inline smtp-notify-action comment now names the v0.5.1 commit. +* `.github/workflows/workflow-contracts.yml` and + `.github/scripts/check-workflow-contracts.sh` add regression checks for + moved reusable coordinates, floating reusable pins, SPDX placement, + lockfile coverage, and stale inline SHAs. + +=== Dependency review + +The v0.5.1 release is a metadata correction. The release notes and commit +diff show no Zig/Idris2 source change; the action downloads the same +SHA-256-pinned musl binaries as v0.5.0. The release asset digests match the +action's pins: + +* x86_64: `2683da8f619dd5f310c9f6884da39e825c807c2c86a6319c19aba39b27f9c5c9` +* aarch64: `a58138308fb9832e1ceb551a12f1b04e591dc38ba5fd336c9acfb3f263161f29` + +The v0.5.1 tag peels to commit `e384daf6167a7a7567642d6a62060106244d2413`. +The commit is GitHub-verified. The annotated tag object itself reports +`unsigned`; this audit did not create or move the tag. + +=== Review/app feedback + +PR #24 had no human reviews and no review threads. CodeRabbit skipped review +because the author was a bot. GitGuardian and Semgrep reported success; +CodeQL reported neutral. Those app checks do not substitute for the missing +required `scan / gitleaks` job. + +=== Merge path + +This session may push only `arena/1e24712e-github`, not PR #24's Dependabot +branch. The repair therefore lands through a signed replacement PR from the +session branch. PR #24 should be closed as superseded only after the +replacement merges. No admin bypass, force-push, or required-context removal +is used. + +== 2. Issue #13 / D199 + +D199 adopts automated rebase-onto-base recovery after squash merges. The +appropriate estate-wide service is the `cicd-squabbler` GitHub App webhook +leg, not a community-health workflow in this repository. The App rollout is +already owner-ruled in https://github.com/hyperpolymath/cicd-squabbler/issues/122[cicd-squabbler#122] +(D266/D267: all repositories, owned compute). The webhook leg is still absent +on `main`. + +A precise implementation and verification handoff is recorded in +`docs/handoffs/D199-post-squash-stack-recovery.adoc`. No deployment is claimed. +The signing policy rules out the naive implementation: a bot must not +force-push rebased commits, and GitHub rebase-merge replays commits unsigned. +The handoff therefore specifies signed replacement branches/PRs through +GitHub's commit API, conflict escalation, fork handling, concurrency, nested +stacks, and deployment evidence. + +== 3. Issue #23, arena scope probe + +Issue #23 and https://github.com/hyperpolymath/absolute-zero/issues/192[absolute-zero#192] +were created by the owner one second apart with identical title/body. The +owner confirmed in this session that #23 was a one-off Arena access/scope +test: whether Arena could create issues in the selected repositories. Issue +creation succeeded. That is evidence of creation capability only, not a +broader permission or deployment guarantee. + +The integration could not write the closing comment or close the issue: +both REST calls returned HTTP 403 `Resource not accessible by integration`. +The issue therefore remains open until a permitted actor closes it as +completed. No product fix is claimed. + +== 4. Policy, security, quality, and CI/CD audit + +=== Branch policy + +Active repository rulesets require linear history, signed commits, and +`scan / gitleaks` (GitHub Actions integration id 15368). Branch-Floor forbids +deletion/non-fast-forward. The secret-scan ruleset has an administrator +bypass actor; this session does not use it. `.github/settings.yml` previously +advertised merge-commit and rebase-merge as allowed, contrary to the live +squash-only policy; it is corrected here. + +=== K9 / DEED / Hypatia + +This repository currently has no committed `.deed` or `.k9.ncl` payloads and +no fork-governance/Hypatia caller. The canonical K9 and DEED reusable gates +would report zero files unless `require-files: true` is adopted. That is a +coverage gap, not a conformance pass. This audit does not claim K9/DEED +conformance or a Hypatia verdict. The local environment lacked the canonical +Elixir/Hypatia toolchain, and release-asset downloads were blocked by the +sandbox egress policy, so no local Hypatia scan was run. + +=== GitHub alerts + +The integration received HTTP 403 for: + +* `repos/hyperpolymath/.github/dependabot/alerts` +* `repos/hyperpolymath/.github/secret-scanning/alerts` +* `repos/hyperpolymath/.github/code-scanning/alerts` +* branch protection and Actions permissions/variables endpoints + +Therefore alert counts and dispositions are not established here. The +visible PR checks are not a substitute for those alert inventories. + +=== Technical debt addressed + +* Removed stale `mise.toml` runtime/build template from a community-health + repository with no local build. +* Added `CHANGELOG.adoc` for the dependency update and repairs. +* Normalized SPDX placement in managed workflows. +* Added regression tests for the workflow coordinate/lock contracts. + +=== Remaining work + +. Merge the replacement PR only after the real required check and the new + workflow-contract check pass; then close PR #24 as superseded if permitted. +. Close issue #23 as completed with the confirmed purpose, if permitted. +. Implement and deploy D199 in `hyperpolymath/cicd-squabbler` under its own + session; this repository cannot deploy that service. +. Obtain a token/app permission set that can read security alerts and Actions + settings, then audit alert inventories and live posture. +. Decide whether this community-health repository should adopt repo DEED/K9 + metadata and fork-governance/Hypatia callers with `require-files: true`. +. Verify live SMTP delivery separately if `PUSH_EMAIL_ENABLED` is armed; the + repository variable endpoint was inaccessible, and this audit did not send + email. diff --git a/docs/handoffs/D199-post-squash-stack-recovery.adoc b/docs/handoffs/D199-post-squash-stack-recovery.adoc new file mode 100644 index 0000000..6fbf3d9 --- /dev/null +++ b/docs/handoffs/D199-post-squash-stack-recovery.adoc @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += D199 handoff: automated post-squash stack recovery +:toc: +:toclevels: 3 + +Status: handoff only. Not implemented or deployed by the `.github` session. +Target repository: https://github.com/hyperpolymath/cicd-squabbler[`hyperpolymath/cicd-squabbler`]. +Target service: `squabble-app` webhook leg, with a new stack-recovery move in +the squabble core/forge layers. Related owner rollout: +https://github.com/hyperpolymath/cicd-squabbler/issues/122[cicd-squabbler#122] +(D266/D267). Policy source: +https://github.com/hyperpolymath/.github/issues/13[.github#13] (D199) and +`metadatastician/standards/docs/SIGNING-POLICY.adoc`. + +== Why this repository/service + +D199 is estate-wide PR branch recovery, not a community-health file. The +owner has already ruled that squabble rolls out as a GitHub App installed on +all repositories of both accounts, hosted on owned compute, with no per-repo +workflow churn. `squabble-app` on `main` currently exposes only loopback +`/health`, `/api/v1/diagnose`, and `/api/v1/fight`; it has no webhook route or +App-auth leg. Implementing D199 here would put estate mutation logic in the +wrong source and would not satisfy the rollout decision. + +Where `gitbot-fleet/robot-repo-automaton` is present and working, respect the +estate precedence directive and avoid duplicate writers. The squabbler App +must either dispatch to the primary or hold a per-repo lease before mutating +branches. It must remain standalone when the fleet is absent. + +== Non-negotiable policy constraints + +* No unsigned bot pushes. +* No force-pushes, including `git push --force-with-lease` to a PR branch. +* No protection bypass, admin merge, required-context removal, or check rename. +* No silent conflict skip (`git rebase --skip` is not an autonomous remedy). +* Every new commit must be GitHub-verified, preferably web-flow GPG through + `createCommitOnBranch`. +* A recovered stack is not "green" until the downstream PR's own required + checks run and pass on the new head. + +Because a normal rebase updates a branch by non-fast-forward, D199 must be +implemented as *replacement-branch recovery*, not in-place force-push. + +== Event and detection + +Subscribe to `pull_request` `closed`. Act only when: + +. `merged == true`; +. the base is the repository default branch or a known stack base; +. the merge is a squash merge, evidenced by the merge commit and PR metadata, + not by title alone; +. the merged head branch has open downstream PRs targeting it. + +Record an idempotency key: repository id + merged PR number + merge commit SHA. +Persist it before mutation so webhook redelivery cannot create duplicate +replacement PRs. + +== Recovery algorithm + +For each downstream PR in topological order (base PR before children): + +. Fetch the downstream head, the merged base, and the old stack base into an + isolated temporary repository. Never operate on a user's working tree. +. Compute the post-squash replay: `git rebase --onto + ` in the temporary repository, or an equivalent three-way + cherry-pick sequence. Capture the exact commit range and tree hashes. +. If any conflict occurs, stop that branch. Do not push a partial result. + Post a structured report naming the PR, conflicting paths, old/new base + SHAs, and the manual command a human can run. Label it `stack:conflict` or + the repo's equivalent. Continue independent sibling stacks only if their + bases are unaffected. +. If clean, create a new branch in the same repository, for example + `stack-recovery/pr--`, from the new base. +. Recreate the downstream commits on that branch through GitHub's signed + commit API (`createCommitOnBranch`), preserving original authorship metadata + and adding a co-author/trailer for the recovery actor where appropriate. + Verify each returned commit has `verification.verified == true` before + proceeding. If the API cannot preserve the required file modes/binary + changes, escalate rather than pushing unsigned local commits. +. Open a replacement PR against the correct new base. Link the original PR, + merge commit, recovery run, and tree-equivalence evidence. Copy relevant + labels/review requests only where the API permits and the semantics are + safe. Do not copy approvals as if they apply to the new head. +. Close the original downstream PR as superseded only after the replacement + PR exists and the new head is visible. If closing is not permitted, leave a + comment and report the partial state. +. Re-target or recover nested children against the replacement branch/PR in + the same topological pass. A child must never be replayed onto a base that + has not itself been recovered or merged. + +== Forks + +* Never push to a fork without explicit permission and a verified signing + path. Default: create an upstream replacement branch from the fork head if + the App has read access and the repository permits it, then open an + upstream replacement PR crediting the fork author. +* If the fork head cannot be read or upstream replacement is not permitted, + do not silently skip. Post a recovery report to the original PR with the + exact conflict-free patch/branch instructions and mark it needing human + action. +* Private forks and deleted source branches are explicit escalation cases. + +== Concurrency and rate limits + +* Queue work per repository and per stack root. Use a lease or equivalent + compare-and-swap on the idempotency key. +* Use `expectedHeadOid` on every commit API call. On mismatch, re-fetch and + recompute; never overwrite a moving branch. +* Debounce by downstream head SHA. One installation token can cover hundreds + of repositories; pace requests and treat secondary-limit 403 as retryable + infrastructure failure, not a verdict. +* Make redelivery safe: if a replacement PR already exists for the same + idempotency key and tree hash, report it instead of creating another. + +== Required tests and evidence + +Unit/fixture tests: + +* squash merge with one downstream PR, clean replay; +* nested stack A -> B -> C, topological recovery; +* sibling stacks, independent failure isolation; +* conflict in a middle PR: no push, no silent skip, precise report; +* duplicate webhook delivery: one replacement PR; +* concurrent head update: `expectedHeadOid` refusal and recompute; +* fork PR: no unauthorized fork push; +* deleted fork/base branch: explicit escalation; +* binary file, executable bit, rename, and delete preservation; +* unsigned API response or missing verification: stop before closing old PR; +* rate-limit/secondary-limit response: retry/escalate, never green. + +Integration tests: + +* Mock GitHub GraphQL/REST with recorded fixtures for `createCommitOnBranch`, + PR creation, PR close, labels, and check-rollup reads. +* A disposable canary repository with a real squash merge and a two-level + stack. Evidence must include old/new SHAs, replacement PR URLs, verified + commit badges/API verification, required-check runs on the new heads, and + the final merge state. +* A paired conflict canary proving the service refuses to push and reports + the conflicting paths. + +Deployment evidence: + +* App registration/installation ids and permission set, without secrets. +* Webhook signature verification: bad signature -> 401. +* Ingress/TLS and loopback origin configuration on owned compute. +* Health/readiness and queue/lease observability. +* A redelivered production webhook that is idempotent. +* Explicit statement that the squabble advisory check is not required. + +== Acceptance + +D199 is complete only when a squash merge automatically produces verified, +conflict-free replacement downstream PRs (or explicit conflict/fork +escalations), nested stacks recover in order, duplicate/concurrent deliveries +are safe, and the downstream required checks pass on the recovered heads. +Closing the tracking issue or seeing the App online is not sufficient. diff --git a/mise.toml b/mise.toml deleted file mode 100644 index 6dd983f..0000000 --- a/mise.toml +++ /dev/null @@ -1,57 +0,0 @@ -[tools] -# Language runtimes -node = "latest" -python = "latest" -rust = "latest" -go = "latest" -zig = "latest" -java = "latest" -bun = "latest" -denojs = "latest" - -# Package managers -npm = "latest" -yarn = "latest" -pnpm = "latest" -pip = "latest" -cargo = "latest" -go-task = "latest" - -# Formatting & Linting -gofmt = "latest" -black = "latest" -isort = "latest" -ruff = "latest" -prettier = "latest" -shfmt = "latest" -stylua = "latest" - -# Build tools -cmake = "latest" -make = "latest" -ninja = "latest" - -# Shell tools -git = "latest" -gnu-sed = "latest" -gnu-tar = "latest" -gnu-grep = "latest" - -# Testing -vitest = "latest" -pytest = "latest" -jest = "latest" - -[env] -# Common environment variables -NODE_ENV = "development" -PYTHONDONTWRITEBYTECODE = "1" -PYTHONUNBUFFERED = "1" - -# Task runner alias -[alias] -task = "go-task" -build = "cargo build --release || npm run build || go build" -test = "cargo test || npm test || go test ./..." -lint = "ruff check . || prettier --check . || black --check ." -fmt = "ruff format . || prettier --write . || black ." From 60e9736c27b38f48f3a67e3c1fd7adb377102c50 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:06:37 +0000 Subject: [PATCH 2/7] docs(ci): explain workflow contract failure reporting --- .github/scripts/check-workflow-contracts.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/check-workflow-contracts.sh b/.github/scripts/check-workflow-contracts.sh index 269b010..76bc601 100644 --- a/.github/scripts/check-workflow-contracts.sh +++ b/.github/scripts/check-workflow-contracts.sh @@ -13,6 +13,7 @@ WF_DIR="$ROOT/.github/workflows" LOCK="$WF_DIR/actions.lock" failures=0 +# Report a contract violation to stderr and count it for the final exit status. fail() { printf 'ERROR: %s\n' "$*" >&2 failures=$((failures + 1)) From 58f62b79243891d97693ee34a45c96fbd331d768 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:35:25 +0000 Subject: [PATCH 3/7] fix(ci): YAML-aware contracts, advisory CodeRabbit, and review repairs Replace grep-based contract checker with PyYAML-aware Python checker. Address all 8 CodeRabbit/Codex review findings. Disable CodeRabbit code-changing finishing touches (unsigned autofix). Preserve CodeRabbit docstring intent in replacement Python fail(). Update D199 handoff, audit, and changelog. --- .coderabbit.yaml | 18 ++ .github/scripts/check-workflow-contracts.sh | 87 +------ .github/scripts/check_workflow_contracts.py | 221 ++++++++++++++++++ .github/scripts/requirements.txt | 76 ++++++ .../scripts/tests/workflow-contracts-test.sh | 40 +++- .github/workflows/actions.lock | 3 +- .github/workflows/push-email-notify.yml | 1 - .github/workflows/workflow-contracts.yml | 6 + CHANGELOG.adoc | 20 +- docs/audits/2026-11-02-open-work-audit.adoc | 67 ++++-- .../D199-post-squash-stack-recovery.adoc | 107 +++++++-- 11 files changed, 516 insertions(+), 130 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .github/scripts/check_workflow_contracts.py create mode 100644 .github/scripts/requirements.txt diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..e34da68 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MPL-2.0 +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# CodeRabbit is advisory in this repository. The estate signing policy forbids +# bot pushes to PR branches; every landed commit must be GitHub-verified. +# Disable CodeRabbit's code-changing finishing touches. This does not reduce +# the GitHub App's permissions or disable a separately enabled Autopilot task. +reviews: + finishing_touches: + docstrings: + enabled: false + unit_tests: + enabled: false + autofix: + enabled: false + fix_ci: + enabled: false + resolve_merge_conflict: + enabled: false diff --git a/.github/scripts/check-workflow-contracts.sh b/.github/scripts/check-workflow-contracts.sh index 76bc601..be8ca6f 100644 --- a/.github/scripts/check-workflow-contracts.sh +++ b/.github/scripts/check-workflow-contracts.sh @@ -1,86 +1,11 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# -# Narrow, fail-closed contracts for this repository's workflow coordinates. -# This is not a replacement for `gh actions-lock --verify`; it catches the -# estate-specific regressions that a syntactically valid lock can still hide: -# moved reusable owners, non-immutable reusable pins, missing SPDX headers, -# and an inline notification-action SHA that disagrees with actions.lock. +# Wrapper for the YAML-aware workflow contract checker. set -euo pipefail -ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" -WF_DIR="$ROOT/.github/workflows" -LOCK="$WF_DIR/actions.lock" -failures=0 - -# Report a contract violation to stderr and count it for the final exit status. -fail() { - printf 'ERROR: %s\n' "$*" >&2 - failures=$((failures + 1)) -} - -[ -d "$WF_DIR" ] || { echo "no workflow directory: $WF_DIR" >&2; exit 2; } -[ -f "$LOCK" ] || { echo "no actions.lock: $LOCK" >&2; exit 2; } - -shopt -s nullglob -workflows=("$WF_DIR"/*.yml "$WF_DIR"/*.yaml) -[ "${#workflows[@]}" -gt 0 ] || { echo "no workflow files" >&2; exit 2; } - -for wf in "${workflows[@]}"; do - rel=".github/workflows/$(basename "$wf")" - - if [ "$(head -n 1 "$wf")" != '# SPDX-License-Identifier: MPL-2.0' ]; then - fail "$rel: SPDX-License-Identifier must be the first line" - fi - - if ! grep -Fq " '$rel':" "$LOCK"; then - fail "$rel: missing actions.lock workflows entry" - fi - - if grep -nE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*hyperpolymath/standards/' "$wf"; then - fail "$rel: pre-transfer hyperpolymath/standards reusable coordinate" - fi - - while IFS= read -r line; do - ref=$(printf '%s\n' "$line" | sed -E 's/^[^@]*@([^ #]+).*/\1/') - if [[ ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then - fail "$rel: remote reusable must be pinned to a 40-hex commit: $line" - fi - done < <(grep -E '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[^@[:space:]]+@' "$wf" || true) -done - -# The notification action is tag-pinned in YAML because actions.lock is the -# pin authority. Its inline comment must name the same commit as the lock. -notify="$WF_DIR/push-email-notify.yml" -if [ -f "$notify" ]; then - uses_line=$(grep -E '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*hyperpolymath/smtp-notify-action@' "$notify" | head -n 1 || true) - if [ -z "$uses_line" ]; then - fail "push-email-notify.yml: missing smtp-notify-action uses line" - else - tag=$(printf '%s\n' "$uses_line" | sed -E 's/^[^@]*@([^ #]+).*/\1/') - sha=$(printf '%s\n' "$uses_line" | sed -nE 's/.*sha1-([0-9a-f]{40}).*/\1/p') - case "$tag" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) fail "push-email-notify.yml: expected an exact release tag, got $tag" ;; - esac - if [ -z "$sha" ]; then - fail "push-email-notify.yml: missing inline sha1 pin comment" - fi - key=" 'hyperpolymath/smtp-notify-action@$tag':" - if ! grep -Fq "$key" "$LOCK"; then - fail "actions.lock: missing dependency key for smtp-notify-action@$tag" - fi - if ! grep -A4 -F "$key" "$LOCK" | grep -Fq "commit: 'sha1-$sha'"; then - fail "actions.lock: smtp-notify-action@$tag commit does not match inline sha1-$sha" - fi - if ! grep -Fq " - 'hyperpolymath/smtp-notify-action@$tag'" "$LOCK"; then - fail "actions.lock: push-email-notify.yml does not reference smtp-notify-action@$tag" - fi - fi -fi - -if [ "$failures" -gt 0 ]; then - printf '%s workflow contract failure(s)\n' "$failures" >&2 - exit 1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required to inspect workflow contracts" >&2 + exit 2 fi -echo 'workflow contracts: OK' +exec python3 "$SCRIPT_DIR/check_workflow_contracts.py" "$@" diff --git a/.github/scripts/check_workflow_contracts.py b/.github/scripts/check_workflow_contracts.py new file mode 100644 index 0000000..1211822 --- /dev/null +++ b/.github/scripts/check_workflow_contracts.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Narrow, fail-closed workflow contracts for hyperpolymath/.github. + +This is not a replacement for `gh actions-lock --verify`. It catches the +estate-specific regressions that a syntactically valid lock can still hide: +moved reusable owners, non-immutable reusable pins, missing SPDX headers, +synthetic lock edges for remote reusable workflows, and inline notification +action SHAs that disagree with actions.lock. + +Exit codes: + 0 every inspected contract passed + 1 at least one contract violation + 2 not inspected (missing input, unreadable YAML, missing PyYAML) +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +try: + import yaml +except ImportError: # pragma: no cover - environment failure, not a pass + print("ERROR: PyYAML is required to inspect workflow contracts", file=sys.stderr) + sys.exit(2) + + +class UniqueKeyLoader(yaml.SafeLoader): + """SafeLoader that rejects duplicate mapping keys instead of hiding them.""" + + +def _unique_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False) -> dict: + loader.flatten_mapping(node) + result: dict = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in result: + raise ValueError(f"duplicate YAML key: {key!r}") + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping) + +REMOTE_REUSABLE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[^@]+@(.+)$") +SHA40 = re.compile(r"^[0-9a-f]{40}$") +SMTP_REF = re.compile(r"^hyperpolymath/smtp-notify-action@(v[0-9]+\.[0-9]+\.[0-9]+)$") +INLINE_SHA = re.compile(r"sha1-([0-9a-f]{40})") + +failures: list[str] = [] + + +def fail(message: str) -> None: + """Report a contract violation and count it for the final exit status.""" + failures.append(message) + + +def load_yaml(path: pathlib.Path): + try: + text = path.read_text(encoding="utf-8") + return yaml.load(text, Loader=UniqueKeyLoader), text + except Exception as exc: + print(f"ERROR: cannot inspect {path}: {exc}", file=sys.stderr) + sys.exit(2) + + +def iter_uses(node, path=()): + """Yield (value, zero-based line, path) for every scalar `uses:` key.""" + if isinstance(node, yaml.MappingNode): + for key_node, value_node in node.value: + if not isinstance(key_node, yaml.ScalarNode): + continue + key = key_node.value + if key == "uses" and isinstance(value_node, yaml.ScalarNode): + yield value_node.value, value_node.start_mark.line, path + yield from iter_uses(value_node, path + (key,)) + elif isinstance(node, yaml.SequenceNode): + for index, item in enumerate(node.value): + yield from iter_uses(item, path + (str(index),)) + + +def main() -> int: + root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path.cwd() + wf_dir = root / ".github" / "workflows" + lock_path = wf_dir / "actions.lock" + + if not wf_dir.is_dir(): + print(f"ERROR: no workflow directory: {wf_dir}", file=sys.stderr) + return 2 + if not lock_path.is_file(): + print(f"ERROR: no actions.lock: {lock_path}", file=sys.stderr) + return 2 + + workflows = sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))) + if not workflows: + print("ERROR: no workflow files", file=sys.stderr) + return 2 + + lock, _ = load_yaml(lock_path) + if not isinstance(lock, dict): + print("ERROR: actions.lock is not a mapping", file=sys.stderr) + return 2 + lock_workflows = lock.get("workflows") + dependencies = lock.get("dependencies") + if not isinstance(lock_workflows, dict) or not isinstance(dependencies, dict): + print("ERROR: actions.lock workflows/dependencies are not mappings", file=sys.stderr) + return 2 + + for wf in workflows: + rel = f".github/workflows/{wf.name}" + try: + first_line = wf.read_text(encoding="utf-8").splitlines()[0] + except Exception as exc: + print(f"ERROR: cannot read {rel}: {exc}", file=sys.stderr) + return 2 + if first_line != "# SPDX-License-Identifier: MPL-2.0": + fail(f"{rel}: SPDX-License-Identifier must be the first line") + + if rel not in lock_workflows: + fail(f"{rel}: missing actions.lock workflows entry") + entry = [] + else: + entry = lock_workflows[rel] + if not isinstance(entry, list): + fail(f"{rel}: actions.lock workflow entry is not a list") + entry = [] + + data, text = load_yaml(wf) + try: + composed = yaml.compose(text, Loader=yaml.SafeLoader) + except Exception as exc: + print(f"ERROR: cannot compose {rel}: {exc}", file=sys.stderr) + return 2 + lines = text.splitlines() + uses = list(iter_uses(composed)) if composed is not None else [] + + has_step_use = False + has_remote_reusable = False + for value, line_no, path in uses: + if not isinstance(value, str): + fail(f"{rel}:{line_no + 1}: uses value is not a string") + continue + if "steps" in path: + has_step_use = True + + if value.startswith("hyperpolymath/standards/"): + fail(f"{rel}:{line_no + 1}: pre-transfer hyperpolymath/standards coordinate: {value}") + + if "/.github/workflows/" in value and not value.startswith(("./", "$/")): + has_remote_reusable = True + match = REMOTE_REUSABLE.match(value) + if not match: + fail(f"{rel}:{line_no + 1}: remote reusable must name an exact ref: {value}") + elif not SHA40.match(match.group(1)): + fail(f"{rel}:{line_no + 1}: remote reusable must be pinned to a 40-hex commit: {value}") + + # gh-actions-lock v0.4.0 does not follow remote job-level reusable + # workflows. A synthetic through-ref under a reusable-only caller is + # an unused pin and fails --verify. Keep the reviewed closure TSV as + # the trust record, but do not put that edge in the caller lock entry. + if has_remote_reusable and not has_step_use and entry: + fail(f"{rel}: reusable-only workflow must have an empty actions.lock entry") + + for pin in entry: + if not isinstance(pin, str): + fail(f"{rel}: non-string actions.lock pin: {pin!r}") + elif pin not in dependencies: + fail(f"{rel}: actions.lock pin has no dependency record: {pin}") + + if wf.name == "push-email-notify.yml": + smtp_count = 0 + for value, line_no, _path in uses: + if not isinstance(value, str): + continue + match = SMTP_REF.match(value) + if not match: + continue + smtp_count += 1 + tag = match.group(1) + raw_line = lines[line_no] if line_no < len(lines) else "" + inline = INLINE_SHA.search(raw_line) + if not inline: + fail(f"{rel}:{line_no + 1}: missing inline sha1 pin comment") + continue + sha = inline.group(1) + key = f"hyperpolymath/smtp-notify-action@{tag}" + dep = dependencies.get(key) + if not isinstance(dep, dict): + fail(f"actions.lock: missing dependency record for {key}") + elif dep.get("commit") != f"sha1-{sha}": + fail(f"actions.lock: {key} commit does not match inline sha1-{sha}") + if value not in entry: + fail(f"actions.lock: {rel} does not reference {value}") + if smtp_count == 0: + fail(f"{rel}: missing smtp-notify-action uses line") + + for key, dep in dependencies.items(): + if not isinstance(dep, dict): + fail(f"actions.lock: dependency {key} is not a mapping") + continue + commit = dep.get("commit") + if not isinstance(commit, str) or not re.match(r"^sha1-[0-9a-f]{40}$", commit): + fail(f"actions.lock: dependency {key} has invalid commit: {commit!r}") + ref = dep.get("ref") + key_sha = key.rsplit("@", 1)[-1] + if SHA40.match(key_sha) and isinstance(ref, str) and ref == key_sha and "uses" not in dep: + fail(f"actions.lock: dependency {key} repeats its own SHA in ref") + + if failures: + for message in failures: + print(f"ERROR: {message}", file=sys.stderr) + print(f"{len(failures)} workflow contract failure(s)", file=sys.stderr) + return 1 + print("workflow contracts: OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 0000000..07eacc3 --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: MPL-2.0 +# Contract-checker dependency. Hashes cover every PyPI artifact for this release. +PyYAML==6.0.3 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f diff --git a/.github/scripts/tests/workflow-contracts-test.sh b/.github/scripts/tests/workflow-contracts-test.sh index b2b7e1a..985c0bb 100644 --- a/.github/scripts/tests/workflow-contracts-test.sh +++ b/.github/scripts/tests/workflow-contracts-test.sh @@ -16,12 +16,8 @@ version: 'v0.0.3' workflows: '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.5.1' - '.github/workflows/secret-scanner.yml': - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/secret-scanner.yml': [] dependencies: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' 'hyperpolymath/smtp-notify-action@v0.5.1': ref: 'v0.5.1' commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' @@ -73,13 +69,45 @@ fixture floating_pin sed -i 's/@8d1a2aab4211493cf42f147f2080f024605797dc/@main/' "$WORK/floating_pin/.github/workflows/secret-scanner.yml" expect floating_reusable_pin 1 "$WORK/floating_pin" +fixture quoted_moved_owner +sed -i 's#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#uses: "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc"#' "$WORK/quoted_moved_owner/.github/workflows/secret-scanner.yml" +expect quoted_moved_reusable_owner 1 "$WORK/quoted_moved_owner" + +fixture quoted_floating_pin +sed -i "s#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#uses: 'metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@main'#" "$WORK/quoted_floating_pin/.github/workflows/secret-scanner.yml" +expect quoted_floating_reusable_pin 1 "$WORK/quoted_floating_pin" + fixture missing_spdx sed -i '1s/.*/# managed/' "$WORK/missing_spdx/.github/workflows/secret-scanner.yml" expect missing_spdx_first_line 1 "$WORK/missing_spdx" fixture missing_lock_entry -sed -i "/secret-scanner.yml':/,+1d" "$WORK/missing_lock_entry/.github/workflows/actions.lock" +sed -i "/secret-scanner.yml':/d" "$WORK/missing_lock_entry/.github/workflows/actions.lock" expect missing_lock_workflow_entry 1 "$WORK/missing_lock_entry" +fixture synthetic_reusable_edge +sed -i "s#'.github/workflows/secret-scanner.yml': \[\]#'.github/workflows/secret-scanner.yml':\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'#" "$WORK/synthetic_reusable_edge/.github/workflows/actions.lock" +expect synthetic_reusable_only_lock_edge 1 "$WORK/synthetic_reusable_edge" + +fixture second_smtp_stale +cat >> "$WORK/second_smtp_stale/.github/workflows/push-email-notify.yml" <<'YAML' + second: + steps: + - uses: hyperpolymath/smtp-notify-action@v0.5.1 # sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7 +YAML +expect second_smtp_use_stale_sha 1 "$WORK/second_smtp_stale" + +fixture second_smtp_missing_sha +cat >> "$WORK/second_smtp_missing_sha/.github/workflows/push-email-notify.yml" <<'YAML' + second: + steps: + - uses: hyperpolymath/smtp-notify-action@v0.5.1 +YAML +expect second_smtp_use_missing_inline_sha 1 "$WORK/second_smtp_missing_sha" + +fixture duplicate_yaml_key +printf '\njobs: {}\n' >> "$WORK/duplicate_yaml_key/.github/workflows/secret-scanner.yml" +expect duplicate_yaml_key_not_inspected 2 "$WORK/duplicate_yaml_key" + printf '%s passed, %s failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 2367e9c..f83043e 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -7,8 +7,7 @@ workflows: '.github/workflows/labels.yml': [] '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.5.1' - '.github/workflows/secret-scanner.yml': - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/secret-scanner.yml': [] '.github/workflows/workflow-contracts.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' dependencies: diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index e6f5c74..9e48973 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -1,6 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -# This workflow is managed by gh actions-lock. # Dormant push-email notification. ARMED by setting the repo variable # PUSH_EMAIL_ENABLED=true (the single on/off switch). Addresses are pre-filled; # sending needs the org SMTP secrets (SMTP_HOST/PORT/USER/PASS). Inherited by diff --git a/.github/workflows/workflow-contracts.yml b/.github/workflows/workflow-contracts.yml index 43d8dc2..f434832 100644 --- a/.github/workflows/workflow-contracts.yml +++ b/.github/workflows/workflow-contracts.yml @@ -28,6 +28,12 @@ jobs: with: persist-credentials: false + - name: Install contract checker dependency + run: | + python3 -m venv "$RUNNER_TEMP/contract-venv" + "$RUNNER_TEMP/contract-venv/bin/pip" install --disable-pip-version-check --require-hashes --only-binary=:all: -r .github/scripts/requirements.txt + echo "$RUNNER_TEMP/contract-venv/bin" >> "$GITHUB_PATH" + - name: Regression tests for the contract checker run: bash .github/scripts/tests/workflow-contracts-test.sh diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 63d107c..cbd5024 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -5,8 +5,9 @@ === Dependencies -* Update `hyperpolymath/smtp-notify-action` from v0.5.0 to v0.5.1 and reconcile - `actions.lock` and the inline pin comment. Carries forward Dependabot PR #24. +* Reconcile `actions.lock` and the inline pin comment for + `hyperpolymath/smtp-notify-action` v0.5.1, merged by Dependabot PR #24. + PR #25 is the follow-up CI repair, not a second dependency bump. === Fixed @@ -15,9 +16,20 @@ failure and left the required `scan / gitleaks` check absent. * Stop inheriting all secrets into the secret scanner; the pinned reusable declares no secrets. -* Add workflow contract checks for moved reusable coordinates, immutable - reusable pins, SPDX headers, lockfile coverage, and stale inline action SHAs. +* Add YAML-aware workflow contract checks for moved reusable coordinates, + immutable reusable pins, quoted `uses:` values, SPDX headers, lockfile + coverage, synthetic reusable-only lock edges, duplicate YAML keys, and + every stale inline notification-action SHA. +* Keep the reusable-only Secret Scanner lock entry empty so + `gh-actions-lock --verify` does not reject an unused synthetic through-ref; + retain the reviewed closure TSV as an advisory trust record. * Align `.github/settings.yml` merge methods with the live squash-only, signed, linear-history rulesets. * Remove the stale `mise.toml` runtime/build template from this community-health repository. +* Tighten the D199 handoff: fail-closed squash classification, resumable + per-downstream-PR idempotency, source-head revalidation, explicit + `createCommitOnBranch` authorship limits, and GraphQL/REST signature checks. +* Configure CodeRabbit as advisory-only for code-changing finishing touches + after an unsigned autofix commit reached the PR branch; the estate signing + policy requires GitHub-verified commits on PR branches. diff --git a/docs/audits/2026-11-02-open-work-audit.adoc b/docs/audits/2026-11-02-open-work-audit.adoc index a91195b..9fdbd5b 100644 --- a/docs/audits/2026-11-02-open-work-audit.adoc +++ b/docs/audits/2026-11-02-open-work-audit.adoc @@ -38,9 +38,17 @@ however, stale in two ways: `main` still locked smtp-notify-action v0.3.0 while the workflow used v0.5.0, and PR #24's inline comment still named the v0.2.0 commit `ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7`. +PR #24 was merged by the repository owner at 2026-11-02 19:18 UTC as +`f0f7896e4fd8a05e533a59dc4ee88aaa136fc523`. The merge did not repair the +caller: post-merge Secret Scanner run +https://github.com/hyperpolymath/.github/actions/runs/41508702476[41508702476] +also completed with `failure` and zero jobs. `main` therefore remained broken +after the dependency bump landed. + === Source repair -This branch carries forward PR #24's dependency bump and repairs the source: +PR #25 is the follow-up repair. It keeps the v0.5.1 dependency bump already +merged by PR #24 and repairs the source: * `.github/workflows/secret-scanner.yml` now calls `metadatastician/standards/.github/workflows/secret-scanner-reusable.yml` @@ -48,15 +56,20 @@ This branch carries forward PR #24's dependency bump and repairs the source: * The caller grants only `contents: read` and no longer uses `secrets: inherit`; the pinned reusable declares no secrets. * `.github/workflows/actions.lock` records smtp-notify-action v0.5.1 at - `e384daf6167a7a7567642d6a62060106244d2413`, keeps the checkout pin, and - records the through-reusable checkout dependency. -* `.github/workflows/actions-lock.closure.tsv` records the reviewed remote - reusable closure. + `e384daf6167a7a7567642d6a62060106244d2413` and the checkout pin used by + `workflow-contracts.yml`. The reusable-only Secret Scanner caller has an + empty lock entry: `gh-actions-lock` v0.4.0 does not follow remote job-level + reusable workflows, and a synthetic through-ref there fails `--verify`. +* `.github/workflows/actions-lock.closure.tsv` remains a reviewed trust + record for the remote reusable closure. It is advisory while upstream + `gh-actions-lock` cannot represent that edge. * The inline smtp-notify-action comment now names the v0.5.1 commit. -* `.github/workflows/workflow-contracts.yml` and - `.github/scripts/check-workflow-contracts.sh` add regression checks for - moved reusable coordinates, floating reusable pins, SPDX placement, - lockfile coverage, and stale inline SHAs. +* `.github/workflows/workflow-contracts.yml`, + `.github/scripts/check-workflow-contracts.sh`, and + `.github/scripts/check_workflow_contracts.py` add YAML-aware regression + checks for moved reusable coordinates, floating reusable pins, quoted + `uses:` values, SPDX placement, lockfile coverage, synthetic reusable-only + lock edges, duplicate YAML keys, and every stale inline notification SHA. === Dependency review @@ -79,13 +92,34 @@ because the author was a bot. GitGuardian and Semgrep reported success; CodeQL reported neutral. Those app checks do not substitute for the missing required `scan / gitleaks` job. +PR #25 received actionable automated review findings. The initial head +`3106fca2` was corrected before merge: + +* Codex: remove the synthetic `actions/checkout` edge from the reusable-only + Secret Scanner lock entry; `gh-actions-lock` v0.4.0 cannot follow remote + job-level reusable workflows and `--verify` rejects the unused pin. +* CodeRabbit: parse YAML rather than grepping unquoted `uses:` values, check + every notification-action use, and make the D199 handoff precise about + squash evidence, resumable per-PR idempotency, source-head revalidation, + authorship limits, and GraphQL signature fields. + +CodeRabbit also pushed autofix commit `60e9736` to the PR branch. REST reports +that commit as `unsigned`; the estate signing policy forbids bot pushes to PR +branches. This session did not push it. The follow-up signed commit preserves +the intent of its docstring in the replacement Python checker, and +`.coderabbit.yaml` disables CodeRabbit's code-changing finishing touches +(`docstrings`, `unit_tests`, `autofix`, `fix_ci`, `resolve_merge_conflict`). +That repository setting does not reduce the App's permissions or disable a +separately enabled Autopilot task. The unsigned commit remains in the PR +history and must be considered by the merge gate; no force-push or admin +bypass is used to hide it. + === Merge path -This session may push only `arena/1e24712e-github`, not PR #24's Dependabot -branch. The repair therefore lands through a signed replacement PR from the -session branch. PR #24 should be closed as superseded only after the -replacement merges. No admin bypass, force-push, or required-context removal -is used. +This session may push only `arena/1e24712e-github`. PR #24 was merged by the +owner before the repair landed, so PR #25 is a follow-up repair to `main`, +not a replacement for an open PR. No admin bypass, force-push, or +required-context removal is used by this session. == 2. Issue #13 / D199 @@ -161,8 +195,9 @@ visible PR checks are not a substitute for those alert inventories. === Remaining work -. Merge the replacement PR only after the real required check and the new - workflow-contract check pass; then close PR #24 as superseded if permitted. +. Merge PR #25 only after the real required check and the new + workflow-contract check pass on its final head. PR #24 is already merged; + no closure action remains for it. . Close issue #23 as completed with the confirmed purpose, if permitted. . Implement and deploy D199 in `hyperpolymath/cicd-squabbler` under its own session; this repository cannot deploy that service. diff --git a/docs/handoffs/D199-post-squash-stack-recovery.adoc b/docs/handoffs/D199-post-squash-stack-recovery.adoc index 6fbf3d9..28b5a0c 100644 --- a/docs/handoffs/D199-post-squash-stack-recovery.adoc +++ b/docs/handoffs/D199-post-squash-stack-recovery.adoc @@ -43,17 +43,61 @@ implemented as *replacement-branch recovery*, not in-place force-push. == Event and detection -Subscribe to `pull_request` `closed`. Act only when: +Subscribe to `pull_request` `closed`. Act only when all of the following are +established from fresh API reads, not from the webhook payload alone: . `merged == true`; . the base is the repository default branch or a known stack base; -. the merge is a squash merge, evidenced by the merge commit and PR metadata, - not by title alone; +. the merge is positively classified as a squash merge; . the merged head branch has open downstream PRs targeting it. -Record an idempotency key: repository id + merged PR number + merge commit SHA. -Persist it before mutation so webhook redelivery cannot create duplicate -replacement PRs. +Squash classification must be fail-closed. GitHub does not expose a stable +post-merge `mergeMethod` field on `PullRequest`, and `merge_commit_sha` alone +does not identify the method. The implementation must record the exact +checks it uses. The minimum evidence set is: + +* repository merge settings at event time permit squash and forbid merge + commits and rebase merges (`allow_squash_merge == true`, + `allow_merge_commit == false`, `allow_rebase_merge == false`); +* the PR is merged and has a non-null `merge_commit_sha`; +* the merge commit is reachable from the base branch and has exactly one + parent, ruling out an ordinary two-parent merge commit; +* the PR timeline contains the corresponding `merged` event and no + merge-queue entry; +* the commit message/tree metadata is consistent with GitHub's squash commit + for that PR, not merely a title containing `(#N)`. + +If any evidence is missing, unreadable, or conflicting, record +`ambiguous_merge_method` and take no mutating action. Merge commits, rebase +merges, merge-queue merges, and ambiguous events are negative cases, not +silent skips. + +== Idempotency and resume state + +Record a stack event key: repository id + merged PR number + merge commit SHA. +Also record a separate recovery row for each downstream PR: + +`repository id + downstream PR number + stack event key`. + +Each row must persist a state machine, lease owner, lease expiry, attempt +count, last error, captured downstream head/base SHAs, replay tree hash, +replacement branch name, replacement PR number/URL, and original-PR closure +status. Suggested states: + +* `observed` +* `replayed` +* `branch_created` +* `pr_created` +* `original_closed` +* `completed` +* `escalated` +* `failed_retryable` + +Persist the row before mutation. Webhook redelivery must resume non-terminal +rows rather than suppress the only retry. A stack-level key alone is not +sufficient because siblings can partially succeed. If a replacement PR +already exists for the same row and tree hash, report it instead of creating +another. == Recovery algorithm @@ -69,21 +113,36 @@ For each downstream PR in topological order (base PR before children): SHAs, and the manual command a human can run. Label it `stack:conflict` or the repo's equivalent. Continue independent sibling stacks only if their bases are unaffected. -. If clean, create a new branch in the same repository, for example - `stack-recovery/pr--`, from the new base. +. Immediately before creating the replacement branch, re-fetch the original + downstream PR and its base. If the PR is no longer open, or its head/base + SHA differs from the captured values, discard the stale replay and + recompute or escalate. `expectedHeadOid` protects the new recovery branch; + it does not protect the original downstream head. +. If clean and still current, create a new branch in the same repository, for + example `stack-recovery/pr--`, from the new base. . Recreate the downstream commits on that branch through GitHub's signed - commit API (`createCommitOnBranch`), preserving original authorship metadata - and adding a co-author/trailer for the recovery actor where appropriate. - Verify each returned commit has `verification.verified == true` before - proceeding. If the API cannot preserve the required file modes/binary - changes, escalate rather than pushing unsigned local commits. + commit API (`createCommitOnBranch`). This API authors commits as the + credential owner; it cannot preserve the original primary author. Preserve + original author/committer metadata in the recovery report and add + `Co-authored-by` trailers where appropriate, but do not claim primary + authorship is preserved. If exact primary authorship is a hard requirement, + escalate or use a separately approved verified signing path with explicit + author metadata; never push unsigned local commits. +. Verify each returned commit before proceeding. In GraphQL, require + `signature.isValid == true` and `signature.wasSignedByGitHub == true`; + a missing signature is a failure. As a cross-check, REST + `commit.verification.verified` must also be true. If the API cannot preserve + required file modes/binary changes, escalate rather than pushing unsigned + local commits. . Open a replacement PR against the correct new base. Link the original PR, merge commit, recovery run, and tree-equivalence evidence. Copy relevant labels/review requests only where the API permits and the semantics are safe. Do not copy approvals as if they apply to the new head. -. Close the original downstream PR as superseded only after the replacement - PR exists and the new head is visible. If closing is not permitted, leave a - comment and report the partial state. +. Immediately before closing the original downstream PR, re-fetch it again. + Close it as superseded only if it is still open, its head/base still match + the recovered values, and the replacement PR exists with the new head + visible. If closing is not permitted or the PR changed, leave a comment and + report the partial state. . Re-target or recover nested children against the replacement branch/PR in the same topological pass. A child must never be replayed onto a base that has not itself been recovered or merged. @@ -103,29 +162,37 @@ For each downstream PR in topological order (base PR before children): == Concurrency and rate limits * Queue work per repository and per stack root. Use a lease or equivalent - compare-and-swap on the idempotency key. + compare-and-swap on the per-downstream-PR recovery row. * Use `expectedHeadOid` on every commit API call. On mismatch, re-fetch and recompute; never overwrite a moving branch. * Debounce by downstream head SHA. One installation token can cover hundreds of repositories; pace requests and treat secondary-limit 403 as retryable infrastructure failure, not a verdict. * Make redelivery safe: if a replacement PR already exists for the same - idempotency key and tree hash, report it instead of creating another. + recovery row and tree hash, report it instead of creating another. +* Crash recovery must resume `replayed`, `branch_created`, and `pr_created` + rows. A row is terminal only when `completed` or explicitly `escalated`. == Required tests and evidence Unit/fixture tests: * squash merge with one downstream PR, clean replay; +* merge commit, rebase merge, and merge-queue merge: no replacement PR; +* ambiguous or conflicting merge evidence: no mutation, escalation recorded; * nested stack A -> B -> C, topological recovery; * sibling stacks, independent failure isolation; * conflict in a middle PR: no push, no silent skip, precise report; * duplicate webhook delivery: one replacement PR; -* concurrent head update: `expectedHeadOid` refusal and recompute; +* crash after persistence but before replacement PR creation: resume succeeds; +* concurrent downstream head update: stale replay discarded and recomputed; +* concurrent original-PR closure/merge: no stale closure; +* `expectedHeadOid` refusal on the recovery branch: recompute; * fork PR: no unauthorized fork push; * deleted fork/base branch: explicit escalation; * binary file, executable bit, rename, and delete preservation; -* unsigned API response or missing verification: stop before closing old PR; +* unsigned API response, missing GraphQL signature, or failed REST verification: + stop before closing old PR; * rate-limit/secondary-limit response: retry/escalate, never green. Integration tests: From 7719fed2d78310b25ecf64484e4d6219187ce2af Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:56:52 +0000 Subject: [PATCH 4/7] chore(ci): align conflict files with main before branch update Temporarily take main's versions of actions.lock and push-email-notify.yml so the merged PR #24 can be merged into this branch without a force-push. The follow-up commit reapplies the repaired lockfile and inline SHA. --- .github/workflows/actions.lock | 7 ------- .github/workflows/push-email-notify.yml | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index f83043e..7155887 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -8,14 +8,7 @@ workflows: '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.5.1' '.github/workflows/secret-scanner.yml': [] - '.github/workflows/workflow-contracts.yml': - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' dependencies: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 'hyperpolymath/smtp-notify-action@v0.5.1': ref: 'v0.5.1' commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index 9e48973..e0f1d22 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -1,3 +1,4 @@ +# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Dormant push-email notification. ARMED by setting the repo variable @@ -40,7 +41,7 @@ jobs: timeout-minutes: 5 steps: - name: Send push notification email - uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-e384daf6167a7a7567642d6a62060106244d2413) + uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7) with: server_address: ${{ secrets.SMTP_HOST }} server_port: ${{ secrets.SMTP_PORT }} From 99442b7b39588fe062a2982a2ac97d860fe19690 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:09:57 +0000 Subject: [PATCH 5/7] fix(ci): reapply repaired lockfile and inline SHA after main merge Restore the workflow-contracts checkout lock entry and the verified smtp-notify-action v0.5.1 inline SHA after the signed merge of main. Record the no-force-push conflict reconciliation in the audit. --- .github/workflows/actions.lock | 7 +++++++ .github/workflows/push-email-notify.yml | 3 +-- docs/audits/2026-11-02-open-work-audit.adoc | 5 +++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 7155887..f83043e 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -8,7 +8,14 @@ workflows: '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.5.1' '.github/workflows/secret-scanner.yml': [] + '.github/workflows/workflow-contracts.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' dependencies: + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'hyperpolymath/smtp-notify-action@v0.5.1': ref: 'v0.5.1' commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index e0f1d22..9e48973 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -1,4 +1,3 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Dormant push-email notification. ARMED by setting the repo variable @@ -41,7 +40,7 @@ jobs: timeout-minutes: 5 steps: - name: Send push notification email - uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7) + uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-e384daf6167a7a7567642d6a62060106244d2413) with: server_address: ${{ secrets.SMTP_HOST }} server_port: ${{ secrets.SMTP_PORT }} diff --git a/docs/audits/2026-11-02-open-work-audit.adoc b/docs/audits/2026-11-02-open-work-audit.adoc index 9fdbd5b..ecd6d66 100644 --- a/docs/audits/2026-11-02-open-work-audit.adoc +++ b/docs/audits/2026-11-02-open-work-audit.adoc @@ -121,6 +121,11 @@ owner before the repair landed, so PR #25 is a follow-up repair to `main`, not a replacement for an open PR. No admin bypass, force-push, or required-context removal is used by this session. +The conflict with merged PR #24 was reconciled without rewriting history: +the two conflicting files were temporarily aligned with `main`, GitHub's +`updatePullRequestBranch` MERGE created verified merge commit `5aba1a4`, and +a follow-up signed commit reapplied the repaired lockfile and inline SHA. + == 2. Issue #13 / D199 D199 adopts automated rebase-onto-base recovery after squash merges. The From cfb0f5566eff213b52e093f4cdd9f705f08c8e32 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:23:21 +0000 Subject: [PATCH 6/7] Update docs/handoffs/D199-post-squash-stack-recovery.adoc Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- docs/handoffs/D199-post-squash-stack-recovery.adoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/handoffs/D199-post-squash-stack-recovery.adoc b/docs/handoffs/D199-post-squash-stack-recovery.adoc index 28b5a0c..2a66823 100644 --- a/docs/handoffs/D199-post-squash-stack-recovery.adoc +++ b/docs/handoffs/D199-post-squash-stack-recovery.adoc @@ -59,6 +59,9 @@ checks it uses. The minimum evidence set is: * repository merge settings at event time permit squash and forbid merge commits and rebase merges (`allow_squash_merge == true`, `allow_merge_commit == false`, `allow_rebase_merge == false`); + These settings must come from persisted event-time webhook or audit + evidence. If that evidence is unavailable, classify the event as + `ambiguous_merge_method` and take no mutating action. * the PR is merged and has a non-null `merge_commit_sha`; * the merge commit is reachable from the base branch and has exactly one parent, ruling out an ordinary two-parent merge commit; From 49939bb7930eaf38512d83f962267b7acb89ee6f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:36:51 +0000 Subject: [PATCH 7/7] fix(ci): vendor secret scanner for allowlist and harden contracts Secret Scanner startup failures show the canonical metadatastician/standards reusable is blocked by this repository Actions allowlist. Vendor the canonical scanner locally, pin its estate-baseline fetches to the vendored standards SHA, preserve scan / gitleaks, lock the vendored checkout uses, and add regression fixtures for the allowlist contract, floating SMTP refs, and synthetic lock edges. Also tighten D199 recovery gates for ref identity and tree equivalence. --- .github/scripts/check_workflow_contracts.py | 31 +- .../scripts/tests/workflow-contracts-test.sh | 46 +- .github/workflows/actions-lock.closure.tsv | 5 +- .github/workflows/actions.lock | 2 + .github/workflows/secret-scanner-reusable.yml | 830 ++++++++++++++++++ .github/workflows/secret-scanner.yml | 25 +- CHANGELOG.adoc | 3 + .../D199-post-squash-stack-recovery.adoc | 21 +- 8 files changed, 937 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/secret-scanner-reusable.yml diff --git a/.github/scripts/check_workflow_contracts.py b/.github/scripts/check_workflow_contracts.py index 1211822..969b7f1 100644 --- a/.github/scripts/check_workflow_contracts.py +++ b/.github/scripts/check_workflow_contracts.py @@ -47,6 +47,8 @@ def _unique_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool REMOTE_REUSABLE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[^@]+@(.+)$") SHA40 = re.compile(r"^[0-9a-f]{40}$") SMTP_REF = re.compile(r"^hyperpolymath/smtp-notify-action@(v[0-9]+\.[0-9]+\.[0-9]+)$") +LOCAL_SCANNER = "./.github/workflows/secret-scanner-reusable.yml" +VENDORED_SCANNER_PIN = "8d1a2aab4211493cf42f147f2080f024605797dc" INLINE_SHA = re.compile(r"sha1-([0-9a-f]{40})") failures: list[str] = [] @@ -148,6 +150,11 @@ def main() -> int: if value.startswith("hyperpolymath/standards/"): fail(f"{rel}:{line_no + 1}: pre-transfer hyperpolymath/standards coordinate: {value}") + if value.startswith("./.github/workflows/"): + local_target = root / value[2:] + if not local_target.is_file(): + fail(f"{rel}:{line_no + 1}: local reusable workflow does not exist: {value}") + if "/.github/workflows/" in value and not value.startswith(("./", "$/")): has_remote_reusable = True match = REMOTE_REUSABLE.match(value) @@ -169,15 +176,33 @@ def main() -> int: elif pin not in dependencies: fail(f"{rel}: actions.lock pin has no dependency record: {pin}") + if wf.name == "secret-scanner.yml": + scanner_uses = [v for v, _line, path in uses if isinstance(v, str) and "jobs" in path and "steps" not in path] + if scanner_uses != [LOCAL_SCANNER]: + fail(f"{rel}: must call the local vendored scanner {LOCAL_SCANNER} (Actions allowlist blocks the remote standards reusable)") + vendored = wf_dir / "secret-scanner-reusable.yml" + if not vendored.is_file(): + fail(f"{rel}: vendored scanner missing: {vendored}") + else: + vtext = vendored.read_text(encoding="utf-8") + if f"at {VENDORED_SCANNER_PIN}." not in vtext: + fail(f"{rel}: vendored scanner missing provenance pin {VENDORED_SCANNER_PIN}") + if "ref: ${{ job.workflow_sha }}" in vtext: + fail(f"{rel}: vendored scanner must pin standards baseline explicitly, not job.workflow_sha") + if wf.name == "push-email-notify.yml": smtp_count = 0 for value, line_no, _path in uses: if not isinstance(value, str): continue - match = SMTP_REF.match(value) - if not match: + if value.startswith("hyperpolymath/smtp-notify-action@"): + smtp_count += 1 + match = SMTP_REF.match(value) + if not match: + fail(f"{rel}:{line_no + 1}: smtp-notify-action must use an exact semver tag: {value}") + continue + else: continue - smtp_count += 1 tag = match.group(1) raw_line = lines[line_no] if line_no < len(lines) else "" inline = INLINE_SHA.search(raw_line) diff --git a/.github/scripts/tests/workflow-contracts-test.sh b/.github/scripts/tests/workflow-contracts-test.sh index 985c0bb..3a01baf 100644 --- a/.github/scripts/tests/workflow-contracts-test.sh +++ b/.github/scripts/tests/workflow-contracts-test.sh @@ -17,7 +17,14 @@ workflows: '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.5.1' '.github/workflows/secret-scanner.yml': [] + '.github/workflows/secret-scanner-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' dependencies: + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'hyperpolymath/smtp-notify-action@v0.5.1': ref: 'v0.5.1' commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' @@ -35,7 +42,19 @@ YAML name: Secret Scanner jobs: scan: - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc + uses: ./.github/workflows/secret-scanner-reusable.yml +YAML + cat > "$root/.github/workflows/secret-scanner-reusable.yml" <<'YAML' +# SPDX-License-Identifier: MPL-2.0 +# Vendored from metadatastician/standards/.github/workflows/secret-scanner-reusable.yml +# at 8d1a2aab4211493cf42f147f2080f024605797dc. +name: Secret Scanner Reusable +on: + workflow_call: +jobs: + gitleaks: + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 YAML } @@ -62,21 +81,29 @@ sed -i 's/sha1-e384daf6167a7a7567642d6a62060106244d2413/sha1-ede1191ef6ff3ac02c4 expect stale_inline_sha 1 "$WORK/stale_sha" fixture moved_owner -sed -i 's#metadatastician/standards/#hyperpolymath/standards/#' "$WORK/moved_owner/.github/workflows/secret-scanner.yml" +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#' "$WORK/moved_owner/.github/workflows/secret-scanner.yml" expect moved_reusable_owner 1 "$WORK/moved_owner" fixture floating_pin -sed -i 's/@8d1a2aab4211493cf42f147f2080f024605797dc/@main/' "$WORK/floating_pin/.github/workflows/secret-scanner.yml" +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@main#' "$WORK/floating_pin/.github/workflows/secret-scanner.yml" expect floating_reusable_pin 1 "$WORK/floating_pin" fixture quoted_moved_owner -sed -i 's#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#uses: "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc"#' "$WORK/quoted_moved_owner/.github/workflows/secret-scanner.yml" +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc"#' "$WORK/quoted_moved_owner/.github/workflows/secret-scanner.yml" expect quoted_moved_reusable_owner 1 "$WORK/quoted_moved_owner" fixture quoted_floating_pin -sed -i "s#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#uses: 'metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@main'#" "$WORK/quoted_floating_pin/.github/workflows/secret-scanner.yml" +sed -i "s#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: 'metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@main'#" "$WORK/quoted_floating_pin/.github/workflows/secret-scanner.yml" expect quoted_floating_reusable_pin 1 "$WORK/quoted_floating_pin" +fixture remote_scanner_blocked +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#' "$WORK/remote_scanner_blocked/.github/workflows/secret-scanner.yml" +expect remote_scanner_allowlist_contract 1 "$WORK/remote_scanner_blocked" + +fixture missing_vendored_scanner +rm "$WORK/missing_vendored_scanner/.github/workflows/secret-scanner-reusable.yml" +expect missing_vendored_scanner 1 "$WORK/missing_vendored_scanner" + fixture missing_spdx sed -i '1s/.*/# managed/' "$WORK/missing_spdx/.github/workflows/secret-scanner.yml" expect missing_spdx_first_line 1 "$WORK/missing_spdx" @@ -86,6 +113,7 @@ sed -i "/secret-scanner.yml':/d" "$WORK/missing_lock_entry/.github/workflows/act expect missing_lock_workflow_entry 1 "$WORK/missing_lock_entry" fixture synthetic_reusable_edge +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#' "$WORK/synthetic_reusable_edge/.github/workflows/secret-scanner.yml" sed -i "s#'.github/workflows/secret-scanner.yml': \[\]#'.github/workflows/secret-scanner.yml':\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'#" "$WORK/synthetic_reusable_edge/.github/workflows/actions.lock" expect synthetic_reusable_only_lock_edge 1 "$WORK/synthetic_reusable_edge" @@ -105,6 +133,14 @@ cat >> "$WORK/second_smtp_missing_sha/.github/workflows/push-email-notify.yml" < YAML expect second_smtp_use_missing_inline_sha 1 "$WORK/second_smtp_missing_sha" +fixture second_smtp_floating +cat >> "$WORK/second_smtp_floating/.github/workflows/push-email-notify.yml" <<'YAML' + second: + steps: + - uses: hyperpolymath/smtp-notify-action@main +YAML +expect second_smtp_use_floating_ref 1 "$WORK/second_smtp_floating" + fixture duplicate_yaml_key printf '\njobs: {}\n' >> "$WORK/duplicate_yaml_key/.github/workflows/secret-scanner.yml" expect duplicate_yaml_key_not_inspected 2 "$WORK/duplicate_yaml_key" diff --git a/.github/workflows/actions-lock.closure.tsv b/.github/workflows/actions-lock.closure.tsv index 9fc5bf6..413ab52 100644 --- a/.github/workflows/actions-lock.closure.tsv +++ b/.github/workflows/actions-lock.closure.tsv @@ -2,4 +2,7 @@ # Produced by scripts/check-reusable-closure.sh --write-baseline; a change # here is a change to what this repository trusts, so review the diff. # workflowreusable-callref reached at startup -.github/workflows/secret-scanner.yml metadatastician/standards@8d1a2aab4211493cf42f147f2080f024605797dc actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 +# Secret Scanner run 41543261728: metadatastician/standards reusable blocked +# by this repo's Actions allowlist; vendored locally at the standards SHA below. +.github/workflows/secret-scanner.yml ./.github/workflows/secret-scanner-reusable.yml vendored from metadatastician/standards@8d1a2aab4211493cf42f147f2080f024605797dc +.github/workflows/secret-scanner-reusable.yml actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 v7.0.1 diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index f83043e..10f7a37 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -8,6 +8,8 @@ workflows: '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.5.1' '.github/workflows/secret-scanner.yml': [] + '.github/workflows/secret-scanner-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/workflow-contracts.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' dependencies: diff --git a/.github/workflows/secret-scanner-reusable.yml b/.github/workflows/secret-scanner-reusable.yml new file mode 100644 index 0000000..30bb9ed --- /dev/null +++ b/.github/workflows/secret-scanner-reusable.yml @@ -0,0 +1,830 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. +# Vendored from metadatastician/standards/.github/workflows/secret-scanner-reusable.yml +# at 8d1a2aab4211493cf42f147f2080f024605797dc. +# +# Why this is local: Secret Scanner run 41543261728 failed at startup because +# this repository's Actions allowlist rejects reusable workflows that are not +# from a hyperpolymath-owned repository. The canonical metadatastician/standards +# coordinate is therefore blocked here, and the pre-transfer hyperpolymath/standards +# coordinate is not followed by GitHub Actions. This local copy preserves the +# canonical scanner while remaining inside the allowlist. +# +# Baseline fetches are pinned to the vendored standards SHA below instead of +# job.workflow_sha, because job.workflow_sha would name this repository's commit, +# not a standards commit. Re-vendor deliberately when standards changes; do not +# let this copy drift silently. +# secret-scanner-reusable.yml — Reusable secret-scanner bundle. +# +# Consolidates the per-repo `secret-scanner.yml` workflow (estate-wide: +# 281 deployments, 54 unique blob SHAs, 19% structural drift — top 4 +# SHAs cover 69% of repos, top 6 cover 79%). Sampled top + long-tail +# variants all carry the same 3-job structure (gitleaks + rust-secrets + +# shell-secrets) at 75-81 lines; drift is action-SHA / whitespace churn. +# +# Rationale for gitleaks over trufflehog: +# - Gitleaks has better CVE coverage for common secret patterns +# - Trufflehog was removed as redundant; gitleaks catches what we need +# - Cost reduction: eliminates duplicate scanning +# +# Critical security debt this PR addresses: +# The `shell-secrets` job (added to the canonical 2026-05-21 in +# response to the Cloudflare token leak via `2-protocols/avow/ +# deploy-repos.sh`, which both `trufflehog --only-verified` and +# default gitleaks missed) is currently present in 0 of 16 sampled +# estate repos. The post-incident guardrail intended to catch the +# *next* leak has propagated to nothing. Consolidating behind this +# reusable force-promotes shell-secrets to all 281 repos on the next +# wrapper sweep. +# +# Design: +# - No per-call inputs other than `runs-on`. Each job self-conditions +# internally (rust-secrets exits early on no Cargo.toml; shell-secrets +# no-ops on no .sh/.bash files), so there is nothing for the caller +# to toggle. +# - Caller passes NO `secrets:` line. This reusable declares no +# `workflow_call.secrets` and references no `secrets.*` context: +# gitleaks runs as a pinned local binary (see the PERMISSIONS note), +# and the `GITHUB_TOKEN` the jobs act under is governed by +# `permissions:`, not by caller secret passing. `secrets: inherit` +# would forward every repository and organisation secret the caller +# can see (CWE-250) and buy nothing. An earlier version of this +# header claimed inherit was REQUIRED so `gitleaks/gitleaks-action` +# could see `secrets.GITHUB_TOKEN`; that claim died with #500 but +# kept propagating into generated callers estate-wide. Do not +# re-add it. (standards#1108 §6b) +# - Caller keeps its own `on:` triggers and `concurrency:` group, so +# the read-only cancel-superseded behaviour stays in the wrapper. +# - Trufflehog removed: gitleaks provides sufficient coverage at lower cost. +# +# PERMISSIONS — `contents: read` is the whole requirement. +# +# Every job here runs `actions/checkout` and then a self-contained binary or +# shell scan. There are no artifacts, no cache, no `gh api` calls and no +# workflow-run queries, so nothing needs `actions: read`, and since #500 +# replaced `gitleaks/gitleaks-action` with a pinned checksum-verified binary, +# nothing needs `pull-requests: write` either. +# +# This block previously instructed callers to grant `pull-requests: write` + +# `actions: read`. That guidance was correct for the pre-#500 action-based +# gitleaks job and became wrong when the binary replaced it — but it was left +# in place, and the workflow-level block below kept an unused `actions: read`. +# Because a called workflow cannot exceed its caller's *job-level* grant, a +# correctly least-privileged wrapper granting only `contents: read` was +# rejected at startup — which is exactly what happened to this repo's own +# `secret-scanner.yml`, silently disabling its secret scanning. +# +# Two rules that make that failure mode non-obvious, worth stating plainly: +# 1. A job-level `permissions:` block REPLACES the workflow-level one for +# that job — it does not merge with it. A caller with `actions: read` at +# the top and `contents: read` on the job grants `contents: read` only. +# 2. A rejected run produces NO check run at all — `gh pr checks` shows +# nothing and the annotations endpoint 404s. Diagnose with +# `gh run list --json conclusion`, never by the absence of a red tick. +# +# Every job below therefore declares `permissions: contents: read` explicitly +# rather than inheriting. Inheritance is what let an unused workflow-level +# grant leak into two jobs unnoticed; explicit per-job blocks mean a future +# widening of the workflow-level block cannot silently re-break callers. +# +# Caller example (wrapper) — this is the complete, minimal, correct shape: +# # SPDX-License-Identifier: MPL-2.0 +# name: Secret Scanner +# on: +# pull_request: +# push: +# branches: [main] +# concurrency: +# group: ${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: true +# permissions: +# contents: read +# jobs: +# scan: +# permissions: +# contents: read +# uses: ./.github/workflows/secret-scanner-reusable.yml +# # Deliberately NO `secrets:` line — the callee declares none and +# # needs none (see the Design note above). + +name: Secret Scanner (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: Runner label for all secret-scanner jobs + type: string + required: false + default: ubuntu-latest + +# `actions: read` was removed here: no job in this file reads workflow runs, +# artifacts or caches, so it was an unused grant — and because a called +# workflow cannot exceed its caller's job-level grant, it broke every wrapper +# that correctly granted `contents: read` alone. See the PERMISSIONS note above. +permissions: + contents: read + +jobs: + gitleaks: + timeout-minutes: 20 + runs-on: ${{ inputs.runs-on }} + # Declared explicitly rather than inherited — see the PERMISSIONS note. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history. One of the two gating passes below scans git history, + # and the default depth-1 clone would silently reduce it to a single + # commit. That pass asserts non-shallowness for itself rather than + # trusting this line to have taken effect. + fetch-depth: 0 + + # Gitleaks runs as a pinned, checksum-verified binary rather than via + # gitleaks/gitleaks-action. The action injects gitleaks into the runner + # tool-cache and prepends it to PATH, which is unreliable on self-hosted + # runners — that unreliability was the reason this step previously carried + # `continue-on-error: true`, i.e. the estate's primary secret scanner + # could not fail a PR. A pinned direct install is deterministic on every + # runner, so the scan gates for real. + - name: Install gitleaks (pinned + checksum-verified) + run: | + set -euo pipefail + GITLEAKS_VERSION=8.18.4 + SHA256=ba6dbb656933921c775ee5a2d1c13a91046e7952e9d919f9bac4cec61d628e7d + cd "$RUNNER_TEMP" + curl -fsSL -o gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + + # Stage the ESTATE BASELINE allowlist into the workspace root. + # + # WHY THIS EXISTS. The scan below used to run with no `--config` at all, + # so gitleaks fell back to looking for `.gitleaks.toml` in the CALLER's + # tree. Measured 2026-08-06: none of the eleven repositories then failing + # this gate had one, so all eleven ran on bare default rules with no + # allowlist — and produced 154 findings of which every single one was a + # false positive (ML training corpora, vendored Yarn bundles, published + # conformance vectors, `.example` configs, and fixtures belonging to the + # estate's own secret DETECTORS). + # + # standards/.gitleaks.toml already anticipated precisely this: "turn the + # gate on with no allowlist, get a wall of red that is entirely noise, + # someone reverts, and the estate is back to a scanner that cannot fail." + # The file was written and then never wired to the scan. This wires it. + # + # It is staged at the WORKSPACE ROOT, not $RUNNER_TEMP, because gitleaks + # resolves `[extend] path` relative to the PROCESS CWD — verified, and the + # opposite of the intuitive "relative to the config file". Putting it here + # lets a repo-local `.gitleaks.toml` portably declare + # [extend] + # path = ".gitleaks-estate.toml" + # and inherit the whole baseline while adding only its own exemptions, so + # a repo-specific blind spot stays local to the repo that justified it + # instead of widening the allowlist for all 400+. + # + # THIS STEP RUNS FOR EVERY CONSUMER WITHOUT A `.gitleaks.toml`. It is the + # only thing that creates `.gitleaks-estate.toml` for that consumer — the + # large majority — and that is exactly the consumer whose CONFIG resolves + # to that file in the scan step below. Do not delete it as a duplicate of + # the gated "Estate baseline — is it referenced?" block further down; + # that block fires only for repos that DO have a local config, so + # removing this one leaves CONFIG naming a file nothing creates, across + # most of the estate. + # + # It is skipped when the repo HAS a `.gitleaks.toml`. That repo scans + # with its own config, and the gated block below stages the baseline + # only if that config `[extend]`s it. Staged anyway, the baseline sat + # inside the scanned tree under a config that does not allowlist the + # fixture values its own comments quote, and the gate reported the + # estate file as the caller's leak (echidnabot#185: three + # generic-api-key findings in .gitleaks-estate.toml). + # + # `ref` was `main` until 2026-09-04, and that was worse than a moving + # pin: the file this step fetches has been RENAMED under it. Before + # 86bad549 the shared allowlist WAS `.gitleaks.toml` (148 lines, 16 + # rules) and gen-1 sparse-checked-out that path; after the rename, + # `.gitleaks.toml` is standards' own 18-line dogfooding stub whose + # `[extend] path` points at `config/gitleaks/estate-baseline.toml` — a + # path the consumer does NOT have, because this step stages exactly one + # file. So every consumer pinned before the rename silently began + # staging a stub with a dangling `[extend]`. the vendored standards SHA + # below is the standards commit this copy was taken from, so a consumer + # receives the baseline as it stood at that SHA, and the path each SHA + # references exists at that SHA (verified for all six gen-1 SHAs, + # 2026-09-04). + - name: Fetch estate gitleaks baseline + if: hashFiles('.gitleaks.toml') == '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: metadatastician/standards + ref: 8d1a2aab4211493cf42f147f2080f024605797dc # vendored standards pin + path: .standards-gitleaks + sparse-checkout: | + config/gitleaks/estate-baseline.toml + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Stage estate gitleaks baseline + if: hashFiles('.gitleaks.toml') == '' + run: | + set -euo pipefail + # Stage THE estate baseline, not standards' own repo config. + # standards/.gitleaks.toml is a CONSUMER of the baseline -- it carries + # `[extend] path = "config/gitleaks/estate-baseline.toml"`, and + # `[extend] path` resolves against the PROCESS working directory. Copied + # into a consumer, that path does not exist and gitleaks dies with + # FTL failed to load extended config ... no such file + # scanning nothing and failing the gate. The baseline file's own header + # documents this staging as the intended behaviour. + cp .standards-gitleaks/config/gitleaks/estate-baseline.toml .gitleaks-estate.toml + # Delete the tooling checkout BEFORE scanning: otherwise the scan + # walks standards' own tree and reports ITS files as the caller's. + # (The Hypatia baseline job above does the same thing for the same + # reason.) + rm -rf .standards-gitleaks + test -s .gitleaks-estate.toml + + # TWO gating passes run below and they are not redundant. Each sees a + # population the other cannot: + # * working tree (--no-git) -- sees untracked and generated files; + # cannot see a blob deleted in an earlier commit + # * git history -- sees committed-then-deleted secrets; + # cannot see a file that was never committed + # Both gate: a finding in either exits non-zero and fails the check. + # Full history costs a slower scan on every PR. That is the accepted + # price of a deleted secret no longer being permanently invisible. + # ── Estate baseline sharing ────────────────────────────────────────── + # A consumer repo can inherit the estate-wide allowlist instead of + # copying it, by writing in its own .gitleaks.toml: + # + # [extend] + # path = ".gitleaks-estate.toml" + # + # The point of the split is blast radius. An entry in the estate baseline + # blinds all 400+ repositories; an entry in a repo's own config blinds + # one, and sits beside the code it describes. Before this existed the + # only way to share the baseline was to copy it, so the two drifted and + # repo-local suppressions had nowhere to live except the shared file. + # + # Staged ONLY when actually referenced. A repo that does not extend it + # must not gain an unreferenced file inside its own scanned tree. + # + # `[extend] path` resolves against the PROCESS working directory, not the + # config file's directory (verified empirically: the same relative path + # fails when gitleaks is invoked from a subdirectory). The scan below runs + # from the workspace root, so the workspace root is where this lands. + - name: Estate baseline — is it referenced? + id: estate_baseline + run: | + set -euo pipefail + if [ -f .gitleaks.toml ] && \ + grep -qE "^[[:space:]]*path[[:space:]]*=[[:space:]]*[\"']\.gitleaks-estate\.toml[\"']" .gitleaks.toml; then + echo "referenced=true" >> "$GITHUB_OUTPUT" + echo "This repo extends the estate baseline; staging it." + else + echo "referenced=false" >> "$GITHUB_OUTPUT" + echo "No reference to .gitleaks-estate.toml; nothing staged." + fi + + # Pinned by construction: the vendored standards SHA below is the standards + # commit this reusable was copied from, so the baseline always matches + # this vendored workflow. Re-vendor both together; do not move one pin. + - name: Estate baseline — fetch + if: steps.estate_baseline.outputs.referenced == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: metadatastician/standards + ref: 8d1a2aab4211493cf42f147f2080f024605797dc # vendored standards pin + path: .estate-baseline-checkout + sparse-checkout: config/gitleaks + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Estate baseline — install + if: steps.estate_baseline.outputs.referenced == 'true' + run: | + set -euo pipefail + src=.estate-baseline-checkout/config/gitleaks/estate-baseline.toml + if [ ! -f "$src" ]; then + echo "::error::Estate baseline missing at $src. The repo's .gitleaks.toml extends .gitleaks-estate.toml, but the baseline could not be fetched — failing rather than scanning with a silently reduced config." + exit 1 + fi + cp "$src" .gitleaks-estate.toml + # Remove the checkout before scanning: otherwise standards' own tree + # is scanned as if it were the caller's, which is how a shared config + # turns into a wall of someone else's findings. + rm -rf .estate-baseline-checkout + echo "Staged .gitleaks-estate.toml ($(wc -l < .gitleaks-estate.toml) lines)." + + # + # --verbose is REQUIRED, not cosmetic. Without it gitleaks prints only + # "leaks found: N" and nothing else — no rule, no file, no line — and the + # run uploads no artifact, so the findings exist solely inside a discarded + # container. That is a gate which blocks a merge while telling the author + # nothing actionable, and it is why 154 false positives sat untriaged + # across eleven repositories. --redact is already set, so values stay + # masked: the log gains the rule, path and line, never the secret. + - name: Gitleaks secret scan (gating) + run: | + set -euo pipefail + + # A repo-local baseline wins outright — it is expected to `[extend]` + # the estate one, so "wins" still means "inherits". This mirrors what + # the AsciiDoc pass below already did, which was inconsistent with + # this step until now. + CONFIG=".gitleaks-estate.toml" + if [ -f .gitleaks.toml ]; then + CONFIG=".gitleaks.toml" + echo "Using repository .gitleaks.toml (extending the estate baseline)." + else + echo "Using estate baseline allowlist." + fi + + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --no-git \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + + # Full-history pass — ADDITIVE, not a replacement. Measured on a fixture + # with this exact pinned binary, the same secret in two placements: + # + # committed then deleted working tree: MISSED history: FOUND + # untracked file working tree: FOUND history: MISSED + # + # git mode scans commit diffs, so it cannot see a file that was never + # committed; --no-git walks the checked-out tree, so it cannot see a blob + # that is no longer in it. Dropping either pass REMOVES coverage. Keeping + # both is why this was added as a new step rather than by editing the one + # above, whose name and log lines existing triage keys on. + # + # KNOWN GAP, stated rather than implied: the AsciiDoc mirror pass below + # scans a filesystem copy with --no-git, so fetch-depth: 0 cannot reach + # it. An AsciiDoc secret that was committed and later deleted stays + # invisible. "The gate is full-history" holds for every file class but + # that one. + - name: Gitleaks secret scan — git history (gating) + run: | + set -euo pipefail + + # fetch-depth: 0 on the checkout is load-bearing HERE. If it ever + # regresses to the default depth-1 clone, detect would walk a single + # commit, find nothing and report a pass — a gate that cannot fail. + # Assert completeness from git itself: gitleaks' own "scanned N + # commits" line under-reports and is not proof of depth. + if [ "$(git rev-parse --is-shallow-repository)" != "false" ]; then + echo "::error::checkout is shallow -- a history scan here would be vacuous; refusing to report a pass" + exit 1 + fi + echo "history available to this scan: $(git rev-list --count HEAD) commits" + + # Same config resolution as the working-tree pass, duplicated rather + # than factored out so that step stays byte-identical. + CONFIG=".gitleaks-estate.toml" + if [ -f .gitleaks.toml ]; then + CONFIG=".gitleaks.toml" + fi + # A staging step that failed silently would leave gitleaks running a + # reduced config over almost nothing and calling the result green. + test -s "$CONFIG" + + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + + # AsciiDoc blind-spot pass — MEASURED, not theoretical. + # + # gitleaks' DEFAULT global allowlist excludes paths matching + # `(jpg|gif|doc|docx|zip|xls|pdf|bin|svg|socket)$`. That `doc` alternative + # is an unanchored *substring* match on the filename, so it swallows + # `.adoc` and `.asciidoc` as collateral — AsciiDoc is not a binary format + # and was never the intended target. + # + # This estate writes documentation in AsciiDoc by default, so the blind + # spot covers ~40,000 files estate-wide (1,620 in standards alone). It is + # not hypothetical: a live write-scoped `glpat-` token sat undetected in + # `rhodium-pipeline/docs/QUICKSTART.adoc` while this scanner reported + # "no leaks found". Proof — identical content, four extensions: + # .txt -> DETECTED .md -> DETECTED + # .adoc -> MISSED .asciidoc -> MISSED + # + # The exclusion cannot be switched off from a config that sets + # `[extend] useDefault = true` (verified: neither `[allowlist] paths = []` + # nor adding an explicit custom rule re-enables scanning — the file is + # dropped at path level, before any rule runs). Turning `useDefault` off + # would forfeit all ~150 default rules, so instead this step scans a + # throwaway mirror in which each AsciiDoc file is copied to a `.txt` + # suffix. Same binary, same rules, same repo config — only the filename + # differs, which is the sole thing the allowlist keys on. + # + # Findings are re-mapped back to their real paths before reporting, so + # output never mentions the mirror. Runs as a separate step (not folded + # into the scan above) so the log distinguishes "leak in code" from + # "leak in docs". + - name: Gitleaks secret scan — AsciiDoc mirror (gating) + run: | + set -euo pipefail + + MIRROR="$RUNNER_TEMP/adoc-mirror" + REPORT="$RUNNER_TEMP/adoc-report.json" + rm -rf "$MIRROR"; mkdir -p "$MIRROR" + + # -print0/read -d '' so paths with spaces or newlines survive; the + # estate has directories with spaces in them. + found=0 + while IFS= read -r -d '' f; do + dest="$MIRROR/$(dirname "$f")" + mkdir -p "$dest" + cp "$f" "$dest/$(basename "$f").txt" + found=$((found + 1)) + done < <(find . -path ./.git -prune -o \ + \( -name '*.adoc' -o -name '*.asciidoc' \) -type f -print0) + + if [ "$found" -eq 0 ]; then + echo "No AsciiDoc files present — nothing to scan." + exit 0 + fi + echo "Scanning $found AsciiDoc file(s) via mirror." + + # Honour the caller's own baseline when it has one, so repo-specific + # allowlists still apply to docs exactly as they do to code — and + # otherwise fall back to the estate baseline, so docs and code are + # judged by the SAME rules. Previously this step honoured a repo + # config while the code scan above honoured none, which meant an + # allowlist entry could suppress a finding in a `.adoc` file and not + # in the `.md` file beside it. + # + # Absolute paths: this scan's --source is the MIRROR directory, so a + # relative config path would resolve against the mirror rather than + # the repository. `[extend] path = ".gitleaks-estate.toml"` inside a + # repo config resolves against the process CWD (still the repo root), + # which is why the estate baseline is staged there. + config_args=(--config "$PWD/.gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + config_args=(--config "$PWD/.gitleaks.toml") + echo "Using repository .gitleaks.toml." + else + echo "Using estate baseline allowlist." + fi + + # Report first, decide after: --exit-code would abort before the paths + # can be mapped back, leaving an unreadable mirror path in the log. + set +e + "$RUNNER_TEMP/gitleaks" detect \ + --source "$MIRROR" \ + --no-git \ + --redact \ + --no-banner \ + --report-format json \ + --report-path "$REPORT" \ + "${config_args[@]}" + set -e + + # `grep` exits 1 when it matches nothing, which under `set -e` + + # `pipefail` aborts the step with status 1 — i.e. a repo whose docs are + # CLEAN would fail the gate. Caught by red-teaming the clean case; + # pipefail is disabled around the count for exactly this reason. + count=0 + if [ -s "$REPORT" ]; then + set +o pipefail + count=$(grep -o '"RuleID"' "$REPORT" 2>/dev/null | wc -l | tr -d ' ') + set -o pipefail + [ -n "$count" ] || count=0 + fi + + if [ "$count" -eq 0 ]; then + echo "No leaks found in AsciiDoc files." + exit 0 + fi + + # Findings list, always printed, values redacted. Paths are recovered + # from the mirror so the output names the real files. + echo "Findings (values redacted; paths are the REAL files, not the mirror):" + sed -n 's/.*"File": *"\([^"]*\)".*/\1/p' "$REPORT" \ + | sed -e "s|^${MIRROR}/||" -e 's|\.txt$||' \ + | sort -u \ + | sed 's/^/ - /' + echo + echo "These files are invisible to a default gitleaks run because the" + echo "default allowlist drops any path ending in 'doc'. Treat every hit" + echo "above as a real, previously unscanned leak." + echo + + # GRACE WINDOW — same cutoff as the docs and package-policy gates + # (standards#513), for the same reason: this step makes ~40,000 + # previously unscanned estate files visible at once, and every hit is + # pre-existing debt rather than a regression introduced by the PR in + # front of it. Turning it straight to blocking would red a large number + # of repos simultaneously for faults none of their authors introduced. + # + # It never prints a green tick while findings exist, and it never + # suppresses the list above — the warning is loud and the date is real. + # An unparseable date refuses to run rather than silently defaulting to + # the permissive branch, which is how a warn-first gate degrades into a + # fake one. + ENFORCE_ADOC_SCAN_FROM="${ENFORCE_ADOC_SCAN_FROM:-2026-08-21}" + TODAY="${ADOC_TODAY:-$(date -u +%Y-%m-%d)}" + for pair in "ENFORCE_ADOC_SCAN_FROM=$ENFORCE_ADOC_SCAN_FROM" "ADOC_TODAY=$TODAY"; do + case "${pair#*=}" in + [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) ;; + *) echo "::error::${pair%%=*} is not YYYY-MM-DD; refusing to run."; exit 1 ;; + esac + done + + if [[ "$TODAY" < "$ENFORCE_ADOC_SCAN_FROM" ]]; then + echo "::warning::${count} secret(s) found in AsciiDoc files. This becomes a" \ + "BLOCKING failure on ${ENFORCE_ADOC_SCAN_FROM} (today is ${TODAY})." + echo "NOT YET ENFORCED: AsciiDoc findings present but inside the grace window." + exit 0 + fi + + echo "::error::${count} secret(s) detected in AsciiDoc files." + exit 1 + + # Rust-specific: hardcoded crypto values. Self-skips when no Cargo.toml + # is present, so this job is safe to run on non-Rust repos. + rust-secrets: + timeout-minutes: 10 + runs-on: ${{ inputs.runs-on }} + # Declared explicitly rather than inherited — see the PERMISSIONS note. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check for hardcoded secrets in Rust + env: + # Findings outside ./src (newly visible — see the scan-scope note + # below) warn until this date, then block. Same warn-then-enforce + # idiom and same cutoff as check-package-policy.sh / + # check-docs-presence.sh. + ENFORCE_RUST_WIDE_SCAN_FROM: '2026-08-21' + run: | + TODAY="${RUST_TODAY:-$(date -u +%Y-%m-%d)}" + + # An unparseable cutoff would pick the warn branch forever, silently + # disarming the widened scan. Refuse to run instead. + require_date() { + case "$2" in + [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) : ;; + *) echo "::error::rust-secrets: $1='$2' is not YYYY-MM-DD." + echo "Refusing to run: an unparseable cutoff would disarm this gate." + exit 1 ;; + esac + } + require_date ENFORCE_RUST_WIDE_SCAN_FROM "$ENFORCE_RUST_WIDE_SCAN_FROM" + require_date RUST_TODAY "$TODAY" + + if ! find . -name Cargo.toml -not -path './target/*' -print -quit | grep -q .; then + echo 'No Cargo.toml found — skipping Rust secrets check' + exit 0 + fi + + # Scan every .rs file, not just ./src. A Cargo workspace keeps its + # code in crates/*/src, and the previous `grep … src/` matched + # nothing there: grep exits 2 on a missing directory, which the `if` + # read as "clean". Measured 2026-07-21 across the 64 Rust repos in + # the estate re-pin sweep: 8 (12.5%) have no root src/ and were + # therefore never scanned at all — a planted secret in + # crates/core/src/lib.rs passed this job silently. + mapfile -t FILES < <(find . -name '*.rs' \ + -not -path './target/*' -not -path '*/target/*' \ + -not -path './vendor/*' -not -path '*/vendor/*' \ + -not -path './.git/*' -print) + if [ ${#FILES[@]} -eq 0 ]; then + echo 'Cargo.toml present but no .rs files — nothing to scan' + exit 0 + fi + echo "Scanning ${#FILES[@]} Rust file(s)." + + # Each pattern requires an assignment DIRECTLY to a string literal of + # meaningful length. The previous forms matched `= ... "` — a quote + # anywhere after an equals — which flagged code that hardcodes + # nothing at all. Measured across the 64 Rust repos in the estate + # sweep, every one of these was a false positive: + # let api_key = request["api_key"].as_str().unwrap_or("") (lookup) + # let api_key_file = temp_dir.path().join("config.js") (path) + # Query::new("Here's my api_key=sk-12345") (a demo) + # A credential shorter than 8 characters is not a credential either. + # `static` is accepted alongside `const`: the old `const.*KEY` form + # missed `static AUTH_KEY: &str = "…"`, a genuine hardcoded secret + # (caught by canary, not by inspection). + PATTERNS=( + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*SECRET[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*KEY[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[a-zA-Z0-9]{16,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(let|static)[[:space:]]+(mut[[:space:]]+)?[a-z0-9_]*api_key[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + 'HMAC.*"[a-fA-F0-9]{32,}"' + '[a-z0-9_]*password[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + ) + + # Exemptions, mirroring the four layers already documented on + # shell-secrets. All match on the VALUE or on an explicit pragma, + # never on a file path: a path-based exemption would blind the job + # to a genuine secret in the same file (the failure mode the + # .gitleaks.toml allowlist was rewritten to avoid, standards#512). + # + # 1. env-lookup RHS — `env::var("NAME")` IS the practice this job + # exists to enforce, so flagging it fails the correct code. This + # was the sole rust-secrets failure in the 201-repo sweep: + # cloudguard-server src/main.rs:59, + # `let api_key = env::var("CLOUDGUARD_API_KEY").ok();`. Anchored + # to the assignment and stopped at `;`, so a literal secret with + # an unrelated env::var mention later on the line still trips. + # 2. URL values — an OAuth endpoint is public and not a credential. + # `const MS_TOKEN_URL = "https://login.microsoftonline.com/…"` + # tripped `const.*TOKEN.*=.*"` in four places in one repo. + # 3. comment lines — documentation of a pattern is not a live secret. + # 4. inline pragma `// scanner-allow: rust-secrets`. + COMMENT='^[^:]*:[0-9][0-9]*:[[:space:]]*(//|/\*|\*)' + ENV_RHS='=[^;]*(env::var|env::var_os|std::env|env!\(|option_env!\(|from_env|dotenv)' + URL_RHS='=[[:space:]]*b?"(https?|wss?|ftp)://' + PRAGMA='scanner-allow:[[:space:]]*rust-secrets' + + blocking=0 + advisory=0 + for pattern in "${PATTERNS[@]}"; do + hits="$(grep -nHE "$pattern" "${FILES[@]}" 2>/dev/null \ + | grep -vE "$COMMENT" \ + | grep -vE "$ENV_RHS" \ + | grep -vE "$URL_RHS" \ + | grep -vE "$PRAGMA" || [ $? -eq 1 ])" + [ -n "$hits" ] || continue + + # ./src was the only path the previous version scanned. Hits there + # keep blocking exactly as before — no regression. Hits outside it + # are newly visible, so they warn until the cutoff rather than + # reddening repos that were never actually being scanned. + old_scope="$(printf '%s\n' "$hits" | grep -E '^\./src/' || [ $? -eq 1 ])" + new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || [ $? -eq 1 ])" + + if [ -n "$old_scope" ]; then + printf '%s\n' "$old_scope" + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + if [ -n "$new_scope" ]; then + printf '%s\n' "$new_scope" + if [[ "$TODAY" < "$ENFORCE_RUST_WIDE_SCAN_FROM" ]]; then + echo "::warning::Potential hardcoded secret outside ./src matching:" \ + "$pattern — becomes BLOCKING on $ENFORCE_RUST_WIDE_SCAN_FROM (today is $TODAY)." + advisory=1 + else + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + fi + done + + if [ "$blocking" -eq 1 ]; then + echo "::error::Potential hardcoded secrets detected. Use environment variables instead." + exit 1 + fi + if [ "$advisory" -eq 1 ]; then + # Never claim a clean pass while findings are outstanding. + echo "NOT YET ENFORCED: findings outside ./src are advisory until $ENFORCE_RUST_WIDE_SCAN_FROM." + exit 0 + fi + echo 'No hardcoded secrets detected in Rust sources.' + + # Shell-specific: catch hardcoded credentials in shell scripts. + # Added to canonical 2026-05-21 after default gitleaks missed a real + # Cloudflare API token leaked via 2-protocols/avow/deploy-repos.sh. + # As of this PR, 0 of 16 sampled estate repos carry this guardrail — + # the reusable closes that gap. + # + # Exemption mechanisms (four layers, applied before reporting a hit): + # 1. Inline pragma (same line or the line immediately above): + # # scanner-allow: shell-secrets + # # hypatia: allow security_errors/secret_detected + # Either form suppresses the hit for that line. + # 2. .shell-secrets-ignore in repo root: gitignore-style line globs; + # a match against the file path suppresses every hit in that file. + # 3. Param-expansion RHS: assignments whose value is ${VAR}, ${VAR:-…}, + # ${VAR:?…}, or $VAR (no literal value) are skipped automatically. + # 4. Comment lines (leading non-whitespace is '#'): documentation of + # patterns cannot be real secrets. + shell-secrets: + timeout-minutes: 10 + runs-on: ${{ inputs.runs-on }} + # Declared explicitly rather than inherited — see the PERMISSIONS note. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check for hardcoded secrets in shell scripts + run: | + # Patterns: an `export FOO=` or `FOO=` with a quoted literal of meaningful length. + # Restricted to *_TOKEN / *_KEY / *_SECRET / PASSWORD to keep false-positives low. + PATTERNS=( + '(export[[:space:]]+)?[A-Z_]*TOKEN[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*API_KEY[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*SECRET[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{16,}["'"'"']' + '(export[[:space:]]+)?PASSWORD=["'"'"'][^"'"'"']{6,}["'"'"']' + ) + + # Inline pragma patterns — suppress a hit when found on the same or + # immediately preceding line. + PRAGMA_RE='(scanner-allow:[[:space:]]*shell-secrets|hypatia:[[:space:]]*allow[[:space:]]+security_errors/secret_detected)' + + # Param-expansion RHS pattern — assignments whose value is a variable + # reference rather than a literal are never real secrets. + # Matches: ="$VAR" ="${VAR}" ="${VAR:-…}" ="${VAR:?…}" ='${VAR}' =$VAR + PARAM_EXPANSION_RE='=['"'"'"'"'"']?\$\{?[A-Za-z_][A-Za-z0-9_]*(:[?-][^}]*)?\}?['"'"'"'"'"']?[[:space:]]*(#.*)?$' + + # Load per-repo ignore globs from .shell-secrets-ignore if present. + IGNORE_GLOBS=() + if [[ -f .shell-secrets-ignore ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments + [[ -z "$line" || "$line" == \#* ]] && continue + IGNORE_GLOBS+=("$line") + done < .shell-secrets-ignore + fi + + # is_ignored — returns 0 (true) if path matches any ignore glob. + is_ignored() { + local path="$1" + for glob in "${IGNORE_GLOBS[@]}"; do + # Use bash pattern matching; strip leading ./ for consistency. + local stripped="${path#./}" + # shellcheck disable=SC2254 + if [[ "$stripped" == $glob ]]; then + return 0 + fi + done + return 1 + } + + found=0 + for pattern in "${PATTERNS[@]}"; do + # Collect raw matches: filepath:lineno:content + while IFS= read -r raw_match; do + [[ -z "$raw_match" ]] && continue + + # Parse filepath and line number from grep -n output (file:line:content). + filepath="${raw_match%%:*}" + rest="${raw_match#*:}" + lineno="${rest%%:*}" + content="${rest#*:}" + + # Exemption 1: .shell-secrets-ignore glob match on the file path. + if is_ignored "$filepath"; then + echo " [skip] $filepath:$lineno — matched .shell-secrets-ignore glob" + continue + fi + + # Exemption 2: param-expansion RHS — value is a variable reference, not a literal. + if echo "$content" | grep -qE "$PARAM_EXPANSION_RE"; then + echo " [skip] $filepath:$lineno — param-expansion (not a literal value)" + continue + fi + + # Exemption 3: comment line — leading non-whitespace char is '#'. + # Comment lines cannot hold real secrets; they only document patterns. + if echo "$content" | grep -qE '^[[:space:]]*#'; then + echo " [skip] $filepath:$lineno — comment line (not executable)" + continue + fi + + # Exemption 5: inline pragma on same line. + if echo "$content" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — inline pragma on same line" + continue + fi + + # Exemption 6: inline pragma on the immediately preceding line. + if [[ "$lineno" -gt 1 ]]; then + prev_line=$(sed -n "$((lineno - 1))p" "$filepath" 2>/dev/null) + if echo "$prev_line" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — pragma on preceding line" + continue + fi + fi + + echo "::warning::$filepath:$lineno: $content" + echo " Potential hardcoded secret matching pattern: $pattern" + found=1 + + done < <(grep -rnE --include='*.sh' --include='*.bash' \ + --exclude-dir='.git' --exclude-dir='node_modules' --exclude-dir='target' \ + "$pattern" . 2>/dev/null || [ $? -eq 1 ]) + done + + if [ $found -eq 1 ]; then + echo "::error::Hardcoded secret detected in a shell script. Read the value from the environment (e.g. \"\${MY_TOKEN:?}\") and supply it via a repo or org secret." + exit 1 + fi diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 1272c7a..4b3e5f3 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,17 +1,20 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -# Calls the estate's shared secret scanner (gitleaks + rust-secrets + -# shell-secrets). +# Calls the estate secret-scanner bundle (gitleaks + rust-secrets + +# shell-secrets) through a local vendored reusable workflow. # -# The reusable lives in metadatastician/standards. GitHub Actions does not -# follow the REST redirect from the pre-transfer hyperpolymath/standards slug -# for job-level `uses:`, so the old coordinate produced a zero-job startup -# failure and no `scan / gitleaks` check. Keep the canonical slug and an -# immutable 40-hex pin. +# Why local: Secret Scanner run 41543261728 failed at startup because this +# repository's Actions allowlist rejects reusable workflows that are not from +# a hyperpolymath-owned repository. The canonical coordinate +# metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab... +# is therefore blocked here, and GitHub Actions does not follow the REST +# redirect from the pre-transfer hyperpolymath/standards slug. The local copy +# in secret-scanner-reusable.yml preserves the canonical scanner at that +# standards SHA while remaining inside the allowlist. # -# The pinned reusable declares no workflow_call secrets and references no -# `secrets.*` context. Do not add `secrets: inherit`: it would forward every -# repository/organisation secret to a scanner that needs none. +# The reusable declares no workflow_call secrets and references no secrets.* +# context. Do not add `secrets: inherit`: it would forward every repository +# and organisation secret to a scanner that needs none. name: "Secret Scanner" on: @@ -27,4 +30,4 @@ jobs: scan: permissions: contents: read - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc + uses: ./.github/workflows/secret-scanner-reusable.yml diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index cbd5024..796f698 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,6 +2,9 @@ = Changelog == Unreleased +* Vendor the estate secret scanner locally because the repository Actions + allowlist blocks the canonical `metadatastician/standards` reusable; keep + the required `scan / gitleaks` context and pin the vendored baseline fetch. === Dependencies diff --git a/docs/handoffs/D199-post-squash-stack-recovery.adoc b/docs/handoffs/D199-post-squash-stack-recovery.adoc index 2a66823..04eae51 100644 --- a/docs/handoffs/D199-post-squash-stack-recovery.adoc +++ b/docs/handoffs/D199-post-squash-stack-recovery.adoc @@ -118,9 +118,11 @@ For each downstream PR in topological order (base PR before children): bases are unaffected. . Immediately before creating the replacement branch, re-fetch the original downstream PR and its base. If the PR is no longer open, or its head/base - SHA differs from the captured values, discard the stale replay and - recompute or escalate. `expectedHeadOid` protects the new recovery branch; - it does not protect the original downstream head. + repository, ref, or SHA differs from the captured values, discard the stale + replay and recompute or escalate. Compare ref identities as well as SHAs: + a retarget to another base branch at the same SHA is still a different PR + relationship. `expectedHeadOid` protects the new recovery branch; it does + not protect the original downstream head. . If clean and still current, create a new branch in the same repository, for example `stack-recovery/pr--`, from the new base. . Recreate the downstream commits on that branch through GitHub's signed @@ -137,14 +139,21 @@ For each downstream PR in topological order (base PR before children): `commit.verification.verified` must also be true. If the API cannot preserve required file modes/binary changes, escalate rather than pushing unsigned local commits. +. Compare the final replacement-branch tree with the captured replay tree + before opening the replacement PR. Compare every path's type, mode, and + object ID. `createCommitOnBranch` accepts file contents and paths but not + file-mode metadata, so a mode/type/object mismatch is a hard stop. If any + entry differs, do not open the replacement PR or close the original PR; + report the mismatch and escalate. . Open a replacement PR against the correct new base. Link the original PR, merge commit, recovery run, and tree-equivalence evidence. Copy relevant labels/review requests only where the API permits and the semantics are safe. Do not copy approvals as if they apply to the new head. . Immediately before closing the original downstream PR, re-fetch it again. - Close it as superseded only if it is still open, its head/base still match - the recovered values, and the replacement PR exists with the new head - visible. If closing is not permitted or the PR changed, leave a comment and + Close it as superseded only if it is still open, its head/base repository, + ref, and SHA still match the recovered values, the tree-equivalence check + passed, and the replacement PR exists with the new head visible. If closing + is not permitted, the PR changed, or the trees differ, leave a comment and report the partial state. . Re-target or recover nested children against the replacement branch/PR in the same topological pass. A child must never be replayed onto a base that