From 71d85ba1b0df22b8c09f453fdc1487ba5a6f07e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:53:02 +0000 Subject: [PATCH 01/44] feat(fix-scripts): repair the three workflow-pin defect classes; re-pin this repo's standards callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actuation half of the pin-propagation architecture (hypatia detects → gitbot-fleet actuates → .git-private-farm propagates) for the three defect classes measured this fortnight, each with a fix script, a row in the registry, and a contract test run by rust.yml: * fix-rust-toolchain-input.sh (WF026) — a SHA-pinned dtolnay/rust-toolchain step with no `toolchain:` input dies in its first step with "'toolchain' is a required input" and the red reads as a code failure (plasma-parser-writer#105, standards#439). The script gives such a step `toolchain: stable`, inside an existing `with:` or in a new one, and leaves tag-pinned and already-named steps alone. * fix-stale-reusable-pin.sh (WF027, PIN-003, WF016) — a caller pinned at a reusable revision the estate has MEASURED dead is moved to the one replacement scripts/lib/stale-reusable-pins.tsv names for it, never to "newest" (standards' lock-selfcheck.sh exists because newer can be less usable). The table carries two windows from a walk of standards' history: A, 83 commits of June 2026 whose rust-ci-reusable.yml lacks the toolchain input; B, 545 commits reachable from main, 2026-08-19..2026-10-19, whose actions.lock carries an action record with its own SHA as `ref:`, which GitHub has rejected at Set up job ("invalid lockfile pin") since ~2026-10-15 for every caller of every standards reusable (hypatia#929, cicd-squabbler run 38846539059). If the caller's actions.lock names the stale SHA it follows the pin, and two dead SHAs moving to one replacement are merged into one record; the nested `uses:` list is never recomputed here, and the script says so. * fix-lock-self-sha-refs.sh (PI006) — rewrites such a record's `ref:` to the tag its commit was resolved from, from scripts/lib/action-ref-tags.tsv (every row taken from the lock GitHub accepts, standards@75bd599) or `git ls-remote --tags`; a commit with no tag is reported and left, exit 1. Reusable-workflow records keep their SHA ref, as the accepted lock does. scripts/tests/fix-workflow-pins.sh: 28 checks — planted defects fixed, planted non-defects untouched, second runs byte-identical, controls with an empty table. AGENTIC.a2ml names what an agent may and may not do with these (never choose a replacement SHA; never recompute a closure here). Dogfooded on this repository: the five standards callers (governance, hypatia-scan, mirror, scorecard, secret-scanner) were pinned inside Window B (da2c748, 571cc73) and have been dying at Set up job since 2026-10-16 (Governance run 39105727502 and every run after). They now pin 75bd599; actions.lock holds one standards@75bd599 record whose nested list is the union of what those five reusables use at that commit, every third-party record copied verbatim from standards' own lock, the seven self-SHA refs given their tags, and the three Dependabot bumps the lock had missed (download-artifact v8.0.2, upload-artifact v7.0.2) resynced. scripts/check-lock-sync.sh: in sync, transitively closed. dispatch-paths.sh still passes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .github/workflows/actions.lock | 95 +-- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/mirror.yml | 2 +- .github/workflows/rust.yml | 2 + .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- .machine_readable/descriptiles/AGENTIC.a2ml | 18 + scripts/fix-lock-self-sha-refs.sh | 80 +++ scripts/fix-rust-toolchain-input.sh | 103 ++++ scripts/fix-script-registry.json | 16 +- scripts/fix-stale-reusable-pin.sh | 122 ++++ scripts/lib/action-ref-tags.tsv | 37 ++ scripts/lib/stale-reusable-pins.tsv | 640 ++++++++++++++++++++ scripts/tests/fix-workflow-pins.sh | 183 ++++++ 15 files changed, 1254 insertions(+), 52 deletions(-) create mode 100755 scripts/fix-lock-self-sha-refs.sh create mode 100755 scripts/fix-rust-toolchain-input.sh create mode 100755 scripts/fix-stale-reusable-pin.sh create mode 100644 scripts/lib/action-ref-tags.tsv create mode 100644 scripts/lib/stale-reusable-pins.tsv create mode 100755 scripts/tests/fix-workflow-pins.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index dda12a26..85e03d00 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -10,7 +10,7 @@ workflows: - 'actions/checkout@v7.0.1' - 'actions/configure-pages@v6.0.0' - 'actions/deploy-pages@v5.0.1' - - 'actions/download-artifact@v8.0.1' + - 'actions/download-artifact@v8.0.2' - 'actions/upload-pages-artifact@v5.0.0' - 'haskell-actions/setup@v2.12.1' '.github/workflows/codeql.yml': @@ -23,11 +23,11 @@ workflows: '.github/workflows/e2e.yml': - 'actions/checkout@v7.0.1' '.github/workflows/governance.yml': - - 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540' + - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' '.github/workflows/hypatia-dispatch-intake.yml': - 'actions/checkout@v7.0.1' '.github/workflows/hypatia-scan.yml': - - 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540' + - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' '.github/workflows/inbox-steward.yml': - 'actions/checkout@v7.0.1' '.github/workflows/instant-sync.yml': @@ -36,17 +36,17 @@ workflows: '.github/workflows/labels.yml': [] '.github/workflows/learning-loop.yml': - 'actions/checkout@v7.0.1' - - 'actions/upload-artifact@v7.0.1' + - 'actions/upload-artifact@v7.0.2' '.github/workflows/lock-sync-gate.yml': [] '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' + - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' - 'actions/upload-pages-artifact@v5.0.0' '.github/workflows/panicbot-sweep.yml': - 'actions/checkout@v7.0.1' - - 'actions/upload-artifact@v7.0.1' + - 'actions/upload-artifact@v7.0.2' - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.2' '.github/workflows/push-email-notify.yml': @@ -56,16 +56,16 @@ workflows: '.github/workflows/rust.yml': - 'actions/checkout@v7.0.1' '.github/workflows/scorecard.yml': - - 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540' + - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' '.github/workflows/secret-scanner.yml': - - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' + - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' '.github/workflows/supervised-fleet-scan.yml': - 'actions/checkout@v7.0.1' '.github/workflows/vendored-bot-drift.yml': - 'actions/checkout@v7.0.1' dependencies: 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': - ref: '55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + ref: 'v6.1.0' commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' owner_id: 44036562 repo_id: 215566462 @@ -75,7 +75,7 @@ dependencies: owner_id: 44036562 repo_id: 215566462 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: '3d3c42e5aac5ba805825da76410c181273ba90b1' + ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 @@ -94,24 +94,24 @@ dependencies: commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 - 'actions/download-artifact@v8.0.1': - ref: 'v8.0.1' - commit: 'sha1-3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' + 'actions/download-artifact@v8.0.2': + ref: 'v8.0.2' + commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' owner_id: 44036562 repo_id: 192626254 - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a': - ref: '043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - owner_id: 44036562 - repo_id: 192625955 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': ref: 'v7.0.0' commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' owner_id: 44036562 repo_id: 192625955 - 'actions/upload-artifact@v7.0.1': - ref: 'v7.0.1' - commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9': + ref: 'v7.0.2' + commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' + owner_id: 44036562 + repo_id: 192625955 + 'actions/upload-artifact@v7.0.2': + ref: 'v7.0.2' + commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' owner_id: 44036562 repo_id: 192625955 'actions/upload-pages-artifact@v5.0.0': @@ -121,9 +121,14 @@ dependencies: repo_id: 496012378 uses: - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' - 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772': - ref: '6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' - commit: 'sha1-6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': + ref: 'v2.0.5' + commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + owner_id: 42048915 + repo_id: 356423100 + 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f': + ref: 'v1' + commit: 'sha1-e2a55d2ffb04f378e9626c28d38b36d230d1e12f' owner_id: 1940490 repo_id: 260749683 'dtolnay/rust-toolchain@v1': @@ -132,18 +137,18 @@ dependencies: owner_id: 1940490 repo_id: 260749683 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': - ref: '51f63319f592f97930c73d9c46184d20bd206393' + ref: 'v3.0.0' commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' owner_id: 26415196 repo_id: 297874902 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': - ref: '54075bcc5e249e4758d363f27d099f55d843f124' + ref: 'v1.24.1' commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' owner_id: 47606891 repo_id: 331103973 - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938': - ref: 'cdf488f595d80d6e07e03d4674febd5ab45fa938' - commit: 'sha1-cdf488f595d80d6e07e03d4674febd5ab45fa938' + 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': + ref: 'v4.38.0' + commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 'github/codeql-action@v4.38.2': @@ -171,33 +176,33 @@ dependencies: commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' owner_id: 6759885 repo_id: 1352485172 - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd': - ref: '571cc734cd69fb846032ec77a662aa8ee4fc32cd' - commit: 'sha1-571cc734cd69fb846032ec77a662aa8ee4fc32cd' - owner_id: 6759885 - repo_id: 1116521501 - uses: - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' - - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' - 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540': - ref: 'da2c748aad55c1a1dcba00b60fe4a35017bc6540' - commit: 'sha1-da2c748aad55c1a1dcba00b60fe4a35017bc6540' + 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66': + ref: '75bd5994470661a099df562d6d5ad2fe744dbd66' + commit: 'sha1-75bd5994470661a099df562d6d5ad2fe744dbd66' owner_id: 6759885 repo_id: 1116521501 uses: - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + - 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f' - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393' - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc': - ref: '2d1146689b8cda280b9bc96326124645441f03bc' + ref: 'v2.4.4' commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' owner_id: 67707773 repo_id: 421101922 + 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6': + ref: 'v2.2.0' + commit: 'sha1-0c5077e51419868618aeaa5fe8019c62421857d6' + owner_id: 108928776 + repo_id: 512644635 'peter-evans/repository-dispatch@v4.0.1': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' @@ -209,7 +214,7 @@ dependencies: owner_id: 580492 repo_id: 298565987 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': - ref: 'e83874834305fe9a4a2997156cb26c5de65a8555' + ref: 'v0.10.0' commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' owner_id: 135788 repo_id: 208510314 diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index df2c3165..039dbdca 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -35,7 +35,7 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540 + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 permissions: actions: read contents: read diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 83451cea..b5c18417 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -31,7 +31,7 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540 + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 secrets: inherit # Total caller-side wall-clock cap for the reusable. Matches # Hypatia's `missing_timeout_minutes` rule expectation. The scan is diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index adac35c1..f35712ec 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -15,7 +15,7 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 secrets: inherit permissions: actions: read diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c034b328..00698a4e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -83,6 +83,8 @@ jobs: with: persist-credentials: false - run: bash scripts/tests/dispatch-paths.sh + - name: Workflow-pin fix scripts keep their contracts + run: bash scripts/tests/fix-workflow-pins.sh gsbot: name: GSBot build, tests and dependency security diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 8c4afbbd..34aded1e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -18,4 +18,4 @@ jobs: contents: read security-events: write id-token: write - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540 + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index cb885b38..23a4b0d6 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -24,5 +24,5 @@ jobs: contents: read pull-requests: write actions: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 secrets: inherit diff --git a/.machine_readable/descriptiles/AGENTIC.a2ml b/.machine_readable/descriptiles/AGENTIC.a2ml index 645cb6c2..1a352a3c 100644 --- a/.machine_readable/descriptiles/AGENTIC.a2ml +++ b/.machine_readable/descriptiles/AGENTIC.a2ml @@ -35,6 +35,24 @@ allow-silent-skip = false require-rerun-after-fix = true release-claim-requires-hard-pass = true +[workflow-pin-remediation] +# The fleet's half of the pin-propagation architecture +# hypatia (detection) -> gitbot-fleet (this: actuation) -> .git-private-farm (propagation) +# Three fix scripts, one contract test (scripts/tests/fix-workflow-pins.sh, run +# by rust.yml), and two data files that carry the SAME facts hypatia detects +# on, so detection and repair cannot drift apart: +# scripts/fix-rust-toolchain-input.sh WF026 SHA-pinned dtolnay/rust-toolchain with no toolchain: input +# scripts/fix-stale-reusable-pin.sh WF027, PIN-003, WF016 a caller pinned at a reusable revision the estate has MEASURED dead +# scripts/fix-lock-self-sha-refs.sh PI006 actions.lock record whose ref is its own SHA (GitHub: "invalid lockfile pin") +# scripts/lib/stale-reusable-pins.tsv the dead revisions and the one replacement each moves to +# scripts/lib/action-ref-tags.tsv the tag that resolves to each commit the estate pins by SHA +may-rewrite-reusable-pins = true # only to a replacement the table names; never "newest" +may-rewrite-actions-lock = true # SHA substitution and ref:tag only; nested uses: lists are never recomputed here +may-choose-replacement-sha = false # a human picks it against standards/scripts/lock-selfcheck.sh and records it in the table +may-run-network-lookups = true # git ls-remote --tags for a tag the map lacks; --offline disables it +must-report-unresolved = true # a self-SHA record with no tag is reported and left, never guessed (exit 1) +must-be-idempotent = true # every script's second run is byte-identical; the contract test asserts it + [automation-hooks] # on-enter: Read 0-AI-MANIFEST.a2ml, then .machine_readable/descriptiles/STATE.a2ml # on-exit: Update .machine_readable/descriptiles/STATE.a2ml with session outcomes diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh new file mode 100755 index 00000000..3d6d9859 --- /dev/null +++ b/scripts/fix-lock-self-sha-refs.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# fix-lock-self-sha-refs.sh — give every actions.lock dependency record whose +# `ref:` is its own commit SHA the tag that commit was resolved from. +# Hypatia rule PI006 (self-SHA lock record). +# +# MEASURED 2026-10-15/16 across the estate (cicd-squabbler run 38846539059, +# hypatia#929): GitHub now validates a reusable workflow against the CALLEE +# repository's lock at the called SHA, and rejects at Set up job +# Unable to resolve action `actions/checkout@3d3c42e5…`: invalid lockfile pin +# whenever the record reads +# 'actions/checkout@3d3c42e5…': +# ref: '3d3c42e5…' <- the SHA repeated as the ref +# `gh actions-lock` writes the tag (`ref: 'v7.0.1'`); hand-added records +# wrote the SHA, and every caller of a reusable whose repository carries such +# a record died. standards main carried them from 2026-08-19 to 2026-10-19. +# +# What it does: for each record `'owner/repo@':` followed by +# `ref: ''`, where owner/repo is NOT an estate reusable-workflow source +# (hyperpolymath/standards, hyperpolymath/cicd-suite — a reusable record +# legitimately carries its SHA as ref; standards' accepted lock does), look the +# commit up in scripts/lib/action-ref-tags.tsv, else (unless --offline) in +# `git ls-remote --tags https://github.com/owner/repo`, preferring the most +# specific tag; rewrite the ref line. A commit with no tag is reported and +# left alone — a wrong tag is worse than a known-bad one. +# +# Idempotent. Exit 0 whether or not anything changed; exit 1 if a self-SHA +# record remains unresolved (so a caller can tell "fixed" from "still dead"). +# +# Usage: +# fix-lock-self-sha-refs.sh [--offline] +# TAG_MAP=/path/map.tsv fix-lock-self-sha-refs.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TAG_MAP="${TAG_MAP:-$SCRIPT_DIR/lib/action-ref-tags.tsv}" +REPO_PATH="${1:?Usage: fix-lock-self-sha-refs.sh [--offline]}" +OFFLINE=0; [ "${2:-}" = "--offline" ] && OFFLINE=1 +LOCK="$REPO_PATH/.github/workflows/actions.lock" + +[ -f "$LOCK" ] || { echo "OK (no actions.lock): $REPO_PATH"; exit 0; } + +# Prefer the most specific tag: more dotted numeric components, then longer. +best_tag() { awk '{ n = gsub(/\./, ".", $0); print n "\t" length($0) "\t" $0 }' | sort -t $'\t' -k1,1nr -k2,2nr | head -1 | cut -f3; } + +lookup_tag() { # + local repo="$1" sha="$2" tag + tag=$(awk -F'\t' -v r="$repo" -v s="$sha" '$1 == r && $2 == s { print $3; exit }' "$TAG_MAP" 2>/dev/null || true) + if [ -z "$tag" ] && [ "$OFFLINE" -eq 0 ]; then + tag=$(git ls-remote --tags "https://github.com/$repo" 2>/dev/null \ + | awk -v s="$sha" '$1 == s { sub(/^refs\/tags\//, "", $2); sub(/\^\{\}$/, "", $2); print $2 }' \ + | sort -u | best_tag || true) + fi + printf '%s' "$tag" +} + +fixed=0; unresolved=0 +tmp=$(mktemp); cp "$LOCK" "$tmp" +while IFS=$'\t' read -r lineno repo sha; do + case "$repo" in hyperpolymath/standards|hyperpolymath/cicd-suite) continue ;; esac + tag=$(lookup_tag "$repo" "$sha") + if [ -z "$tag" ]; then + echo "UNRESOLVED $repo@${sha:0:7}: no tag found for this commit (record left as is; GitHub rejects it)" >&2 + unresolved=$((unresolved + 1)); continue + fi + # The ref line is the one directly after the record key. + sed -i "$((lineno + 1))s|ref: '$sha'|ref: '$tag'|" "$tmp" + echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" + fixed=$((fixed + 1)) +done < <(awk ' + match($0, /^ '"'"'([^'"'"'@]+)@([0-9a-f]{40})'"'"':$/, m) { key_line = NR; key_repo = m[1]; key_sha = m[2]; next } + key_line && NR == key_line + 1 && $0 ~ "^ ref: '"'"'" key_sha "'"'"'$" { print key_line "\t" key_repo "\t" key_sha } + { key_line = 0 } +' "$LOCK") + +if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; else rm -f "$tmp"; fi +if [ "$fixed" -eq 0 ] && [ "$unresolved" -eq 0 ]; then echo "OK (no self-SHA action records): $LOCK"; fi +[ "$fixed" -gt 0 ] && echo "Rewrote $fixed record(s) in $LOCK" +[ "$unresolved" -eq 0 ] diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh new file mode 100755 index 00000000..c0e1d0a1 --- /dev/null +++ b/scripts/fix-rust-toolchain-input.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# fix-rust-toolchain-input.sh — give every SHA-pinned dtolnay/rust-toolchain +# step a `toolchain:` input. Hypatia rule WF026. +# +# dtolnay/rust-toolchain reads the toolchain from its `@`-ref (`@stable`, +# `@1.85.0`). A full-SHA ref carries no version, so a SHA-pinned step with no +# `with: toolchain:` fails its first step with +# 'toolchain' is a required input +# before any cargo command runs — and the red reads as a code failure. +# plasma-parser-writer's `rust-ci / Cargo check + clippy + fmt` was red this +# way from 2026-09-29 to 2026-10-14 (plasma-parser-writer#105); standards' +# rust-ci-reusable.yml carried the defect from d73c93e0 to 49ed4715 +# (fixed by standards#439, 9fe0d8ff). +# +# What it does, per workflow file: +# * a step whose `uses:` is dtolnay/rust-toolchain@<40-hex> and whose +# `with:` block has no `toolchain:` key gets `toolchain: ` +# (default `stable`) as the first key of that block; +# * a step with no `with:` block at all gets one, directly under `uses:`; +# * a tag-pinned step (`@stable`, `@1.85.0`) and a step that already +# names a toolchain are left alone. +# Idempotent: a second run changes nothing. +# +# Usage: +# fix-rust-toolchain-input.sh +# fix-rust-toolchain-input.sh (every .github/workflows/*.yml) +# TOOLCHAIN=1.85.0 fix-rust-toolchain-input.sh +# +# Pairs with Hypatia.Rules.WorkflowAudit.check_sha_pinned_toolchain_without_input/1 +# (WF026) and recipe id `WF026` in fix-script-registry.json. + +set -euo pipefail + +TOOLCHAIN="${TOOLCHAIN:-stable}" +TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" + +apply_to_file() { + local f="$1" tmp + [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } + grep -qE 'uses:[[:space:]]*["'"'"']?dtolnay/rust-toolchain@[0-9a-f]{40}' "$f" || return 0 + + tmp=$(mktemp) + awk -v toolchain="$TOOLCHAIN" ' + # Emit the buffered step, patched if it needs it. + function flush( i, has_with, has_tc, with_indent, out) { + if (n == 0) return + has_with = 0; has_tc = 0; with_indent = "" + for (i = 1; i <= n; i++) { + if (buf[i] ~ /^[[:space:]]*with:[[:space:]]*$/) { has_with = 1; with_indent = buf[i]; sub(/with:.*$/, "", with_indent) } + if (buf[i] ~ /^[[:space:]]*toolchain:[[:space:]]*[^[:space:]]/) has_tc = 1 + } + if (has_tc) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } + for (i = 1; i <= n; i++) { + print buf[i] + if (has_with && buf[i] ~ /^[[:space:]]*with:[[:space:]]*$/) { + print with_indent " toolchain: " toolchain + fixed++ + } else if (!has_with && buf[i] ~ /^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*["'"'"']?dtolnay\/rust-toolchain@[0-9a-f]{40}/) { + # `with:` sits at the indentation of the step'"'"'s keys: the + # `uses:` column when `uses:` opened the step with `- `, else + # the `uses:` line'"'"'s own indentation. + key_indent = buf[i]; sub(/uses:.*$/, "", key_indent); gsub(/-/, " ", key_indent) + print key_indent "with:" + print key_indent " toolchain: " toolchain + fixed++ + } + } + n = 0 + } + BEGIN { n = 0; in_step = 0; fixed = 0 } + { + if (in_step) { + # The step ends at the next list item at the step'"'"'s own indentation + # (or shallower), or at a shallower key, or at a blank line followed + # by such a line. Comments and deeper lines belong to the step. + ind = $0; sub(/[^ ].*$/, "", ind) + if ($0 ~ /^[[:space:]]*-[[:space:]]/ && length(ind) <= length(step_indent)) { flush(); in_step = 0 } + else if ($0 !~ /^[[:space:]]*$/ && $0 !~ /^[[:space:]]*#/ && length(ind) < length(step_indent)) { flush(); in_step = 0 } + } + if (!in_step && $0 ~ /^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*["'"'"']?dtolnay\/rust-toolchain@[0-9a-f]{40}/) { + in_step = 1; step_indent = $0; sub(/[^ ].*$/, "", step_indent) + # A `- name:` line may have opened this step above; it was already + # printed, which is fine: we only ever insert below `uses:`. + buf[++n] = $0; next + } + if (in_step) { buf[++n] = $0; next } + print + } + END { flush(); if (fixed) printf "FIXED %s steps=%d toolchain=%s\n", FILENAME, fixed, toolchain > "/dev/stderr" } + ' "$f" > "$tmp" + + if ! cmp -s "$f" "$tmp"; then mv "$tmp" "$f"; else rm -f "$tmp"; echo "OK (every SHA-pinned dtolnay step names a toolchain): $f"; fi +} + +if [ -d "$TARGET" ]; then + for f in "$TARGET"/.github/workflows/*.yml "$TARGET"/.github/workflows/*.yaml; do + [ -f "$f" ] || continue + apply_to_file "$f" + done +else + apply_to_file "$TARGET" +fi diff --git a/scripts/fix-script-registry.json b/scripts/fix-script-registry.json index c2f6bb5c..cd04cd22 100644 --- a/scripts/fix-script-registry.json +++ b/scripts/fix-script-registry.json @@ -49,7 +49,10 @@ "LicensePMPLDrift": "fix-pmpl-drift.sh", "ActionsPolicyTooRestrictive": "fix-actions-policy.sh", "SessionDetritus": "fix-session-detritus.sh", - "RetiredDescriptilePolicy": "fix-retired-descriptile-policy.sh" + "RetiredDescriptilePolicy": "fix-retired-descriptile-policy.sh", + "ToolchainInputMissing": "fix-rust-toolchain-input.sh", + "StaleReusablePin": "fix-stale-reusable-pin.sh", + "LockSelfShaRef": "fix-lock-self-sha-refs.sh" }, "by_recipe": { "recipe-actions-allow-all": "fix-actions-policy.sh", @@ -111,7 +114,16 @@ "reusable_workflow_sha_bump_needs_propagation": "propagate-sha-bump.sh", "recipe-fix-pmpl-drift": "fix-pmpl-drift.sh", "recipe-archive-session-detritus": "fix-session-detritus.sh", - "recipe-retired-descriptile-policy": "fix-retired-descriptile-policy.sh" + "recipe-retired-descriptile-policy": "fix-retired-descriptile-policy.sh", + "WF026": "fix-rust-toolchain-input.sh", + "workflow_audit/sha_pinned_toolchain_without_input": "fix-rust-toolchain-input.sh", + "WF027": "fix-stale-reusable-pin.sh", + "workflow_audit/known_broken_reusable_revision": "fix-stale-reusable-pin.sh", + "WF016": "fix-stale-reusable-pin.sh", + "recipe-rewrite-orphan-reusable-pin": "fix-stale-reusable-pin.sh", + "PIN-003": "fix-stale-reusable-pin.sh", + "PI006": "fix-lock-self-sha-refs.sh", + "pin_integrity/self_sha_lock_record": "fix-lock-self-sha-refs.sh" } } } diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh new file mode 100755 index 00000000..8f6c01f2 --- /dev/null +++ b/scripts/fix-stale-reusable-pin.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# fix-stale-reusable-pin.sh — move a caller off a reusable-workflow pin that is +# KNOWN DEAD. Hypatia rules WF016 (orphan pin) and PI001 via PIN-003 (standards +# pins in a measured dead window); the auto-fix half of +# `reusable_workflow_sha_bump_needs_propagation` for pins the estate has already +# diagnosed. +# +# A caller line +# uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@ +# is rewritten to the replacement SHA the table names for that pin, and ONLY +# for pins the table names: this script never guesses that "newer is better". +# standards' scripts/lock-selfcheck.sh exists because a newer standards commit +# can be LESS usable than an older one, so each replacement in the table was +# chosen by hand against that check. +# +# If the repository carries .github/workflows/actions.lock and it names the +# stale SHA (a caller onboarded with its reusable's dependency record), the +# SHA is replaced there too — in the workflows: list and in the record's +# key/ref/commit — so the lock stays internally consistent. The record's +# nested `uses:` list is NOT recomputed: run `gh actions-lock` (or the +# repository's check-lock-sync.sh) afterwards; the script says so when it +# touches a lock. +# +# Idempotent. Rewrites nothing it does not recognise. Exit 0 whether or not +# anything changed; exit 2 when the table is unreadable. +# +# Usage: +# fix-stale-reusable-pin.sh +# PINS_TABLE=/path/to/table.tsv fix-stale-reusable-pin.sh +# +# Table: scripts/lib/stale-reusable-pins.tsv — reusable_pathstale_sha +# replacement_shareason. reusable_path may end in `/*` to cover every +# reusable of that repository. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PINS_TABLE="${PINS_TABLE:-$SCRIPT_DIR/lib/stale-reusable-pins.tsv}" +REPO_PATH="${1:?Usage: fix-stale-reusable-pin.sh }" + +[ -r "$PINS_TABLE" ] || { echo "fix-stale-reusable-pin: table not readable: $PINS_TABLE" >&2; exit 2; } +[ -d "$REPO_PATH/.github/workflows" ] || { echo "OK (no workflows): $REPO_PATH"; exit 0; } + +LOCK="$REPO_PATH/.github/workflows/actions.lock" +rewrites=0; lock_touched=0 + +# Two callers pinned at two different dead SHAs move to the SAME replacement, +# so after substitution the lock can hold the same dependency key twice. A +# duplicate key is a lockfile GitHub refuses. Keep the first record, fold the +# others' nested `uses:` into it (deduplicated, sorted as gh actions-lock sorts), +# and drop them. gh-actions-lock's 4-space layout is preserved. +merge_duplicate_records() { + local lock="$1" tmp + tmp=$(mktemp) + awk ' + function emit_record( i, n, out) { + if (!cur) return + # Rebuild the record: header lines, then a sorted, unique uses: list. + print cur + for (i = 1; i <= nhead[cur]; i++) print head[cur, i] + n = 0 + for (u in uses_of) if (index(u, cur SUBSEP) == 1) n++ + if (n > 0) { + print " uses:" + cmd = "sort" + for (u in uses_of) if (index(u, cur SUBSEP) == 1) print " - " substr(u, length(cur) + 2) | cmd + close(cmd) + } + } + BEGIN { indep = 0; cur = "" } + /^dependencies:[[:space:]]*$/ { indep = 1; print; next } + !indep { print; next } + /^ '"'"'[^'"'"']+'"'"':$/ { + key = $0 + if (!(key in seen)) { seen[key] = 1; order[++norder] = key; nhead[key] = 0 } + cur = key; next + } + /^ uses:[[:space:]]*$/ { next } + /^ - '"'"'[^'"'"']+'"'"'$/ { u = $0; sub(/^ - /, "", u); uses_of[cur SUBSEP u] = 1; next } + /^ [a-z_]+: / { if (!(cur in done_head)) { head[cur, ++nhead[cur]] = $0 } ; next } + { next } + END { + for (k = 1; k <= norder; k++) { + cur = order[k]; delete done_head + emit_record() + } + } + ' "$lock" > "$tmp" + if ! cmp -s "$lock" "$tmp"; then mv "$tmp" "$lock"; else rm -f "$tmp"; fi +} + +# Every `uses:` of an estate reusable at a full SHA, across the workflows. +while IFS= read -r -d '' wf; do + while IFS= read -r line; do + path="${line%@*}"; stale="${line##*@}" + [[ "$stale" =~ ^[0-9a-f]{40}$ ]] || continue + replacement=""; reason="" + while IFS=$'\t' read -r t_path t_stale t_new t_reason; do + [[ -z "$t_path" || "$t_path" == \#* ]] && continue + [[ "$t_stale" == "$stale" ]] || continue + if [[ "$t_path" == "$path" ]] || { [[ "$t_path" == */\* ]] && [[ "$path" == "${t_path%\*}"* ]]; }; then + replacement="$t_new"; reason="$t_reason"; break + fi + done < "$PINS_TABLE" + [ -n "$replacement" ] || continue + [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } + sed -i "s|\(uses:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" + echo "FIXED ${wf#"$REPO_PATH"/} $path ${stale:0:7} -> ${replacement:0:7} ($reason)" + rewrites=$((rewrites + 1)) + if [ -f "$LOCK" ] && grep -q "$stale" "$LOCK"; then + sed -i "s/$stale/$replacement/g" "$LOCK" + merge_duplicate_records "$LOCK" + lock_touched=1 + fi + done < <(grep -oE "uses:[[:space:]]*[\"']?hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" "$wf" | sed -E "s/^uses:[[:space:]]*[\"']?//" | sort -u) +done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + +if [ "$lock_touched" -eq 1 ]; then + echo "WARN: actions.lock named a stale SHA and was rewritten to the replacement; its nested uses: list was NOT recomputed — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 +fi +if [ "$rewrites" -eq 0 ]; then echo "OK (no known-dead reusable pins): $REPO_PATH"; else echo "Rewrote $rewrites pin(s) in $REPO_PATH"; fi diff --git a/scripts/lib/action-ref-tags.tsv b/scripts/lib/action-ref-tags.tsv new file mode 100644 index 00000000..3f984040 --- /dev/null +++ b/scripts/lib/action-ref-tags.tsv @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: MPL-2.0 +# action-ref-tags.tsv — the tag that resolves to a commit, for actions the estate pins by SHA. +# Columns (tab-separated): owner/repocommit_shatag +# Consumed by scripts/fix-lock-self-sha-refs.sh as its offline map; a commit not listed here is +# resolved with `git ls-remote --tags` unless --offline is given. Source of truth for every row: +# hyperpolymath/standards actions.lock at 75bd5994 (the lock GitHub accepts), plus `git ls-remote` +# for the two cicd-squabbler release pins. Add a row when you add a pin; never invent a tag. +actions/cache 55cc8345863c7cc4c66a329aec7e433d2d1c52a9 v6.1.0 +actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1 v7.0.1 +actions/configure-pages 45bfe0192ca1faeb007ade9deae92b16b8254a0d v6.0.0 +actions/create-github-app-token bcd2ba49218906704ab6c1aa796996da409d3eb1 v3.2.0 +actions/deploy-pages 368f82528645a54fb793d4d04e342629a3f51346 v5.0.1 +actions/download-artifact 9000827ccba6bdab643e8b6fd33ac0654aef8333 v8.0.2 +actions/github-script 3a2844b7e9c422d3c10d287c895573f7108da1b3 v9.0.0 +actions/upload-artifact bbbca2ddaa5d8feaa63e36b76fdaad77386f024f v7.0.0 +actions/upload-artifact cf430e030ddbb5b0abf93d22962f4752f3646cd9 v7.0.2 +actions/upload-artifact 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a v7.0.1 +actions/upload-pages-artifact fc324d3547104276b827a68afc52ff2a11cc49c9 v5.0.0 +actions/attest-build-provenance 4d101475d8b20a2381f78447822ac1eab6504dd8 v4.2.2 +denoland/setup-deno 22d081ff2d3a40755e97629de92e3bcbfa7cf2ed v2.0.5 +dtolnay/rust-toolchain e2a55d2ffb04f378e9626c28d38b36d230d1e12f v1 +dtolnay/rust-toolchain 683b588c487a69d01f6c6ddf40f036ff682cc2c8 v1 +editorconfig-checker/action-editorconfig-checker 51f63319f592f97930c73d9c46184d20bd206393 v3.0.0 +erlef/setup-beam 54075bcc5e249e4758d363f27d099f55d843f124 v1.24.1 +github/codeql-action b96794f015dfd88f77b49b1c93e0fa7110f94c63 v4.38.0 +goto-bus-stop/setup-zig abea47f85e598557f500fa1fd2ab7464fcb39406 v2.2.1 +haskell-actions/setup 0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d v2.12.1 +haskell-actions/setup 6037f33647c3f17758a2356c80fc4a53d7e0685d v2.12.0 +ocaml/setup-ocaml 93303b622b2522e4411e295f9e77411a24912ac7 v3.9.0 +ossf/scorecard-action 2d1146689b8cda280b9bc96326124645441f03bc v2.4.4 +oven-sh/setup-bun 0c5077e51419868618aeaa5fe8019c62421857d6 v2.2.0 +peter-evans/repository-dispatch 28959ce8df70de7be546dd1250a005dd32156697 v4.0.1 +softprops/action-gh-release efb35369e0ad2afab669f228072c1b0d510eae64 v3.0.3 +step-security/harden-runner ccd8616d44fd3846e67624a50d5aad6d37bf2d25 v2.22.1 +swatinem/rust-cache 6323deb102c322ba6fcbdcafc7e3dddab59af2b6 v2.9.2 +tailscale/github-action d1b6cd204f8dceda5b3eaad7f1f767be390056cd v4.2.0 +webfactory/ssh-agent e83874834305fe9a4a2997156cb26c5de65a8555 v0.10.0 diff --git a/scripts/lib/stale-reusable-pins.tsv b/scripts/lib/stale-reusable-pins.tsv new file mode 100644 index 00000000..f8be4f6c --- /dev/null +++ b/scripts/lib/stale-reusable-pins.tsv @@ -0,0 +1,640 @@ +# SPDX-License-Identifier: MPL-2.0 +# stale-reusable-pins.tsv — pins of estate reusables that are KNOWN DEAD, and the pin to move to. +# Columns (tab-separated): reusable_pathstale_shareplacement_shareason +# reusable_path is `hyperpolymath//.github/workflows/` or `hyperpolymath//.github/workflows/*` (every reusable of that repo). +# Consumed by scripts/fix-stale-reusable-pin.sh; mirrored by hypatia (pin denylist PIN-003) so detection and repair carry the same facts. +# +# Window A — standards main 2026-06-01..2026-06-24 (d73c93e0..49ed4715): rust-ci-reusable.yml pins dtolnay/rust-toolchain by SHA and passes +# no toolchain: input, so every caller fails in its first step with "'toolchain' is a required input" (standards#439 fixed it; plasma-parser-writer#105). +# Window B — standards commits reachable from main, 2026-08-19..2026-10-19 (5b5dab9f..fae7f010, 545 commits): actions.lock carries action records whose ref is the bare commit SHA; +# since ~2026-10-15 GitHub validates the CALLEE lock at the called SHA and rejects these ("invalid lockfile pin"), so every reusable of standards +# at these commits dies at Set up job for every caller (hypatia#929, cicd-squabbler run 38846539059). 75bd5994 is the first clean commit. +# Generated 2026-10-19 from a first-parent walk of hyperpolymath/standards; regenerate when standards fixes or re-breaks a window. +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml bf32531befa4bb7f692a5f7ecac1eab71af20506 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 583a9346ebf28e0fd8114f22aef140eaa1be4ac0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml d7c22711e830e1f383846472f6e9b99debdb201e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 747b2f0f690505b24c90991a030a01e564ff59c6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 509142da4ec04d74dccfc9e52813aeabc2c6fa02 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e2ef79eecdd623ff631fcbf3fe0268ba4f4ce166 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 0597948e81c0be7570f805d7ec49f64fa0eeefd6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml da1ee5ebaec4de70e7530a9fcb4b2c693434bcc1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 0f49246bc5055f7159c92d649c4d3fb97b75cf66 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml f15ced3f44146de9931bcb70157e1825d07b83ed 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 5e29ef7923aff017419bdce9354e9df4c3823cb5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml ad8c45387878f2b00217330b0e1260edc6f878b5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 3ce22c244bd4b31ed99397a585cd458735a79819 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 8c785d41adbbee534b158747a5c6796402e450f0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml c1070339c80c4f1af71bb35cacc58c19f611f3a2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 49ed471515bdf587aeb9931af2d1314cd6aa5de2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 7282b1ac828d89eaecd3403b59591975746ec3ff 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml d135b05bfc647d0c0fbfedc7e80f37ea50f49236 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 4762ba66441de05bccb36e074ba3d2202219126a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 59429dc666d347d030114cd6ef6f4ff7b7f6a6b4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e8fe153b568af5e3534a261eee0258fa84fa6c5e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 280cb7e3f2db8b0e5f26c42877ea5fa80243b06f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml c6c095b38f7e729d3d355629b80e771dfe1a351b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 09e7023d24682621bea4e11965a1ef5e87d86c3b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 42455b19727fa0ccf73a9b914f0e9781d4c34577 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 4ddc926b4b86451c2711a16bf927ed16ad2db45e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 0809b8e3630d38229b6c3629956e0dff4b937c33 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml d72fe5a14e841ac6d78514b53624b6173038ee20 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 5a93d9d57cc04de4002d6d0ecd336fc7a8698910 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 3e57141d38672438a7a9110bd10ea0f76e6099a7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 78b29005efe954822c86c553b40523b9fdae78d4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml f290d4a696b8af78f9141daeced486b7b8a85d11 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml a8c69ecfcd87d5418dd4559019ac860fff20e834 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml a04c1d9f32e3c6cb5e91b7ba220080475449a4d2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml c43aafaa2d707f3f195cad9742d3ccc8d068e3db 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml a955527810b82dbf7a9bc541502c5235f119499d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 2ff85f8f84212ae2cbedb1ea232c6f5a470acef3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 8e6ba7d4a0890d25425855a77404d4690d0ab4b5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 426368b72949d8235c8cf47f8bdc5e0e5cd5b54f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 025bba3f35f5040bf76d4954f02c744d61f68e8f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 8466cee8adbb814a2507a9a33e36ba415c5d7eba 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml f5f0506a6ec88e574753eee701a268e0d4b3a7f2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml ae5b6731e0c1ce192edc14737a226d6b201341aa 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 742424ec9cffeb414174276d9321325859e3aa64 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 19995ace5f1179e9e2d4783fe1d36df0f343492d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 524523c1a6057942aadaef5999912fbf13a355c5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e4c7d968fa9aad6fcecab23bd60d1025679d1ad9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e96096c6614469e29e02edcc923fdacc0e611b34 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml ca29b1950ac493e4a29db3b61b6991fbdc8a57b2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e59affd85741ac786867a667bc1526ade31b1dd4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 7694d58cd4c82d6d706ef9653bcf545e28126794 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 33a82ee5e3635bd137b2bc8ef2b1d71367195546 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 52db79bee40380820e01c454efe6e85ae6115b2c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 6022e498a06552d71e51662283b9453855185b94 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml c97b2bdd12aeaff4624efcaec874ea7665dadae6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 9726a3814c895bf531f22c2b82cc15a3b1efa1f3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml bec61613c821fdb1035f18b88e6e5d19d23328f7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 4236493691a06a6393b5ec4480f38e5d804f5b69 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 6cd3772824e59c8c9affeab66061e25383544242 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e763d82b93f46e7201d95e6d817ac61d739a8cc1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml aa665acc04fff66632ede8368dbc9c2b5a630d3c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 3a5c23cb5888eca79df090fde6d52a8dc4820285 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 0b96f61a3f043bd5409b913e526b29f3fd38a217 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml ae6619ecddd0eff0f957c73280efc36bb9302d75 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 3b3549e293f87be99d7dbb939d380c8128842c8e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 2b88f34c97a9478b7a96b07425bb37ee5822bb8e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 9bdaff9479d53328ed907bdd5ba885398800fb00 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 142dd7a0b9cd6101de8c4279c55b7051c537f76b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml ac6b3252e7170d9459779c03bf31ae04c3629a61 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml f2b9d5eb5fb68a60b30468b8289a0f8cefbdd558 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e401e47106ce0b0e32e3d0e2b7d910410e5b165e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml e3ad4ca266e4dc93b7cebce5e3b3b48c6d523e45 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 74748546395bf3dcedd5b660f58feb36be9d53d6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml fc7abf589b93f9a33b8af74dbfe98ba22707156f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 7814dc67a489afc39f0289af5d3b6c3e23645156 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml c98915a225c98f2b90980cf045a00eb603e6af0f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 18dd92c19d5b9e50740826a4be8301afbab91bae 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 82a8ac8e2fd446b710139a759bf22f175dc5e299 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 55da6b331922f0ff7389c783d363ae12d8c61068 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 1376eb6251fa9beeaea75241739b1d00dd6b4028 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml 28946811fb7fe2d05e9ac11c9ebee7295e966d40 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml d7c3b8f75d70ab640e6f37a4c8f9e6e8f2b6df56 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml d73c93e0e7d38f673b68e616d51a62f5860081fd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window A: toolchain input missing +hyperpolymath/standards/.github/workflows/* fae7f01010f44606a65f84fe53e613201a0950ac 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e16a23170fa378bf3b1444c3ee94100413ae3ff7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b2cb7b685e17bd24e0122d19950a45de94ddcbc6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ce768a6abea6fb0bca9b4c3eeb135c3a75a7f06e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3de430074148c49c8db8878653b3c66707c344e4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f7573a00ba48a2dc19c6f912d3096f95b38f7be5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 813dcbaba53f11c06e09a0092c8466b4cbb778ee 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f335165a8882abadc99a092f55ff950b392af2d8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a88f511b9bc06b430a4b4071efe75d91921cd9f7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0d9a814ac8d7b1d5c1f39d8510639e8fdd99f3e4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f78a36c9058cbab0b6610a675d3d5d7b024fd837 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 68242e650c0bd83bbf280f3b578fa949c0ab4c16 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 94572e612a7bf11f8e6343d19adadf17ef2a3de3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 80014df42eb81f09396c948299febb258cec4719 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 95de3dfc3fcf09a1cf3325ab42723e3d81682b45 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6a9251037760690d56022ad6fab2cfe9d887dd76 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 35cf3e2b05ab1efaecf72f8e0875ee7b6da46b88 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ed04132e56c086795d3cc478cbdc145333ab5780 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 25824f323e76d481a3d37f22f28440ef77dc7905 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 934b879f6068295e42dac20d80e010d555ebec82 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 25d23cf3934e6c1fffd275ac78398c70aa9d58f3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8a39fa0bdb4d7787f7529111be417f984c85381e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 192389bf1b40b70c594464937db7fdbf39a9a964 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2ae085c076a4b14990bf67da0dd3821ce1c12944 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c11c255e1b5546854e874d1486d61d6d5a4da29a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 75064de100849f1864f8fe1c912e64c48d70a3ee 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ae36881b5ff4b469fb904b66542ac226a15107f3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3daaad25e39d671dde8ad58e6427282309b02cce 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b26d3fb548fa2c4a7920cc69128821811d4e3222 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 50cfbd55d44130399bf1215c04fd154a49277ee8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 507b89f1b35453bf3de62622373cc6573245b664 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bf21155871bfd8d4789f33543d0c30880b8403cd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e3d5107b6319dc1121e3edf12f497bed2f9fcd61 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7d2041a2315572d8257341449257e6cfddc9aebc 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 48e0ed9138ade95a5357f76079fc56d8f6f91bb5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 736f792321e24af85b0dfc44b7666538bf4a2ff1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 312ff116d9acbfff3523da5e9f71ac3ca9c327d3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0b1898e2e6a0fc88348c0f2517970593d373ca10 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4c101fbbc0b20e816c6beca9fc0c909994163443 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9164d76efef88f5173fdb717b231618a2d3c701a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3108c1b01186741eea80c00da3f4beda912286f9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7712ae1568af5c872408bfa1ba27578dd2afecd0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8496854d8241c3a0e20abaecf67e9c70638aa3b1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* dd58dd9659f425a3f2372b123dde524c27d045a4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9d629d112daa396636bbb32757213552a8fb6878 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 76773445d8b0c3a2558f33cc426bacc638206a6f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6d19ce0ebae047027dcfd594c7810bf35c642faa 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d8848f8509ccd94e931f6768487c88c8423f6033 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2e12b312b529c51f095ce3b22cd35968f5aba898 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ca12cd467cb13d02072837f9a2a69ede429b0b09 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 900c42c70b968e11a2bca04bf0ce050be5c286dc 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a695e37936bacff66add7ec354eb629f659477e5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bbcc722bb0a5969ca0b1ebb1178af2ebcd5e9c34 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 691ea2af89d4313c906672d880690f81219dbdc5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d8a90cf8093ab787e98b940cd7d7675b2c981ec2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e35431d5bce94895b675d5a135e3ff5af1ca4a63 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a709d3a642bee6e8d22829a69d2d64e1e8656d57 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c9dfc2dcb333c1672aa779fcc86671be037ee735 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8639b851db282e89083351aa782d906eb537def2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2f9d0be57ecbb09fb5d93df411fa6c9758ab305c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0683ddf00237ae92c5756cf858b3da1cc706894e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 85aa1934975a8df576c7a4912bacbb8160ab2d2d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 69ea45cfefab795faa2122df468ce7d120ebdff2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f77fc28ad400f281d26431b5e6a204dfc7a2ea22 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 63d2bbf64020b5f87bc546973d21881c19e79c65 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8453510aa0da5027dabe54743e06ed7e7648fbe2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d7b85cac57eb16edf51508d6f30806e86d63c9d3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d5a7cfa4a9e9b77e1ec45b828ea32e3bdba4634f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9f68971b9fa254a80559432d9fcb7652b72e941d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b9e78cd426a83d87408d1f5db9e798b9fb8a3bbe 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8b5dfd0646c1232174e680ae7772250610bfa2b8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7d51f30f992b51fe2b33119761901c594e266e82 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 89d778d9dc5343ad3dc178656c60e9caf235588b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 043496c2d1efb9ba48f69ec533b5444ab61bbb5d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ebfcd98ba6c162df2eeca854254e725095fc85d2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 239759976b272a426a3b7d34d0d48c0009bddcfb 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 79df53059d638e699b317951f717ee9710d2618d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 640d86efeddb1e47da230515f5af9f4c936bba80 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ad637e5134bc9f02ddf65431827d515b26bd58ee 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bae58d9398f6deff6187a18c59620c984b6e1c47 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 223a9da337e327246d17ad49800cf567744a3d5e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 617f3bcb83d33f5a0e3793c41d26a3c7937dfd16 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0ae8b773b5a0f16ca1c5389442c907ceacf75e00 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* eafcfbb5933dde8b42fdbd61483794bb75fb4c97 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5a0891e9e58b785181a92e141505ca96ca987b42 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8464935a8028dbf0b06eb3d2b9bc7c7a7a35b4c6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b935e63937b1f1b5cbaeea7e375947d7b924038f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 44a0de481c1b10843b358e6c5c278efbfd4eaad9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cfc3a92e55f6e6feeed33412d9fad94812d330ed 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 78a0a61f481bbe394214002f51f41a92325c0fff 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ce92a8cdb60b60ea4b8cd5040a14c5ec2eec9f0e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5751b97ed597e560573242f56f1287db159edd00 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a5ff08d7a12399fe21fa9ed72841a9c5749d086b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8556163f6bb9f06e2ed9beb67a91a335aa1f8913 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ef7e6c4cc801d1ebf5504b940ff70f72061334d0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0cb69026a45f756d6610d8b67e298c4c3d6acc95 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 67e148fe710381c732ee1a6a8b2898688ad13110 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 80b3a1abccfe39b16d8af8ead874e09e9529737b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5b56aec07917394f48d51cc95c459b12dc9c179e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 32d37263e96600ee81ec27f343bef812c4670968 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3bd25bd0fa9bb8f5a50d9d8288ad6c615a83fbda 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bee32592ef4952e7e0dce8035e66f827c79839b5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 44ca61e2deca34d8fa68a31922b1efbe7f13f241 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8ffcaf6251be5b05fe8ae4b3b88efbc5acc203bb 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5693b8b28b402355ab4516db4db3246b0b1019b4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6075712d2138d0bdcb1192d473ba50fba9501e0f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* aa1c4dd591fa2d5ecf0d65d53bb127eab6b95486 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c33b22b1037d6eabceb7673975e50c78030059d7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a41f3692d3e17193f14ede5cd7f79172f339085b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 65d9af0140d3761b4b450a5f475d5490b31ec5e2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4d60617725a5eb899cc5ebba85e72d598e66560c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4e13946f6ff22657c7ce1592e42100222da4e554 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1f099de983ed1ec26b8fbfdf176b69950291ead3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 177be894c56f68c3f3a3eb428b4993a8996da892 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e59aad7153dd7bc87c056039fcd71ba0567f37af 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 93f0c3fdc0c2dbf24d41cb888668adc4e34c6352 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2f8c3eebad6418c04ed8dcc35d0b1ac4bf3954d4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b3075e041f52cb6f7e3f9bc610e22000ce388548 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5c300a80f82d94d6faf02de0da7a1ab25700461f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a9a3281d620bdd195369c9a6f0bc1d661910965a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0ad178797da63e826742c20600a89e50c47c20ec 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e2e0f6d61c2d2a36ef516849dc1ca9223563479f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c55031473e743591b649da28cc16a4f095712082 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 804b780093b3c48842913ee472e9191afe5f8329 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f14575da71758f21a437fc875ec99d68399d7931 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f5f48a4605aff2622330356f45cf720871aa6c54 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* aa8fd9e81585ced5ce0fa580092b21e1f84d0fe1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 202a215d01297eb825c0d45495fcbc46a82a5e30 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 907bbfe535d21e0709eb4a67fbea1e304beec22b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 11ba299dca221b6d4e970eeb58dd17ccbb825a9a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 56b767ff3f70ea5427900c37659b0ea94eb49628 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c780e55efec3ef2f765cb0223cd154102a85a1a3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e4b64b00cf7698a624ea8a0a8bfba551d4674dc0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 215df8e96590dda0539d168eba000a579ab52517 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 71bb615e234133d22f04106c812aad9fcb269aa5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 721e4a9e1beb2392ee096d38ead44c0ece962eb3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 10e0b6ffb979f0a27fe58206512055133e67460a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 39ee4ec5e026dbdae084989c856a655e1e342629 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9719570729342a13fac0ead93a68d4516c5dab79 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* df20618b3ce9cefc19d603d5300952550074a3fe 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 13b872c19a9459e21a7f91aaebaf2a29e99684c9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0187ccd95604614009b37bb23a55f2e634cdc404 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 89813bf8e4cb806eb01232b396410c28f30ca742 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1b6e19eaef9e81ec19d6e826837e7e1a09f968cd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2c06ddf3d4f0d682c70ae75643db307df4fd36dd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7de6a8a7965950448a3abd0fe16653fe5af21e82 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7243deb443c38ff4a47a840ae01546c8106de245 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9f0a8bef3c4839c14751d6223a69b03e4f33fcea 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3c5bf1e1cefc9f3ecc2cb3b07ea5981933df9fd9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 658ded3ffea6353492de3f082e61aa489c728fa3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3fbfa71e126a95a884f74e9eeff8d189902d378a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bd9c319f9f10d9dc3c3d94fd490d92dd4270ba40 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8cfad82a72b94d1b362c20c5be95fbf677cc31f1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3238461b0930ade9172a2a449497af12aad56e1a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 74d2f66f575246cf6e313ae7775f44df6e097ff2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4b2a34740425b4bc124804edd8e37c51d303c135 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2ee32bc65b7e0d2ca8aa05a6b952817fd87f93f1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5fb9ad197f5bf57cf86c5f8df0c061ccb0bcf6a2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5f82b635c5da5c3df44d5a0caa8983d9b95e0db9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d0dc04d9c315b2a658bcabb3b970994365034c6d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 51eeca7ec47cf217f8bf296f6d1b2190584d0398 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 13baaa8df7dc7ba1c8e41351b08caacfd1f2244c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3a845452177a9910e21ce553a3c53d9f5e5763c0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bd9313a6fca4a08b4f0f0763f0079f6a24262726 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d8b9ad9a680729df98bfa947c7c1538adab02b00 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f428562bf63be8d5004bc871a6260d91a6a20d7c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6e0d69c42ee3b83080a8ec82bf76cb8fe558c24a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6e98f4bf810afb9b03d363b4c2c0c0adc1ac4953 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* fcf6e8d66463ddcb23a0abc1ba6fa9732ea595bd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 60fa96c63967305b9784cbd8f685824281f67bea 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7b2060bc871878d55adadb012f300fe3f00cac16 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2479cf769ed5f0481ccf64860a2ab954514c2b59 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 53b7a0716e36654f041e922e1c41843d7a04ff4f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ebe60f2fc0f71a215cf0fd7fa848769f446f7fbf 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c56f4ecc791676a2f8ce8f4e47aaf0b1c5b3763a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2c91e9e30567447d038f6bbe37308e9185087ea7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* db10ad3fa281dfbd5f682a76b808193b8e179752 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 64ad98645b13a5746996af0ac964c19512fe6472 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f56da4fbb342e7452dfbe9fd42430615833048d0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 520dcf63a8c6d0f194de3ed4aa72f78fa573721f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 30815fa6e9132605d8f0e8cfdce855638d2eb553 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 65de939f110068b1761504dcbed341383ab9819b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 779d8a9e62cf9e5661dc3abeecf62e49ca64c15a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0f2d9b5447d3d6e56b41fe69d54e834849d7c932 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 682fc60a5381744863443a719dfbe6b9bac93ab7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 392e84d63946e6337901baca316c0e106d3bb7ea 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0f13f51fafe9d2b670252aa2a18993223bffdece 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a613156836c24bfb1b94ea014273c0b711db6e69 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 991b13e8006d44c66a609ece7a3433f67112f06a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* faed4dd1957a6b60ecc131ddd6aa41f6fdc3f0bc 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 48dc0add6170c4b6441554449ed21644230d7165 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a55935e103729fc834780386b77176d6b7b7b285 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 73e84b8fd767310142604b67b67e41f7fea9cc6f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e3b9929cdc57149874e2211e6c8934145d245855 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 58aa824437eb20147acdf72dcabb4e271874a243 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ed5e3f651305dd1ce0d0b5d2d08b97a963634632 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d1bd7f42fcacd3fe29328b32be361964a915788e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f95130bbcbc8917bfec8f8875fa810ad93b08871 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* de6810309a6165547443f9970ee311af0bc7688c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* edac0d6a533281e3bb2eb729dcd09d1b40023bbe 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 79991c75d1419fcf76e72eddb9b0aea14016e438 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7b931ef7f9dbb8d2386fc9170895708c0acae95c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 68acee77cebda300e1f36fb6279f19d53d5b5cda 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2eb9bb19d43b6285466ca36b92d1324a09e7a207 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 417094b2224ea7cc130997f8d9c36847b2efb302 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 436a89b30cc988fd5505c9cd8f5dd81110a8606b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8aea869bd93421128db2b4c9d4fd0b936e2a3a99 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 12d76e891ffe5eda6265c79b438a07f5dce126d3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 051f98ec6ab2479cd46e405432811d2bb129e4e6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3a7c90aeacbd418f2d29d2522027e5621060dbf7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 38d211f19a4bcb9b8b684cdb3fe1f0459f05b73d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9c256b67486b4b30c757730e1b65b2c2d2af935b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* be6c9580237ec7a4f86c3d98228e4db569d6dca4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e977cc67456de8349dcc08143b92ce5608083f67 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7b05a32346a40789271f401afbbb7fb3310b281f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a2ff696ab8f5e0b38823ad3b5cdff72772093c6e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5c5b894a67b967484f7c34a6be0d076fda20e246 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4f7f02ca528212c578fd56379a202d219d01abe0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8fd6c915c96073c818bfc42089a95936e80f265c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b1a3b7b7c9c8cef5e8c8d44771426e7764b9521e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a521ed701f0e58cc5e7effd239b10b360908365a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b77c53c85e5734756a793497845735485c9dc933 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 56723d0cf7cd8ff07c5f08bf9bc4575b4e2ed895 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 07f85ad7a273e9f7b761265e4a1edf4ef1d45252 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* fb15752f38f89920dc533e20572e1f6980fbe831 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* eb0561c9c4e2d7d8a334b9d58075aeac8393eec8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9379fb71b66a2e1412cab7c5b5a5ff9f04632552 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d06afc106ce644f2deb5530a36aa7878bb0fe9f7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a8305e82e93d03322a704cb09d3caf09d14bad1d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c5b5450dab4e2fbef695e16856ec44228688c9ae 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2b0709d61427694b5c21140021b61a0d402e9c21 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6615f70c912e4af82f2149ae08a0dfa8e0ed1a2c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d1ca0e8c4774c776703106c7bd1efbd22ef44bcf 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 08f77c1486bf2028cea594d13cc1c1a7a3a8ca75 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f86fc748ae0c1744484609f65abdfd9af59d258d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 45b4c981e5f3fbb804b711787e4d5ac98f5942a9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6e8770f0d1a953d23ed475b47c299cd44a9fe7ee 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bf41f3ac094a68854f2af8ab92d7752435a6e2af 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2b77b4130f0de2e3e3846f8c51ece58720b0bad9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 84a438ed9baa75c499ac51e67f1ee831cbacde57 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 59a3f1806f2535298003582703b4d43d52474999 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8d7652776678320027c8f13a3af826e28423ae2b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3e388f6c6905dcb158fa1969b8a23ce3638bea6e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2aa452611d542482c8f854b565b5311aad3542d3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 115a28148283de8ea6314aa468212541b50906aa 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bcf9dfa654964e72b32b3ac74a34aaef2b5ec3c0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a087ce57288aab8945cdf4aca4ba554eb46fec94 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 69074330666ab52f4386e0ef3fcff622d0d63c93 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e13e2ea3dbbc9c7815e39c3749b4480514f555a6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 82e4206fdc6e045f1c6f0f372995a5c943f15237 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 588bc92c33dfad516c730819a763175cc1b1fccf 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 000b9f6baeb60071512616d345c355a5c1ff5de3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6e97d0dde7947a2e2d42fc8069c4d2c0d4c8817c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 097ac91926e57cfdcf05f76144946ebe664123e7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7acfded6f6171adcf2466ce83d20c4cb27e74a62 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4885cffc1b986dfbf29184c7ec7603f2cbb8447d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c9257ae75101d9731483ac39d5771f382e6da39c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4e6ffe5568d7864f540d99c7d4ea9b9250ed30f2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1fcf0271646951e9b89c7303a9c3519fcf4c08ea 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9f68279ef0e04b184bd133ddb395b3e8b13c41ba 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 699c47fa4b5c9ec4056b59b351761ccb6402072a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8438e359ffa7be7ba0b375404b54ce36687241ff 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 92ae1834e8ff26f9d9b15b4afb9ea36556503c2d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a8a2bded92d86f6ef4f16e92e303896198dc92b5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 950bbbca54a0bd723341882fcc354a16568ec4ac 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c5f469cf2f823889aaa53fbf90a1c41f4f4bb891 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6054f0fd118073a08752807758629bab61cf258a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 408d3a678a8b72d6f20e52ca5df34834fb8bc7c6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4e0e123f5012a4154bbe4c20194c7c021be69c77 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4c29ea1bcf0cffc114de0f49c9162fce894f4a4f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 71d0b9f08ab6bbdd4fe0234b0486c52dd45498e7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0974c3717fb71650a25898f4067eaf861d52f93f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d79abd10f12bd15f7be6a4b737f3c5d891a245c4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 816905345646edb682078451ec7e1fc6c549b68b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 598d6a5005c06d15e74ae621fa93e08dfe5884ff 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b3b3190d0ea0a8b8e00a9378892f23d3af362b61 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b6d5842c64f903b63cd61b79212e9c4b2a21a9a5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5574d3c433b99861ca3452a60be6cff1060e6a0b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f5ba975e5d1f9d862bb1d43621da76c14b6403e8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 482c1feb328e8e83edf55f6053fc592b09a8b9cd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 418d5768ee3a81f5f6c711e967f5bb2aa70ed2f6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7298334213411e218b7bc15aa99dcaa80e0910ca 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 61362e7b629e018ccf41293eca0b6fe382162e09 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 81a19667ebd56b604c7a158702b270b517fdf0a4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 92e25e207bddd35facb23ee9d15ded30b753aca0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3cdaa69bca7bcc1fae6013095a979a95708dfba4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cfbb662982caf238e9006810045d70e1285e33b6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7521a1572358a038a77bdc3d4c4c19c8e6f0549c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d7cf0ba471fbc278aad355b829b442c77aad7368 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cf7d262d74679cd874d143a245a7bf3337383dc5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 176d1f794a0779a5c22f786828602ac4d8063a65 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 37395b3ebf25b2680159526750f9e370b8ed134f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8ec31edb38353f95bb9cac14a73d7ee861d22390 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0afcfd4c3c7edd3c039057cb0ea1d8c58bee6c62 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b584759103b9fa3d1b2688394bfee2ccb7246094 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cdec5738cfc2c09b51c44d0958222c63b64f5606 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 56e217b077abb33045bfb2dc88dbb0f3de3f8eac 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c2191a3fb10641d904107c54e08e34778b4abc01 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* efaec62cd396d3f71a4a522b3a7ae4948e335a12 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4ef3f49b137ddda009973360e7b5118b535ed919 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 33ec4a7d1f46de9c0ff62c966b332689a983c008 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 41a6d7baa54df23e06b1b7513c9218d12b11cdbe 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bbecaeeaaf38425d9085d759ded5ad845e74406f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 613408509f896c68616a2df6c9c6d3fa6662f519 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e9ec616d2f10323199073af1b25a586f1619ecb9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1af4a5ecd0c05f722ca04dfd287d892e11677b5c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* eb01951c9598f15b2bf35b1c653aedbe87c49f73 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f02def8ae7d7fcff0deb967f2f11d88e9d099b4d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b7fd0d56b2e89e4624ccd81f1dd50a2cc8c2e091 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0db83dd13da549be953a316ba32826d974eed8c2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f69b35c8cff251e82fa33b82acb48c60ea05bf3f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ea75b9f21956341d325fdce4e38b6db41603f09c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 245a86c80a2b2e1515a80a217aa7afdc139a8d9c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cf0fc461ac82eb8e9e2ca121a3cb1865533283aa 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ddf1d2a883276c1575606fb9794df53fa0e0b400 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 31d58a849958079807963ee428d211200538d66e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 59b4daee8f3db173f6a170b455ed840ac80679da 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 26ded1686b33f7ed2ed5d73f95eceb740b8d4387 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f8dae005ddb31b54275ec4333adb317902a4f9f5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6d3cb50f1d58a0c816ee9c187c0408e6462f1f1c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6bbb4283c62e81f7f2788d468bbaa88685a3306b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4257362a3e91d29a34ccf1d27f5e5d645c468cd7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 131f3254e45c071d618b500e06dff22f444dcf48 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1723e19174c7ef3aa13e3b368eae0f979b26d790 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1fbeb72867dc079fc4857de481fa1220e8362853 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1f9128710b2dd8fb396ddf70c2b0cc6b36014271 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ac7ba305e8216a9aa115bf63f9358915f1306b4f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a9cd1ddef78f4bf5476bf0c46be548bcd794ca3f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7c50f4f5e5f2799f05c2137453c34cdfd3ee150c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 32b26bb692e612f1d0264b1ed3ac8a38c9dc4ac7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a423e48a52edc03febf258af9a86d383be728842 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2ef97728fcc962749f5b2f4c20e772d84f342c82 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b1987eba6fa6f5c40f987504a2fe2aaa1a34bde8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 84ae82df0065de23110e705e4d09bb06f38bc291 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a9ff271af5500fcd4b1609b964f80aad8105d4b9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4c9225a17aad6cc3e32d747b6960f4bc9697242b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3d516202c3259dbd9b4783c26072a0029e405e53 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5fe2cca931aa907e46855b5f6a1c5863b6b2e762 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* abd97578e14c62c3e7127b70c46153cbd9421ed6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ee04eb7c0f1896611fec9f054c82be45b8246a21 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3b4b717030770423ddb1f9da18e454203d318cc0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0b7c72d9a5b2dda6ceeefbbe23220d19a5076414 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* af1e59d8cb5d8eaaebe73a11132cdadad902886f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 71439735b8e1e17d8bfa5c0ee91aa539c949c6bc 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 317101e03b8fe642589498f4bdb84541ab466062 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a310095a5e5a0b085b5685300a9d1b757b839cf2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 28849f5da2a875129ba4cdfd46c6ddb1f750a9a7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3305a76b4be86519655b5d27ea24627b3239c9c6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2ddb42eb26d6b19c66fcb75a626931bf5f4eb46f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 32c69eda9af620dc6c3cf293546bd78226fcaf2e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c27611fffa3c59dddacc0152ce767207b3773a27 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 145618fb84bf448299e91704c3b82a851bbe87e0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6eaf5d4cadaf5fcbca4b9e63cbe8c9f2de95917c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0925f99493413ffa512b2f9278d12f0f81d43797 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 40e5c1a4dc420d35698d8d90c1daa6dc2eaff7ab 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5331019bef5906209c7661f24adefca29e2fe9b4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 210f14e753c80064ec1bcae72f1d654dd9b0e687 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0e6e9ad98496ace25ae4b4c3bf1754f718061548 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3a829c5bde408289cddac5ce23e1dc687cfbb154 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c78f9148873c2e0f8e6dd9248fe3d0c4d93015f5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 853424a7406f3c85d8090d697f6964cd5e9ed674 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b2b01e6c5ca5e9d11c58a4bd7517c106affd53a2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 593a08c8b28f8dca79230774c2b5158549976303 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ad7ed8aa8e3acfe3d0e2efa8a0592c1119a54133 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 26aaa4f2533324ffee8f70e2aecffaf44a7a1d4e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a06a278fb2599177b063bfc56e1abf0591a2fde9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7100c159f552c6262c82954e3626ffeabeb4f421 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c57c5efb6380da3ebc408113f836aa079d3638ee 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7f0357977e3bfcf7484ca7032dbb0146d48f3a14 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0cb2c4eca79737ef1cdc0d00899a519b5b630551 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* db3ebdaab2b66808a2063c4e732003606fdf8adc 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c0058d9452a45c5192c2b47cf80d71e75c191d78 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8ebeb45c666f938fec64fb7d0b5b7259c6cbe608 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1730741cc09f9724a53bb23476ab0aa55828a249 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cc88e9cc38c44719aebcf16f4333fab0e75ab60c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* da549f59cd2113ac8d1ca133e80ecedfeb4db6bb 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a24c4d8320cf5aecfc3797a91f3b9c76a5a7233e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 83d8d49757658d7a742526ba5c72250244fd57d9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2cea69eb79fe358f63223e4a83058981461bbc77 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 73118049bb38a2f854c8c1280188a96839a1663e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1729091101160b4f1a78b60f86e2a8f9eb7cc9e3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 42599e7dfd21a23dc24f18dc650066cd69a6a9cd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7eb8c94a909187eb08219891742e8eb60bc265fc 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d073cb1f08ba1cc193331e6b0778b25be3397fc9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4a64f9a844e0af8cab93cda2f1b03bde083008f9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 74d10cab153ab6d08f9b8c1cfa7f0a3ba78ae15e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 27d8e086071759e4a642195e06fd952da6de2928 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ec59d8731a967cb33044ba90d725565cf85cc07f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 635bccec53fd04a22367ed387295f2ef38a50b79 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5ff8eb6998ac8fa894f8db5a4f32e2e4fbed72f9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4394a3bc03e92c2a7c99f6584b86de85e41a96f1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* fed2736a78077642a73256045c6e67985a2a8522 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1d95d26d7c15aaadded91503dd461a034aae16d4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9d5437bc483e714468f9fa343e34ea098c40fb6d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3900059f7cbd83537475837d9e4a2243853cbb40 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e22f91d9229bee5cac23b11a212315ad07088927 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e9ef0092c9e97a415e677e0568f57a76100516bf 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e12248c0e1996e5c2f1c618fbb47da4f6cb3ff43 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 49bf1a22fd3a906c6dde59819a404095b64834dc 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* aeb2fe1c274be79898e4a0ce1345cd2d260c48ad 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* aa5cce1e6c279364fba5a63d94d48835214dec3d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 313c0031bbeded98d1bdbf2ed8ff785e73615d38 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* da2c748aad55c1a1dcba00b60fe4a35017bc6540 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 591cea1b074b56367f25ec0084a0c4ab66b42a7e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e9fa12b965897f485f8ec143e048a424b52f689a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 874ffe5839095bd9b181eb465ec20fb473cda7c5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* febccbdc19e0fc80761797dc52811dc056c0a39e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 62e38a72a41d072f70ad78baa402d5ca4bb13022 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 460c375ff6889a2ad7eeffbdc246eedf767b6136 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* af24e419f48e543ced58dbca99ab39dc48ab6b11 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 486dda64980163cf4ab642b00514fcca169dd148 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c94044ef540811ec9546606c8f641ce8c7fb7495 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cc5f7693398ffd9844316ef7c16233f106bee8d7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8e50188e183222ffeb44f9729dde056a33dded38 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 953756fcbc88d5c17943faee243bd3efe9a5815c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0ec476f32193c33d024ec99d26e2cea0d74168db 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d746d4625b2cb306f2c7427c5c97efc6d25fcb34 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f69ab255a58896e066a754aeea1e4381c4e858b0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 7f89241892e2385aa1442c2afed93a5542018b45 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1f3eef621985b2dd0e7e2d3ac265c0f0ba6b0a78 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 841eaea8885f568c5f195f22ff3c8738769f2fe6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 704d27e3eac9b0ae42e600559f763cacd9a55959 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9766128dbc8b3b47f154d1112e063183df0f5021 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 619713b3aa1bbaaca174387537a9b089f728f9b9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f90201b532678aeadd5933ad31893a5c720ef0a7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* db5c90a92b6a3218f340af2847934c6549645082 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5ec48f1be265535c91d4044048bb363fb17893bd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cf0af730bf0023484db82d4cc337d2f68541cb6e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 60837d59cfad2b8adaba1aaf7b459171e4588792 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 14ed3374e4e70ffdbdfef740a6272e92e4a7300c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d8aba486f57f013a337f4a48774f161b7cc594ee 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cd2ab490341f080607792ec86b6c4e61cfb760d3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ba8dbd364dd18a16396bf10f722a156241fffcf6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3c616d0c3b519cff42ee675fa113bd86c05a77a0 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4f85a1b7fd2a6dda80872e28ccd1c426ece01b36 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8f2ee50841e216cd8c192eeb68953118190f105c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* be39d4eac8653672120843ad9bc715bad8fca05c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* fdd54cf0d1643a1060147159e1d9ced46402a1a1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 83a79230dc2a0e30ed0dfc03121a0ae2f9ee6de6 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5483e909b32272ee294e3518036479498af37902 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 257869d3061d5a8ed1529bf34225d90a2416d51a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9e9513b11fff182b08cf8cd6c9c2272460636586 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d200ddcafb7f7336b0ae0353ea1504153876d6f2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* fad242d35291de1898242d6737ba02b74a59a2f2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d32478ac7c6cb7fdad4dc7a8fdc54f7f171a22fe 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a8f7249c3c5aef1ed6dfa16e637f13dfde815464 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* fcb566cfb8a86cea2d3666bf65a4f177a49b1313 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a26cc7479b14cdf70433919bfc228625f1c5c6f2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 092dedada188f56c5915f74a5fd40aac093742c3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a3308ea8d0cc513727c8ad149b1132317a886c98 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 76117d7031e856687c5bd9f5f99c79b94b1316d7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9637782beb62abfc9a53a59ab762d39f6ae6fb29 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 51662dfb31fe27ffd77d375397c539f31ed5a648 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a4ab5378e4cc20b30ef94848cca5700af46449fe 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3e85179a5601bf322bff7697cfc40d5212249707 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 516bd724d2f4820c822d94af2b3b523dd0a87ae3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5db75ff7f6a7c6bb5ae534dd5c618c4e787e7ff3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 48c78d90a3625cae8716bb20091bda775378b075 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 67e2663dd470950473d6183be345f6d8b1269165 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* aed079ec655046f6a876654d7c36f388682b777f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 62f33637281ffb45ca8fa91b9411e1cd73e06dab 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 2da539ab426f347c58640a45f5428a80b4ca930f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 25ca9884a186e07efd9c6ad0c26ab53552db2f11 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4dd1f01c26e4b24e691075f1b78bcbf90f7f19b1 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6036065906a8bff074116960cd7dd39db9887b56 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* ba9506eb36a78cd081ea10ffcc543e0f1abd840b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e083dfe79605163283d635989662937bd34dbe79 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 055c65edc75d5c6e95b96d2094aadda49b44c965 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5ba80ae9a40991b8f01c69debd791ffdad5ceb2d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 969a93e25e377c6990508e8d0bc86aa526812f59 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bebca33f522165afbdd23f95118715ff6637a731 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* afd733ebdb9260f143953f817a3ce31855ba8812 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d64ae050c7936eb0a5d00144900f892fc826707c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 174c264c0a59e84e3c4d2a283e94d6981606a8be 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4703b76221f1cc7194e11e8af2b9d5959c672a55 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c59f24f76e02a8d4ecd2aea4309d185d7ed13128 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5dda3276064d871b8372501271cd62264efb8d89 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9b171118ea9a3207fe3992d56104582c5fb75be5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c51fb9761ebdbf9f6fb46300e047635b1c8afcd3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 33c8d5f15521969a09ea245c076e541d768b5070 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0ef5917b3a6742dc69b6758b3e40a7beb8b97d04 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f8cc276bb0add12debc9eddce79e2a842bac5d3e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b5e96eef9e61e53c947e3ba407d4025d71375af9 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 571cc734cd69fb846032ec77a662aa8ee4fc32cd 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6b38eb50104901e2fec80f9455a972bc3eced813 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* b1048c6b106073c8e51dd61d3104200dfbe7f807 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4d104d325b96c348d685956c6678139640c15c4b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f83f03f59b00d9ed0efc0f372c9a153bcd5dc56d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 28f7a2cba34c51ebccbc4e99acd4cb7cbe07c71a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 541214b84dea7f2a94e5a5ca9506c4a0aadcda28 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 26112958664f295cd0ffa28936fc940aa001acda 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4a329372d9257c59929624e8d05d67124d3b84a3 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f192f08d8f7b27bbb61ad905764255c83d684cdf 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0a70a726ce116e2d51c65a2b7282ca2134325759 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 0ecfa6323d4bcc400f1a32c6693ef11691c26448 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 77eaea773f952715656a1734e24f274d41e751a7 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 18d883be86974f1836675db59f277861de57c750 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4e74daad2f74bd836eec04ff2f4202bee973107c 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 24a12d6f9278a4ab2928475e8ce06a0f59ae73d4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 59c181fb6e48f7c58e7fe12a1dda10d2183d0e2b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cc02f9afca03970a97c2ddea6d36df3b864e7e18 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e4326a39e36a0b72b15adc95c91671e6a6dcce66 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1441c354eec2ab2d25319e1d96b33ef347123f47 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3007f0538458a697749520100901aa80885aeae2 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* de3a4883c3197a29abafab5e87a0a8bead421fbe 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 84355587cb2a1f86e6882de83514a32db2646e7a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e9b9c9c34e06822ca1b643d0fdccea3efb4d2899 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* cf064b91ac6d89d5cc6014365d3c0d4753cd6378 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 1d74c6cc47df5cbf75b0b71151c80262459af8d8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6cb237c5f1c11c10377ab406385617ee783707ad 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 53eb5671945452618ad3288e6f939717b8b446ef 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 685341c6544a6ed2ce488028184430456ca07005 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9f916c1709074435f18c6bdc6ee968078cb9cd43 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 9de5f9213b29c2469410048b94f783697734d56e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a63b2761ba0b97575c4f964ffc0833afb4d39986 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 4ac10c837490a0d2b68e589643b9c0e36f539297 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* e23bb022327c32995eb415991e7fefb5b26a6ec4 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d5fe075a50ab3ce4f41614d66ed77f152fda134f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* d67def6716c4ec2a650e7a8d18264fa22aff2750 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 11886177fe1b02839bd4f6d5f2ac12e03d651a7d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* beb62aaaad0c1701fbce6f3f21777837a424d737 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6fd77606f4ead7b81d4aa948a88b4064ebd50b97 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 3de9c413664d81786e789a379e9d8ce9b31402d5 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* bbfae30d95cf72ec632a5914265c3f4ab50a9ec8 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 210dc96d7d514abb943a8b314603397c5577ffff 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 14c162416ebe76d1eb4d542af75f876fc2237c1d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 07d7b63f09b6685278032e189e9f6d52dccb0939 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8c702cea303c5efd4348d1b6cad58a5a9b545121 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 63f31133a69fb15322546633a334864d6636834d 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 23187bb333d50fdeddba88a9eb769f4cc6df1127 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f3139b7e8104dbe3da75ab238b86695c75525a3f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* f451a2bb3874ca0a286a5f09cd15f7fe6470ddda 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 70cdad0e95bb2366a9b2ae9789c0e377fef6e3ef 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* c65070f9cd162346bb724d7bf4f2fa20cde8f1cf 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* a22a7abf2f6d6dda8e2ad2e41aeaeff9d7b2d02b 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 19bccaf083d56fce700beb7550414231e562b4ea 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6f45838f3cca3ec8f25b0ee62d1d4259b0e85c2a 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 321873914f47d759dce3fc87bb18f77f8455bf78 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 6e612b12d7fee334726651eb156c57151c64ab7f 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* dfe8b4600c2f80fddba6835b21a6091939882a5e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 8d0ae097adba4f90a2fad325abc5585ad8b9966e 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) +hyperpolymath/standards/.github/workflows/* 5b5dab9fb89c0ab7187ddc334d8995bd24392e41 75bd5994470661a099df562d6d5ad2fe744dbd66 Window B: self-SHA lock records (invalid lockfile pin) diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh new file mode 100755 index 00000000..935ab6ae --- /dev/null +++ b/scripts/tests/fix-workflow-pins.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Contracts of the three workflow-pin fix scripts, each against a planted +# defect, a planted non-defect that must be left alone, and a second run that +# must change nothing. Offline: no network, no gh. Run by rust.yml +# (dispatch-contracts) and `just test`. +set -euo pipefail +fleet="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fx="$(mktemp -d)" +trap 'rm -rf "$fx"' EXIT +pass=0; fail=0 +ok() { pass=$((pass + 1)); echo " ok $1"; } +bad() { fail=$((fail + 1)); echo " FAIL $1" >&2; } +check(){ if eval "$2"; then ok "$1"; else bad "$1"; fi; } + +# ── 1. fix-rust-toolchain-input.sh ─────────────────────────────────────────── +mkdir -p "$fx/tc/.github/workflows" +cat > "$fx/tc/.github/workflows/rust.yml" <<'YML' +name: Rust +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable + with: + components: clippy, rustfmt + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 + with: + toolchain: 1.85.0 + - run: cargo check --locked +YML +cp "$fx/tc/.github/workflows/rust.yml" "$fx/tc-before.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc" >/dev/null 2>&1 +check "toolchain: inserted as first key of an existing with: block" \ + "grep -A2 '6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' '$fx/tc/.github/workflows/rust.yml' | grep -q '^ toolchain: stable$'" +check "toolchain: inserted with a new with: block under a bare uses:" \ + "grep -A2 'e2a55d2ffb04f378e9626c28d38b36d230d1e12f' '$fx/tc/.github/workflows/rust.yml' | grep -q '^ with:$' && grep -A2 'e2a55d2ffb04f378e9626c28d38b36d230d1e12f' '$fx/tc/.github/workflows/rust.yml' | grep -q '^ toolchain: stable$'" +check "a tag-pinned step is untouched" \ + "grep -A2 'rust-toolchain@stable' '$fx/tc/.github/workflows/rust.yml' | grep -vq 'toolchain: stable$' " +check "a step that already names a toolchain keeps it, once" \ + "[ \$(grep -c 'toolchain: 1.85.0' '$fx/tc/.github/workflows/rust.yml') -eq 1 ] && ! grep -B1 'toolchain: 1.85.0' '$fx/tc/.github/workflows/rust.yml' | grep -q 'toolchain: stable'" +check "the checkout step and the run step are untouched" \ + "grep -q 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1' '$fx/tc/.github/workflows/rust.yml' && grep -q 'cargo check --locked' '$fx/tc/.github/workflows/rust.yml'" +check "exactly two steps were fixed" \ + "[ \$(grep -c 'toolchain: stable' '$fx/tc/.github/workflows/rust.yml') -eq 2 ]" +cp "$fx/tc/.github/workflows/rust.yml" "$fx/tc-after1.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc" >/dev/null 2>&1 +check "second run is a no-op" "cmp -s '$fx/tc-after1.yml' '$fx/tc/.github/workflows/rust.yml'" +check "the fixed file still parses as a step list (actionlint-free structural probe)" \ + "! grep -nE '^\s*with:\s*$' '$fx/tc/.github/workflows/rust.yml' | awk -F: '{print \$1}' | while read -r n; do sed -n \"\$((n+1))p\" '$fx/tc/.github/workflows/rust.yml' | grep -qvE '^\s+[a-z-]+:' && echo bad; done | grep -q bad" +# Planted control: a tree with no SHA-pinned dtolnay step is left byte-identical. +mkdir -p "$fx/tc0/.github/workflows"; printf 'name: x\njobs:\n a:\n steps:\n - uses: dtolnay/rust-toolchain@stable\n' > "$fx/tc0/.github/workflows/x.yml" +cp "$fx/tc0/.github/workflows/x.yml" "$fx/tc0-before.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc0" >/dev/null 2>&1 +check "control: a tree without the defect is untouched" "cmp -s '$fx/tc0-before.yml' '$fx/tc0/.github/workflows/x.yml'" + +# ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── +STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) +STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) +GOOD=75bd5994470661a099df562d6d5ad2fe744dbd66 +mkdir -p "$fx/sp/.github/workflows" +printf 'jobs:\n rust-ci:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp/.github/workflows/rust-ci.yml" +printf 'jobs:\n governance:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STALE_B" > "$fx/sp/.github/workflows/governance.yml" +printf 'jobs:\n mirror:\n uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@%s\n' "$STALE_A" > "$fx/sp/.github/workflows/mirror.yml" +printf 'jobs:\n audit:\n uses: hyperpolymath/cicd-suite/.github/workflows/main-estate-audit.yml@%s\n' "$STALE_B" > "$fx/sp/.github/workflows/audit.yml" +cat > "$fx/sp/.github/workflows/actions.lock" <&1) +check "Window A pin of rust-ci-reusable is moved to the replacement" "grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/sp/.github/workflows/rust-ci.yml'" +check "Window B pin of any standards reusable is moved (wildcard row)" "grep -q \"governance-reusable.yml@$GOOD\" '$fx/sp/.github/workflows/governance.yml'" +check "a Window-A SHA on a reusable the window does not cover is left alone" "grep -q \"mirror-reusable.yml@$STALE_A\" '$fx/sp/.github/workflows/mirror.yml'" +check "a cicd-suite pin that happens to share the SHA is not a standards pin and is untouched" "grep -q \"main-estate-audit.yml@$STALE_B\" '$fx/sp/.github/workflows/audit.yml'" +check "the lock's workflows entry and record follow the pin" "[ \$(grep -c \"$GOOD\" '$fx/sp/.github/workflows/actions.lock') -eq 4 ] && ! grep -q \"$STALE_B\" '$fx/sp/.github/workflows/actions.lock'" +check "touching the lock is said out loud" "grep -q 'nested uses: list was NOT recomputed' <<<\"\$out\"" +tar -C "$fx/sp" -cf "$fx/sp1.tar" .github +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp" >/dev/null 2>&1 +tar -C "$fx/sp" -cf "$fx/sp2.tar" .github +check "second run is a no-op" "cmp -s '$fx/sp1.tar' '$fx/sp2.tar'" +# Planted control: an empty table rewrites nothing. +printf '# empty\n' > "$fx/empty.tsv" +mkdir -p "$fx/sp0/.github/workflows"; printf 'jobs:\n rust-ci:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp0/.github/workflows/rust-ci.yml" +PINS_TABLE="$fx/empty.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp0" >/dev/null 2>&1 +check "control: with no table rows nothing is rewritten" "grep -q \"@$STALE_A\" '$fx/sp0/.github/workflows/rust-ci.yml'" +check "the shipped table names both windows' edges and never lists the replacement as stale" \ + "grep -q \"rust-ci-reusable.yml.$STALE_A\" '$fleet/scripts/lib/stale-reusable-pins.tsv' && grep -q \"workflows/\\*.$STALE_B\" '$fleet/scripts/lib/stale-reusable-pins.tsv' && ! awk -F'\\t' -v g=\"$GOOD\" '\$2 == g' '$fleet/scripts/lib/stale-reusable-pins.tsv' | grep -q ." +# Two callers at two different dead SHAs that move to the SAME replacement must +# leave ONE record for it in the lock, with the union of both nested lists. +mkdir -p "$fx/sp2/.github/workflows" +printf 'jobs:\n governance:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STALE_B" > "$fx/sp2/.github/workflows/governance.yml" +STALE_C=da2c748aad55c1a1dcba00b60fe4a35017bc6540 +printf 'jobs:\n scorecard:\n uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@%s\n' "$STALE_C" > "$fx/sp2/.github/workflows/scorecard.yml" +cat > "$fx/sp2/.github/workflows/actions.lock" </dev/null 2>&1 +check "two dead SHAs moving to one replacement leave ONE record for it" "[ \$(grep -c \"^ 'hyperpolymath/standards@$GOOD':\" '$fx/sp2/.github/workflows/actions.lock') -eq 1 ]" +check "the merged record carries the union of both nested lists, once each" \ + "[ \$(grep -c \" - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\" '$fx/sp2/.github/workflows/actions.lock') -eq 1 ] && grep -q 'erlef/setup-beam' '$fx/sp2/.github/workflows/actions.lock' && grep -q 'ossf/scorecard-action' '$fx/sp2/.github/workflows/actions.lock'" +check "both workflows: entries point at the replacement" "[ \$(grep -c \" - 'hyperpolymath/standards@$GOOD'\" '$fx/sp2/.github/workflows/actions.lock') -eq 2 ]" + +# ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── +mkdir -p "$fx/ls/.github/workflows" +cat > "$fx/ls/.github/workflows/actions.lock" <<'LOCK' +version: 'v0.0.3' +workflows: + '.github/workflows/ci.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' +dependencies: + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: '3d3c42e5aac5ba805825da76410c181273ba90b1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597': + ref: '427eb35c40552d912d8c757518e8326f0d20f597' + commit: 'sha1-427eb35c40552d912d8c757518e8326f0d20f597' + owner_id: 6759885 + repo_id: 1116521501 + 'nobody/unknown-action@0000000000000000000000000000000000000001': + ref: '0000000000000000000000000000000000000001' + commit: 'sha1-0000000000000000000000000000000000000001' + owner_id: 1 + repo_id: 1 +LOCK +set +e +bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls" --offline > "$fx/ls.out" 2>&1; rc=$? +set -e +check "the self-SHA checkout record gets its tag from the offline map" "grep -A1 \"'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':\" '$fx/ls/.github/workflows/actions.lock' | grep -q \"ref: 'v7.0.1'\"" +check "a tag-ref record is untouched" "[ \$(grep -c \"ref: 'v7.0.1'\" '$fx/ls/.github/workflows/actions.lock') -eq 2 ]" +check "a reusable-source record keeps its SHA ref (standards' accepted lock does the same)" "grep -A1 \"'hyperpolymath/standards@427eb35c\" '$fx/ls/.github/workflows/actions.lock' | grep -q \"ref: '427eb35c40552d912d8c757518e8326f0d20f597'\"" +check "an unresolvable record is left alone and reported, and the exit code says so" "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED nobody/unknown-action' '$fx/ls.out' && grep -A1 \"'nobody/unknown-action@\" '$fx/ls/.github/workflows/actions.lock' | grep -q \"ref: '0000000000000000000000000000000000000001'\"" +check "commit: lines and ids are untouched" "[ \$(grep -c \"commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'\" '$fx/ls/.github/workflows/actions.lock') -eq 2 ] && grep -q 'repo_id: 197814629' '$fx/ls/.github/workflows/actions.lock'" +sed -i '/nobody\/unknown-action/,/repo_id: 1$/d' "$fx/ls/.github/workflows/actions.lock" +cp "$fx/ls/.github/workflows/actions.lock" "$fx/ls1.lock" +bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls" --offline >/dev/null 2>&1 +check "second run is a no-op and exits 0" "cmp -s '$fx/ls1.lock' '$fx/ls/.github/workflows/actions.lock'" +check "a repository without a lock is OK, not an error" "mkdir -p '$fx/nolock' && bash '$fleet/scripts/fix-lock-self-sha-refs.sh' '$fx/nolock' --offline | grep -q 'OK (no actions.lock)'" + +echo "fix-workflow-pins: $pass passed, $fail failed" +[ "$fail" -eq 0 ] From 8b16d464be822a45449743fb93db4989654747b3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:54:16 +0100 Subject: [PATCH 02/44] Update scripts/fix-stale-reusable-pin.sh 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> --- scripts/fix-stale-reusable-pin.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 8f6c01f2..b50d835e 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -63,6 +63,7 @@ merge_duplicate_records() { for (u in uses_of) if (index(u, cur SUBSEP) == 1) n++ if (n > 0) { print " uses:" + fflush() cmd = "sort" for (u in uses_of) if (index(u, cur SUBSEP) == 1) print " - " substr(u, length(cur) + 2) | cmd close(cmd) From 7de465214854d8aa1d9df3aff97b4574bc877f57 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:54:43 +0100 Subject: [PATCH 03/44] Update scripts/fix-stale-reusable-pin.sh 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> --- scripts/fix-stale-reusable-pin.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index b50d835e..eb2086aa 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -74,12 +74,12 @@ merge_duplicate_records() { !indep { print; next } /^ '"'"'[^'"'"']+'"'"':$/ { key = $0 - if (!(key in seen)) { seen[key] = 1; order[++norder] = key; nhead[key] = 0 } + if (!(key in seen)) { seen[key] = 1; order[++norder] = key; nhead[key] = 0; first = 1 } else { first = 0 } cur = key; next } /^ uses:[[:space:]]*$/ { next } /^ - '"'"'[^'"'"']+'"'"'$/ { u = $0; sub(/^ - /, "", u); uses_of[cur SUBSEP u] = 1; next } - /^ [a-z_]+: / { if (!(cur in done_head)) { head[cur, ++nhead[cur]] = $0 } ; next } + /^ [a-z_]+: / { if (first) { head[cur, ++nhead[cur]] = $0 } ; next } { next } END { for (k = 1; k <= norder; k++) { From 1be55f2de61cd267b28446cda5afbeefd02b0a39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:32:49 +0000 Subject: [PATCH 04/44] fix(pins): review fixes for the three repair scripts; declared secrets; gitleaks ignores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repair scripts (CodeRabbit, Codex): * fix-rust-toolchain-input.sh inspects each step whole — from its opening `- ` line, so a `with:` above `uses:` is found — and only the `with:` mapping decides: `env.toolchain` no longer suppresses the input. A `with: # comment` is reused, a flow `with: { … }` gets `toolchain` as its first entry, and a quoted `"uses"` key is recognised. * fix-lock-self-sha-refs.sh parses with POSIX awk (mawk is Debian's default awk: no match() capture array, no interval expressions) and parses before it loops, so a parser failure exits 2 with the lock untouched instead of reporting OK. * fix-stale-reusable-pin.sh edits the lock per rewritten caller: only that workflow's `workflows:` edge moves; the stale dependency record is renamed when nothing references it any more and copied under the replacement key while another caller still pins the stale SHA. merge_duplicate_records emits a merged record's header once (the owner's fflush/first fixes, now asserted). Quoted `"uses"` keys are rewritten too. * fix-script-registry.json no longer routes WF016 / orphan-pin findings to the table-only fixer, which cannot choose a replacement for an arbitrary orphan SHA and exited 0 without fixing anything. * scripts/tests/fix-workflow-pins.sh: 19 new contracts — the review cases above, a partial migration where two callers share a stale SHA, the one-header merge, a mawk run when mawk is installed, a 39-hex key, and a parser failure. 47 pass under gawk and under mawk. Workflows: hypatia-scan passes HYPATIA_SCAN_PAT by name, mirror the seven forge keys mirror-reusable declares, secret-scanner none (WH008 ×3). .gitleaksignore: six fingerprints of two documented placeholders (the seambot `ghs_xxx…` test token and a deleted sustainabot setup doc's `-----BEGIN PRIVATE KEY-----` template) that the scanner reports on every run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .github/workflows/hypatia-scan.yml | 4 +- .github/workflows/mirror.yml | 11 ++- .github/workflows/secret-scanner.yml | 2 +- .gitleaksignore | 10 +++ scripts/fix-lock-self-sha-refs.sh | 27 ++++-- scripts/fix-rust-toolchain-input.sh | 100 +++++++++++++-------- scripts/fix-script-registry.json | 2 - scripts/fix-stale-reusable-pin.sh | 71 +++++++++++---- scripts/tests/fix-workflow-pins.sh | 124 +++++++++++++++++++++++++++ 9 files changed, 290 insertions(+), 61 deletions(-) create mode 100644 .gitleaksignore diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index b5c18417..ac8947d5 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -32,7 +32,9 @@ permissions: jobs: hypatia: uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 - secrets: inherit + # hypatia-scan-reusable declares one secret; pass that one, not everything. + secrets: + HYPATIA_SCAN_PAT: ${{ secrets.HYPATIA_SCAN_PAT }} # Total caller-side wall-clock cap for the reusable. Matches # Hypatia's `missing_timeout_minutes` rule expectation. The scan is # typically ~3 min; the cap leaves headroom for slow estate builds. diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index f35712ec..93ef2652 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -16,7 +16,16 @@ permissions: jobs: mirror: uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 - secrets: inherit + # mirror-reusable declares exactly these seven forge keys and forbids + # `secrets: inherit`; pass them by name. + secrets: + GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} + BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} + CODEBERG_SSH_KEY: ${{ secrets.CODEBERG_SSH_KEY }} + SOURCEHUT_SSH_KEY: ${{ secrets.SOURCEHUT_SSH_KEY }} + DISROOT_SSH_KEY: ${{ secrets.DISROOT_SSH_KEY }} + GITEA_SSH_KEY: ${{ secrets.GITEA_SSH_KEY }} + RADICLE_KEY: ${{ secrets.RADICLE_KEY }} permissions: actions: read contents: read diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 23a4b0d6..dbac2872 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -25,4 +25,4 @@ jobs: pull-requests: write actions: read uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 - secrets: inherit + # secret-scanner-reusable declares no caller secret, so none is passed. diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..b2e082ec --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MPL-2.0 +# gitleaks fingerprints (commit:file:rule:line) of historical false positives. +# Each is a documented placeholder, not a credential; the sustainabot file no +# longer exists (deleted in ded43d0) and the seambot literal is `ghs_xxx…`. +d1d8e1db885c6bf15833d413cdae42a5710d7ff4:bots/seambot/tests/github_integration.rs:github-app-token:154 +37db42f48f7f85f2558a57e3f6643a62f39da2bc:bots/seambot/tests/github_integration.rs:github-app-token:154 +f1b3dc853aa9c13e47fde308681c4a1e0bb58eb1:bots/seambot/tests/github_integration.rs:github-app-token:154 +d1d8e1db885c6bf15833d413cdae42a5710d7ff4:bots/sustainabot/docs/GITHUB_APP_SETUP.md:private-key:89 +37db42f48f7f85f2558a57e3f6643a62f39da2bc:bots/sustainabot/docs/GITHUB_APP_SETUP.md:private-key:89 +f1b3dc853aa9c13e47fde308681c4a1e0bb58eb1:bots/sustainabot/docs/GITHUB_APP_SETUP.md:private-key:89 diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 3d6d9859..f494130b 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -25,7 +25,8 @@ # left alone — a wrong tag is worse than a known-bad one. # # Idempotent. Exit 0 whether or not anything changed; exit 1 if a self-SHA -# record remains unresolved (so a caller can tell "fixed" from "still dead"). +# record remains unresolved (so a caller can tell "fixed" from "still dead"); +# exit 2, lock untouched, if the lock cannot be parsed. # # Usage: # fix-lock-self-sha-refs.sh [--offline] @@ -57,6 +58,23 @@ lookup_tag() { # fixed=0; unresolved=0 tmp=$(mktemp); cp "$LOCK" "$tmp" +# Parse first, loop second: a parser that dies inside a process substitution +# would look like "no records" and the script would report OK. POSIX awk only +# (Debian/Ubuntu's default awk is mawk: no match() capture array, no interval +# expressions), so the SHA is checked for content and length separately. +records=$(mktemp) +if ! awk ' + /^ '"'"'[^'"'"'@]+@[0-9a-f]+'"'"':$/ { + s = $0; sub(/^ '"'"'/, "", s); sub(/'"'"':$/, "", s) + i = index(s, "@"); repo = substr(s, 1, i - 1); sha = substr(s, i + 1) + if (length(sha) == 40) { key_line = NR; key_repo = repo; key_sha = sha; next } + } + key_line && NR == key_line + 1 && $0 == " ref: '"'"'" key_sha "'"'"'" { print key_line "\t" key_repo "\t" key_sha } + { key_line = 0 } +' "$LOCK" > "$records"; then + echo "fix-lock-self-sha-refs: could not parse $LOCK (awk failed); lock left unchanged" >&2 + rm -f "$tmp" "$records"; exit 2 +fi while IFS=$'\t' read -r lineno repo sha; do case "$repo" in hyperpolymath/standards|hyperpolymath/cicd-suite) continue ;; esac tag=$(lookup_tag "$repo" "$sha") @@ -68,11 +86,8 @@ while IFS=$'\t' read -r lineno repo sha; do sed -i "$((lineno + 1))s|ref: '$sha'|ref: '$tag'|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) -done < <(awk ' - match($0, /^ '"'"'([^'"'"'@]+)@([0-9a-f]{40})'"'"':$/, m) { key_line = NR; key_repo = m[1]; key_sha = m[2]; next } - key_line && NR == key_line + 1 && $0 ~ "^ ref: '"'"'" key_sha "'"'"'$" { print key_line "\t" key_repo "\t" key_sha } - { key_line = 0 } -' "$LOCK") +done < "$records" +rm -f "$records" if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; else rm -f "$tmp"; fi if [ "$fixed" -eq 0 ] && [ "$unresolved" -eq 0 ]; then echo "OK (no self-SHA action records): $LOCK"; fi diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index c0e1d0a1..2d88c20b 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -13,13 +13,19 @@ # rust-ci-reusable.yml carried the defect from d73c93e0 to 49ed4715 # (fixed by standards#439, 9fe0d8ff). # -# What it does, per workflow file: -# * a step whose `uses:` is dtolnay/rust-toolchain@<40-hex> and whose -# `with:` block has no `toolchain:` key gets `toolchain: ` -# (default `stable`) as the first key of that block; -# * a step with no `with:` block at all gets one, directly under `uses:`; -# * a tag-pinned step (`@stable`, `@1.85.0`) and a step that already -# names a toolchain are left alone. +# What it does, per workflow file, for every list item that holds a step +# whose `uses:` is dtolnay/rust-toolchain@<40-hex>: +# * the step is inspected whole — from its opening `- ` line, so a `with:` +# written above `uses:` counts (YAML mapping keys carry no order); +# * only the step's `with:` mapping decides: `toolchain:` under `env:` or any +# other key is not an action input and does not count; +# * a block `with:` (with or without a trailing comment) that names no +# `toolchain:` gets `toolchain: ` (default `stable`) as its +# first key, at the indentation its other keys use; +# * a flow `with: { ... }` without `toolchain` gets it as its first entry; +# * a step with no `with:` at all gets one, directly under `uses:`; +# * a tag-pinned step (`@stable`, `@1.85.0`) and a step that already names a +# toolchain are left alone. # Idempotent: a second run changes nothing. # # Usage: @@ -38,52 +44,76 @@ TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" apply_to_file() { local f="$1" tmp [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } - grep -qE 'uses:[[:space:]]*["'"'"']?dtolnay/rust-toolchain@[0-9a-f]{40}' "$f" || return 0 + grep -qE '["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?dtolnay/rust-toolchain@[0-9a-f]{40}' "$f" || return 0 tmp=$(mktemp) awk -v toolchain="$TOOLCHAIN" ' - # Emit the buffered step, patched if it needs it. - function flush( i, has_with, has_tc, with_indent, out) { + function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } + # Column of a key, with a list dash counted as indentation: ` - with:` -> 4. + function key_indent_of(s, t) { t = s; sub(/[A-Za-z"'"'"'].*$/, "", t); gsub(/-/, " ", t); return t } + function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } + function spaces(n, t) { t = ""; while (n-- > 0) t = t " "; return t } + # Emit the buffered list item, patched if it is a SHA-pinned dtolnay step + # whose `with:` names no toolchain. + function flush( i, line, ind, uses_i, has_with, with_i, with_flow, with_indent, in_with, child_indent, has_tc, key_indent) { if (n == 0) return - has_with = 0; has_tc = 0; with_indent = "" + uses_i = 0 + for (i = 1; i <= n; i++) if (buf[i] ~ USES_RE) { uses_i = i; break } + if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } + has_with = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; child_indent = -1; has_tc = 0 for (i = 1; i <= n; i++) { - if (buf[i] ~ /^[[:space:]]*with:[[:space:]]*$/) { has_with = 1; with_indent = buf[i]; sub(/with:.*$/, "", with_indent) } - if (buf[i] ~ /^[[:space:]]*toolchain:[[:space:]]*[^[:space:]]/) has_tc = 1 + line = buf[i]; ind = indent_of(line) + if (in_with) { + if (plain(line) && ind <= with_indent) in_with = 0 + else if (plain(line)) { + if (child_indent < 0) child_indent = ind + if (line ~ /^[[:space:]]*["'"'"']?toolchain["'"'"']?[[:space:]]*:[[:space:]]*[^[:space:]]/) has_tc = 1 + } + } + if (!in_with && !has_with && line ~ /^[[:space:]]*(-[[:space:]]+)?with:[[:space:]]*(#.*)?$/) { + has_with = 1; with_i = i; with_indent = length(key_indent_of(line)); in_with = 1 + } else if (!in_with && !has_with && line ~ /^[[:space:]]*(-[[:space:]]+)?with:[[:space:]]*\{/) { + has_with = 1; with_i = i; with_flow = 1 + if (line ~ /[{,][[:space:]]*["'"'"']?toolchain["'"'"']?[[:space:]]*:[[:space:]]*[^[:space:],}]/) has_tc = 1 + } } if (has_tc) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } for (i = 1; i <= n; i++) { - print buf[i] - if (has_with && buf[i] ~ /^[[:space:]]*with:[[:space:]]*$/) { - print with_indent " toolchain: " toolchain + line = buf[i] + if (has_with && with_flow && i == with_i) { + if (line ~ /\{[[:space:]]*\}/) sub(/\{[[:space:]]*\}/, "{ toolchain: " toolchain " }", line) + else sub(/\{[[:space:]]*/, "{ toolchain: " toolchain ", ", line) + print line; fixed++ + } else if (has_with && !with_flow && i == with_i) { + print line + print spaces(child_indent >= 0 ? child_indent : with_indent + 2) "toolchain: " toolchain fixed++ - } else if (!has_with && buf[i] ~ /^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*["'"'"']?dtolnay\/rust-toolchain@[0-9a-f]{40}/) { - # `with:` sits at the indentation of the step'"'"'s keys: the - # `uses:` column when `uses:` opened the step with `- `, else - # the `uses:` line'"'"'s own indentation. - key_indent = buf[i]; sub(/uses:.*$/, "", key_indent); gsub(/-/, " ", key_indent) + } else if (!has_with && i == uses_i) { + print line + # `with:` sits at the column of the step'"'"'s keys: the `uses:` + # column, with a list dash counted as indentation. + key_indent = key_indent_of(line) print key_indent "with:" print key_indent " toolchain: " toolchain fixed++ - } + } else print line } n = 0 } - BEGIN { n = 0; in_step = 0; fixed = 0 } + BEGIN { + n = 0; in_step = 0; fixed = 0 + USES_RE = "^[[:space:]]*(-[[:space:]]+)?[\"'"'"']?uses[\"'"'"']?[[:space:]]*:[[:space:]]*[\"'"'"']?dtolnay/rust-toolchain@[0-9a-f]+" + } { if (in_step) { - # The step ends at the next list item at the step'"'"'s own indentation - # (or shallower), or at a shallower key, or at a blank line followed - # by such a line. Comments and deeper lines belong to the step. - ind = $0; sub(/[^ ].*$/, "", ind) - if ($0 ~ /^[[:space:]]*-[[:space:]]/ && length(ind) <= length(step_indent)) { flush(); in_step = 0 } - else if ($0 !~ /^[[:space:]]*$/ && $0 !~ /^[[:space:]]*#/ && length(ind) < length(step_indent)) { flush(); in_step = 0 } - } - if (!in_step && $0 ~ /^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*["'"'"']?dtolnay\/rust-toolchain@[0-9a-f]{40}/) { - in_step = 1; step_indent = $0; sub(/[^ ].*$/, "", step_indent) - # A `- name:` line may have opened this step above; it was already - # printed, which is fine: we only ever insert below `uses:`. - buf[++n] = $0; next + # The item ends at the next list item at its own indentation (or + # shallower), or at a shallower key. Blank and comment lines and + # deeper lines belong to it. + ind = indent_of($0) + if ($0 ~ /^[[:space:]]*-([[:space:]]|$)/ && ind <= step_indent) { flush(); in_step = 0 } + else if (plain($0) && ind < step_indent) { flush(); in_step = 0 } } + if (!in_step && $0 ~ /^[[:space:]]*-([[:space:]]|$)/) { in_step = 1; step_indent = indent_of($0); buf[++n] = $0; next } if (in_step) { buf[++n] = $0; next } print } diff --git a/scripts/fix-script-registry.json b/scripts/fix-script-registry.json index cd04cd22..d9caf09d 100644 --- a/scripts/fix-script-registry.json +++ b/scripts/fix-script-registry.json @@ -119,8 +119,6 @@ "workflow_audit/sha_pinned_toolchain_without_input": "fix-rust-toolchain-input.sh", "WF027": "fix-stale-reusable-pin.sh", "workflow_audit/known_broken_reusable_revision": "fix-stale-reusable-pin.sh", - "WF016": "fix-stale-reusable-pin.sh", - "recipe-rewrite-orphan-reusable-pin": "fix-stale-reusable-pin.sh", "PIN-003": "fix-stale-reusable-pin.sh", "PI006": "fix-lock-self-sha-refs.sh", "pin_integrity/self_sha_lock_record": "fix-lock-self-sha-refs.sh" diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index eb2086aa..611d7737 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -14,13 +14,16 @@ # can be LESS usable than an older one, so each replacement in the table was # chosen by hand against that check. # -# If the repository carries .github/workflows/actions.lock and it names the -# stale SHA (a caller onboarded with its reusable's dependency record), the -# SHA is replaced there too — in the workflows: list and in the record's -# key/ref/commit — so the lock stays internally consistent. The record's -# nested `uses:` list is NOT recomputed: run `gh actions-lock` (or the -# repository's check-lock-sync.sh) afterwards; the script says so when it -# touches a lock. +# If the repository carries .github/workflows/actions.lock, the lock follows +# each rewritten CALLER, not the SHA: in `workflows:` only the rewritten +# workflow's own entry moves to the replacement. In `dependencies:` the stale +# record is renamed when no workflow references it any more, and copied under +# the replacement key (stale record kept) while another workflow still pins +# the stale SHA — a lock key is shared by every reusable of a repository, and +# a caller the table does not cover must keep its record. Records that +# collapse onto one key are merged. A record's nested `uses:` list is NOT +# recomputed: run `gh actions-lock` (or the repository's check-lock-sync.sh) +# afterwards; the script says so when it touches a lock. # # Idempotent. Rewrites nothing it does not recognise. Exit 0 whether or not # anything changed; exit 2 when the table is unreadable. @@ -83,7 +86,7 @@ merge_duplicate_records() { { next } END { for (k = 1; k <= norder; k++) { - cur = order[k]; delete done_head + cur = order[k] emit_record() } } @@ -92,6 +95,8 @@ merge_duplicate_records() { } # Every `uses:` of an estate reusable at a full SHA, across the workflows. +# The lock is edited after the loop, once every workflow rewrite is known. +edits=$(mktemp) # workflow-keystalereplacement while IFS= read -r -d '' wf; do while IFS= read -r line; do path="${line%@*}"; stale="${line##*@}" @@ -106,17 +111,53 @@ while IFS= read -r -d '' wf; do done < "$PINS_TABLE" [ -n "$replacement" ] || continue [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } - sed -i "s|\(uses:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" + sed -i "s|\(uses[\"']\?[[:space:]]*:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" echo "FIXED ${wf#"$REPO_PATH"/} $path ${stale:0:7} -> ${replacement:0:7} ($reason)" rewrites=$((rewrites + 1)) - if [ -f "$LOCK" ] && grep -q "$stale" "$LOCK"; then - sed -i "s/$stale/$replacement/g" "$LOCK" - merge_duplicate_records "$LOCK" - lock_touched=1 - fi - done < <(grep -oE "uses:[[:space:]]*[\"']?hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" "$wf" | sed -E "s/^uses:[[:space:]]*[\"']?//" | sort -u) + printf '%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" >> "$edits" + done < <(grep -oE "[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" "$wf" | sed -E "s/^[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?//" | sort -u) done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) +# Lock: move each rewritten workflow's own `workflows:` edges first. +if [ -f "$LOCK" ] && [ -s "$edits" ]; then + sort -u "$edits" -o "$edits" + while IFS=$'\t' read -r wfkey stale new; do + grep -q "$stale" "$LOCK" || continue + tmp=$(mktemp) + awk -v wfkey=" '$wfkey':" -v stale="$stale" -v new="$new" ' + /^workflows:[[:space:]]*$/ { sec = "w" } + /^dependencies:[[:space:]]*$/ { sec = "d" } + sec == "w" && /^ '"'"'/ { inwf = ($0 == wfkey) } + sec == "w" && inwf && /^ - / { i = index($0, "@" stale "'"'"'"); if (i) $0 = substr($0, 1, i) new "'"'"'" } + { print } + ' "$LOCK" > "$tmp" + if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi + done < "$edits" + # Then the records: rename a stale record nothing references any more; + # copy it under the replacement key while a workflow still pins the stale SHA. + while IFS=$'\t' read -r stale new; do + grep -q "$stale" "$LOCK" || continue + still=$(awk -v stale="$stale" ' + /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } + sec == "w" && index($0, "@" stale "'"'"'") { n++ } END { print n + 0 }' "$LOCK") + tmp=$(mktemp) + if [ "$still" -eq 0 ]; then + sed "s/$stale/$new/g" "$LOCK" > "$tmp" + else + awk -v stale="$stale" -v new="$new" ' + /^dependencies:[[:space:]]*$/ { sec = "d" } + sec == "d" && /^ '"'"'/ { inrec = (index($0, "@" stale "'"'"':") > 0) } + sec == "d" && inrec { c = $0; gsub(stale, new, c); copy[++nc] = c } + { print } + END { for (i = 1; i <= nc; i++) print copy[i] }' "$LOCK" > "$tmp" + echo "NOTE: actions.lock keeps the record for ${stale:0:7}: a workflow the table does not cover still pins it; the replacement got its own record." >&2 + fi + if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi + done < <(cut -f2,3 "$edits" | sort -u) + [ "$lock_touched" -eq 1 ] && merge_duplicate_records "$LOCK" +fi +rm -f "$edits" + if [ "$lock_touched" -eq 1 ]; then echo "WARN: actions.lock named a stale SHA and was rewritten to the replacement; its nested uses: list was NOT recomputed — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 fi diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 935ab6ae..61866f21 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -4,6 +4,7 @@ # defect, a planted non-defect that must be left alone, and a second run that # must change nothing. Offline: no network, no gh. Run by rust.yml # (dispatch-contracts) and `just test`. +# shellcheck disable=SC2034 # out/rc variables are read inside check()'s eval strings set -euo pipefail fleet="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" fx="$(mktemp -d)" @@ -60,6 +61,59 @@ cp "$fx/tc0/.github/workflows/x.yml" "$fx/tc0-before.yml" bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc0" >/dev/null 2>&1 check "control: a tree without the defect is untouched" "cmp -s '$fx/tc0-before.yml' '$fx/tc0/.github/workflows/x.yml'" + +# Review cases (gitbot-fleet#615): a `with:` with a trailing comment, a flow +# `with:`, `with:` written above `uses:`, `env.toolchain` (not an input, so it +# must not count), and a quoted key. Each step is inspected whole. +mkdir -p "$fx/tc2/.github/workflows" +cat > "$fx/tc2/.github/workflows/rust.yml" <<'YML' +jobs: + a: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: # pinned inputs + components: clippy + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f + with: { components: rustfmt } + - with: + components: llvm-tools + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 + - with: { toolchain: nightly } + uses: dtolnay/rust-toolchain@1234567890abcdef1234567890abcdef12345678 + - uses: dtolnay/rust-toolchain@abcdefabcdefabcdefabcdefabcdefabcdefabcd + env: + toolchain: beta + - env: + toolchain: beta + uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000001 + with: + components: clippy + - "uses": "dtolnay/rust-toolchain@0000000000000000000000000000000000000002" + - run: cargo test +YML +f2="$fx/tc2/.github/workflows/rust.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc2" >/dev/null 2>&1 +after() { grep -A"$2" -- "$1" "$f2"; } # the N lines after the first match +check "a with: with a trailing comment is reused: toolchain: goes under it, no second with:" \ + "after 6c977a6c 2 | sed -n '2p' | grep -q '^ with: # pinned inputs$' && after 6c977a6c 2 | sed -n '3p' | grep -q '^ toolchain: stable$'" +check "a flow with: { } gets toolchain as its first entry" \ + "grep -q '^ with: { toolchain: stable, components: rustfmt }$' '$f2'" +check "a with: written above uses: is found and extended; nothing is inserted under uses:" \ + "grep -B3 683b588c '$f2' | grep -q '^ toolchain: stable$' && after 683b588c 1 | sed -n '2p' | grep -qv '^ with:'" +check "a flow with: above uses: that names a toolchain is left alone" \ + "grep -q '^ - with: { toolchain: nightly }$' '$f2' && after 12345678 1 | sed -n '2p' | grep -qv '^ with:'" +check "env.toolchain is not an input: the step still gets with: toolchain:, env: is kept" \ + "after abcdefab 2 | sed -n '2p' | grep -q '^ with:$' && after abcdefab 2 | sed -n '3p' | grep -q '^ toolchain: stable$' && grep -A4 abcdefab '$f2' | grep -q '^ toolchain: beta$'" +check "env.toolchain above uses: does not count either; the existing with: block is extended" \ + "grep -A3 '0000000000000000000000000000000000000001' '$f2' | grep -q '^ toolchain: stable$' && grep -B3 '0000000000000000000000000000000000000001' '$f2' | grep -q '^ toolchain: beta$'" +check "a quoted \"uses\" key is recognised" \ + "after 0000000000000000000000000000000000000002 2 | sed -n '2p' | grep -q '^ with:$' && after 0000000000000000000000000000000000000002 2 | sed -n '3p' | grep -q '^ toolchain: stable$'" +check "exactly six steps were fixed, one toolchain: each" \ + "[ \$(grep -c 'toolchain: stable' '$f2') -eq 6 ] && [ \$(grep -c 'toolchain: nightly' '$f2') -eq 1 ] && grep -q 'cargo test' '$f2'" +cp "$f2" "$fx/tc2-after1.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc2" >/dev/null 2>&1 +check "second run over the review cases is a no-op" "cmp -s '$fx/tc2-after1.yml' '$f2'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -136,6 +190,49 @@ check "the merged record carries the union of both nested lists, once each" \ "[ \$(grep -c \" - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\" '$fx/sp2/.github/workflows/actions.lock') -eq 1 ] && grep -q 'erlef/setup-beam' '$fx/sp2/.github/workflows/actions.lock' && grep -q 'ossf/scorecard-action' '$fx/sp2/.github/workflows/actions.lock'" check "both workflows: entries point at the replacement" "[ \$(grep -c \" - 'hyperpolymath/standards@$GOOD'\" '$fx/sp2/.github/workflows/actions.lock') -eq 2 ]" +check "the merged record carries its header once (one ref: line)" \ + "[ \$(grep -c \" ref: '$GOOD'\" '$fx/sp2/.github/workflows/actions.lock') -eq 1 ] && [ \$(grep -c \" commit: 'sha1-$GOOD'\" '$fx/sp2/.github/workflows/actions.lock') -eq 1 ]" +# Partial migration (review): two callers share a Window-A SHA but the table +# row covers only rust-ci-reusable. The lock must follow the rewritten caller +# only: mirror's edge and the stale record stay, the replacement gets its own. +mkdir -p "$fx/sp3/.github/workflows" +printf 'jobs:\n rust-ci:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp3/.github/workflows/rust-ci.yml" +printf 'jobs:\n mirror:\n uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@%s\n' "$STALE_A" > "$fx/sp3/.github/workflows/mirror.yml" +cat > "$fx/sp3/.github/workflows/actions.lock" <&1) +l3="$fx/sp3/.github/workflows/actions.lock" +check "partial migration: the rewritten caller's lock edge moves" \ + "grep -A1 \"'.github/workflows/rust-ci.yml':\" '$l3' | grep -q \"hyperpolymath/standards@$GOOD\"" +check "partial migration: the untouched caller's lock edge stays at the stale SHA" \ + "grep -A1 \"'.github/workflows/mirror.yml':\" '$l3' | grep -q \"hyperpolymath/standards@$STALE_A\" && grep -q \"mirror-reusable.yml@$STALE_A\" '$fx/sp3/.github/workflows/mirror.yml'" +check "partial migration: both dependency records exist, each with its own ref and commit" \ + "[ \$(grep -c \"^ 'hyperpolymath/standards@\" '$l3') -eq 2 ] && grep -q \"ref: '$GOOD'\" '$l3' && grep -q \"ref: '$STALE_A'\" '$l3' && grep -q \"commit: 'sha1-$GOOD'\" '$l3'" +check "partial migration is said out loud" "grep -q 'keeps the record for' <<<\"\$out3\"" +tar -C "$fx/sp3" -cf "$fx/sp3a.tar" .github +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp3" >/dev/null 2>&1 +tar -C "$fx/sp3" -cf "$fx/sp3b.tar" .github +check "partial migration: second run is a no-op" "cmp -s '$fx/sp3a.tar' '$fx/sp3b.tar'" +# A quoted key and value are still a pin. +mkdir -p "$fx/sp4/.github/workflows" +printf 'jobs:\n governance:\n "uses": "hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s"\n' "$STALE_B" > "$fx/sp4/.github/workflows/governance.yml" +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp4" >/dev/null 2>&1 +check "a quoted \"uses\" key with a quoted value is rewritten" "grep -q \"\\\"hyperpolymath/standards/.github/workflows/governance-reusable.yml@$GOOD\\\"\" '$fx/sp4/.github/workflows/governance.yml'" + # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── mkdir -p "$fx/ls/.github/workflows" cat > "$fx/ls/.github/workflows/actions.lock" <<'LOCK' @@ -179,5 +276,32 @@ bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls" --offline >/dev/null 2> check "second run is a no-op and exits 0" "cmp -s '$fx/ls1.lock' '$fx/ls/.github/workflows/actions.lock'" check "a repository without a lock is OK, not an error" "mkdir -p '$fx/nolock' && bash '$fleet/scripts/fix-lock-self-sha-refs.sh' '$fx/nolock' --offline | grep -q 'OK (no actions.lock)'" +# Portability (review): Debian/Ubuntu's default awk is mawk. Run the same +# fixture through mawk when it is installed, and refuse a lock the parser +# cannot read instead of reporting OK. +if command -v mawk >/dev/null; then + mkdir -p "$fx/mawk-bin" "$fx/ls2/.github/workflows"; ln -sf "$(command -v mawk)" "$fx/mawk-bin/awk" + cp "$fx/ls1.lock" "$fx/ls2/.github/workflows/actions.lock" + sed -i "s/ref: 'v7.0.1'/ref: '3d3c42e5aac5ba805825da76410c181273ba90b1'/" "$fx/ls2/.github/workflows/actions.lock" + sed -i '0,/ref: .3d3c42e5aac5ba805825da76410c181273ba90b1./!{0,/ref: .3d3c42e5aac5ba805825da76410c181273ba90b1./s//ref: '"'"'v7.0.1'"'"'/}' "$fx/ls2/.github/workflows/actions.lock" + printf " 'x/thirty-nine@123456789012345678901234567890123456789':\n ref: '123456789012345678901234567890123456789'\n commit: 'sha1-123456789012345678901234567890123456789'\n" >> "$fx/ls2/.github/workflows/actions.lock" + set +e + PATH="$fx/mawk-bin:$PATH" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls2" --offline > "$fx/ls2.out" 2>&1; rc2=$? + set -e + check "under mawk the self-SHA record is repaired and the run exits 0" \ + "[ \$rc2 -eq 0 ] && grep -A1 \"'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':\" '$fx/ls2/.github/workflows/actions.lock' | grep -q \"ref: 'v7.0.1'\"" + check "a 39-hex key is not a record and is neither rewritten nor reported" \ + "grep -q \"ref: '123456789012345678901234567890123456789'\" '$fx/ls2/.github/workflows/actions.lock' && ! grep -q UNRESOLVED '$fx/ls2.out'" +else + echo " skip mawk portability (mawk not installed)" +fi +mkdir -p "$fx/noawk-bin"; printf '#!/bin/sh\nexit 1\n' > "$fx/noawk-bin/awk"; chmod +x "$fx/noawk-bin/awk" +cp "$fx/ls1.lock" "$fx/ls3.lock"; mkdir -p "$fx/ls3/.github/workflows"; cp "$fx/ls1.lock" "$fx/ls3/.github/workflows/actions.lock" +set +e +PATH="$fx/noawk-bin:$PATH" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls3" --offline > "$fx/ls3.out" 2>&1; rc3=$? +set -e +check "a parser failure exits 2, says so, and leaves the lock untouched (never OK)" \ + "[ \$rc3 -eq 2 ] && grep -q 'could not parse' '$fx/ls3.out' && ! grep -q '^OK' '$fx/ls3.out' && cmp -s '$fx/ls3.lock' '$fx/ls3/.github/workflows/actions.lock'" + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] From d66ef19951ebc2f0efbbb5c51b68041dd519c7ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:37:22 +0000 Subject: [PATCH 05/44] fix(pins): exit 3 when the stale-pin fixer touched a lock it could not finish The nested `uses:` graph of a replaced dependency record is not recomputed offline, and dispatch-runner records any exit 0 as a successful fix. A run that rewrote pins but left actions.lock needing `gh actions-lock` now exits 3 after saying so, so the outcome is recorded as a failure and routed to review rather than reported clean; a second run with nothing left to do exits 0. Contracts assert the codes on the lock-touched, partial-migration, no-lock and no-table paths. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-stale-reusable-pin.sh | 9 ++++++--- scripts/tests/fix-workflow-pins.sh | 30 +++++++++++++++++------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 611d7737..51c033fd 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -26,7 +26,9 @@ # afterwards; the script says so when it touches a lock. # # Idempotent. Rewrites nothing it does not recognise. Exit 0 whether or not -# anything changed; exit 2 when the table is unreadable. +# anything changed; exit 2 when the table is unreadable; exit 3 when the pins +# were rewritten but actions.lock was touched and still needs `gh actions-lock` +# — a dispatcher must not record that as a clean success. # # Usage: # fix-stale-reusable-pin.sh @@ -158,7 +160,8 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then fi rm -f "$edits" +if [ "$rewrites" -eq 0 ]; then echo "OK (no known-dead reusable pins): $REPO_PATH"; else echo "Rewrote $rewrites pin(s) in $REPO_PATH"; fi if [ "$lock_touched" -eq 1 ]; then - echo "WARN: actions.lock named a stale SHA and was rewritten to the replacement; its nested uses: list was NOT recomputed — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 + echo "WARN: actions.lock named a stale SHA and was rewritten to the replacement; its nested uses: list was NOT recomputed — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing. Exit 3: not a clean success." >&2 + exit 3 fi -if [ "$rewrites" -eq 0 ]; then echo "OK (no known-dead reusable pins): $REPO_PATH"; else echo "Rewrote $rewrites pin(s) in $REPO_PATH"; fi diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 61866f21..e015e7fa 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -135,22 +135,24 @@ dependencies: owner_id: 6759885 repo_id: 1116521501 LOCK -out=$(bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp" 2>&1) +set +e +out=$(bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp" 2>&1); rc_sp=$? +set -e check "Window A pin of rust-ci-reusable is moved to the replacement" "grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/sp/.github/workflows/rust-ci.yml'" check "Window B pin of any standards reusable is moved (wildcard row)" "grep -q \"governance-reusable.yml@$GOOD\" '$fx/sp/.github/workflows/governance.yml'" check "a Window-A SHA on a reusable the window does not cover is left alone" "grep -q \"mirror-reusable.yml@$STALE_A\" '$fx/sp/.github/workflows/mirror.yml'" check "a cicd-suite pin that happens to share the SHA is not a standards pin and is untouched" "grep -q \"main-estate-audit.yml@$STALE_B\" '$fx/sp/.github/workflows/audit.yml'" check "the lock's workflows entry and record follow the pin" "[ \$(grep -c \"$GOOD\" '$fx/sp/.github/workflows/actions.lock') -eq 4 ] && ! grep -q \"$STALE_B\" '$fx/sp/.github/workflows/actions.lock'" -check "touching the lock is said out loud" "grep -q 'nested uses: list was NOT recomputed' <<<\"\$out\"" +check "touching the lock is said out loud and exits 3, never a clean success" "[ \$rc_sp -eq 3 ] && grep -q 'nested uses: list was NOT recomputed' <<<\"\$out\"" tar -C "$fx/sp" -cf "$fx/sp1.tar" .github -bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp" >/dev/null 2>&1 +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp" >/dev/null 2>&1; rc_sp2=$? tar -C "$fx/sp" -cf "$fx/sp2.tar" .github -check "second run is a no-op" "cmp -s '$fx/sp1.tar' '$fx/sp2.tar'" +check "second run is a no-op and exits 0 (nothing left to say about the lock)" "[ \$rc_sp2 -eq 0 ] && cmp -s '$fx/sp1.tar' '$fx/sp2.tar'" # Planted control: an empty table rewrites nothing. printf '# empty\n' > "$fx/empty.tsv" mkdir -p "$fx/sp0/.github/workflows"; printf 'jobs:\n rust-ci:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp0/.github/workflows/rust-ci.yml" -PINS_TABLE="$fx/empty.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp0" >/dev/null 2>&1 -check "control: with no table rows nothing is rewritten" "grep -q \"@$STALE_A\" '$fx/sp0/.github/workflows/rust-ci.yml'" +PINS_TABLE="$fx/empty.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp0" >/dev/null 2>&1; rc_sp0=$? +check "control: with no table rows nothing is rewritten and the run exits 0" "[ \$rc_sp0 -eq 0 ] && grep -q \"@$STALE_A\" '$fx/sp0/.github/workflows/rust-ci.yml'" check "the shipped table names both windows' edges and never lists the replacement as stale" \ "grep -q \"rust-ci-reusable.yml.$STALE_A\" '$fleet/scripts/lib/stale-reusable-pins.tsv' && grep -q \"workflows/\\*.$STALE_B\" '$fleet/scripts/lib/stale-reusable-pins.tsv' && ! awk -F'\\t' -v g=\"$GOOD\" '\$2 == g' '$fleet/scripts/lib/stale-reusable-pins.tsv' | grep -q ." # Two callers at two different dead SHAs that move to the SAME replacement must @@ -184,7 +186,7 @@ dependencies: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' LOCK -bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp2" >/dev/null 2>&1 +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp2" >/dev/null 2>&1 || true check "two dead SHAs moving to one replacement leave ONE record for it" "[ \$(grep -c \"^ 'hyperpolymath/standards@$GOOD':\" '$fx/sp2/.github/workflows/actions.lock') -eq 1 ]" check "the merged record carries the union of both nested lists, once each" \ "[ \$(grep -c \" - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\" '$fx/sp2/.github/workflows/actions.lock') -eq 1 ] && grep -q 'erlef/setup-beam' '$fx/sp2/.github/workflows/actions.lock' && grep -q 'ossf/scorecard-action' '$fx/sp2/.github/workflows/actions.lock'" @@ -214,7 +216,9 @@ dependencies: uses: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' LOCK -out3=$(bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp3" 2>&1) +set +e +out3=$(bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp3" 2>&1); rc_sp3=$? +set -e l3="$fx/sp3/.github/workflows/actions.lock" check "partial migration: the rewritten caller's lock edge moves" \ "grep -A1 \"'.github/workflows/rust-ci.yml':\" '$l3' | grep -q \"hyperpolymath/standards@$GOOD\"" @@ -222,16 +226,16 @@ check "partial migration: the untouched caller's lock edge stays at the stale SH "grep -A1 \"'.github/workflows/mirror.yml':\" '$l3' | grep -q \"hyperpolymath/standards@$STALE_A\" && grep -q \"mirror-reusable.yml@$STALE_A\" '$fx/sp3/.github/workflows/mirror.yml'" check "partial migration: both dependency records exist, each with its own ref and commit" \ "[ \$(grep -c \"^ 'hyperpolymath/standards@\" '$l3') -eq 2 ] && grep -q \"ref: '$GOOD'\" '$l3' && grep -q \"ref: '$STALE_A'\" '$l3' && grep -q \"commit: 'sha1-$GOOD'\" '$l3'" -check "partial migration is said out loud" "grep -q 'keeps the record for' <<<\"\$out3\"" +check "partial migration is said out loud and exits 3" "[ \$rc_sp3 -eq 3 ] && grep -q 'keeps the record for' <<<\"\$out3\"" tar -C "$fx/sp3" -cf "$fx/sp3a.tar" .github -bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp3" >/dev/null 2>&1 +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp3" >/dev/null 2>&1; rc_sp3b=$? tar -C "$fx/sp3" -cf "$fx/sp3b.tar" .github -check "partial migration: second run is a no-op" "cmp -s '$fx/sp3a.tar' '$fx/sp3b.tar'" +check "partial migration: second run is a no-op and exits 0" "[ \$rc_sp3b -eq 0 ] && cmp -s '$fx/sp3a.tar' '$fx/sp3b.tar'" # A quoted key and value are still a pin. mkdir -p "$fx/sp4/.github/workflows" printf 'jobs:\n governance:\n "uses": "hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s"\n' "$STALE_B" > "$fx/sp4/.github/workflows/governance.yml" -bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp4" >/dev/null 2>&1 -check "a quoted \"uses\" key with a quoted value is rewritten" "grep -q \"\\\"hyperpolymath/standards/.github/workflows/governance-reusable.yml@$GOOD\\\"\" '$fx/sp4/.github/workflows/governance.yml'" +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp4" >/dev/null 2>&1; rc_sp4=$? +check "a quoted \"uses\" key with a quoted value is rewritten" "[ \$rc_sp4 -eq 0 ] && grep -q \"\\\"hyperpolymath/standards/.github/workflows/governance-reusable.yml@$GOOD\\\"\" '$fx/sp4/.github/workflows/governance.yml'" # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── mkdir -p "$fx/ls/.github/workflows" From fb02dbfcc80c86744ad6872c48130cd0809b0ebc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:43:38 +0000 Subject: [PATCH 06/44] ci(rust): make the rustfmt check a hard gate All four matrix crates (robot-repo-automaton, shared-context, dashboard, bots/rhodibot) are rustfmt-clean today, so the `continue-on-error: true` that kept the check informational (hypatia RE005 on this file) only hid a future regression. Dropped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .github/workflows/rust.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 00698a4e..b6cc0357 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -65,12 +65,11 @@ jobs: run: cargo test --locked --verbose - name: Clippy (deny warnings) run: cargo clippy --locked --all-targets -- -D warnings - - name: Rustfmt check (informational) - # Pre-existing formatting drift is not yet gated; surfaced here so it - # stays visible without blocking. Flip to a hard gate after a dedicated - # `cargo fmt` pass lands. + - name: Rustfmt check + # A hard gate since 2026-10-19: all four matrix crates were measured + # rustfmt-clean, so `continue-on-error` (hypatia RE005, a silent + # green) no longer buys anything. run: cargo fmt --check - continue-on-error: true dispatch-contracts: name: Dispatch path and outcome contracts From 9838040954528842fbe3ba4ce3e7eb1a347f9b9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:05:28 +0000 Subject: [PATCH 07/44] fix(fix-scripts): place every SHA-pinned toolchain ref, refuse flow steps, anchor stale-pin callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-rust-toolchain-input.sh (WF026 repair): - only a `uses:` at the step's key column is the step's action; the same text nested under `env:` or inside a `run: |` script that writes a fixture is data and is left alone (it was being "fixed" in place); - quoted keys count: `"uses":` and `"with":` are the same keys to GitHub; - a bare `-` on its own line opens a step, so a `with:` written under it and above `uses:` is seen; - a tag ref that starts with hex digits (`@1.85.0`) is a tag, not a SHA: the old `[0-9a-f]+` tail took it for a pin and would have inserted `toolchain: stable` over the version the ref names; - a flow-style step (`- { name: x, uses: ... }`) is UNSUPPORTED: the file is left untouched, the line is named on stderr and the run exits 2. Before this it reported "OK (every SHA-pinned dtolnay step names a toolchain)" for a step that names none — silent green. fix-stale-reusable-pin.sh: - only a YAML `uses` key at the start of a line (quoted or bare, list dash or not) is a caller: a commented-out caller moves nothing, in the workflow or in the lock; - when the workflow still calls another reusable of the same repository at the stale SHA (a pin the table does not cover), the lock edge for the replacement is added beside the stale one instead of replacing it, so the lock stays consistent with the workflow. tests: 15 new contracts in scripts/tests/fix-workflow-pins.sh (62 total), run under gawk and, where installed, mawk. shellcheck clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-rust-toolchain-input.sh | 107 ++++++++++++++++++++++------ scripts/fix-stale-reusable-pin.sh | 37 +++++++--- scripts/tests/fix-workflow-pins.sh | 107 ++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 29 deletions(-) diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 2d88c20b..aeb41de8 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -15,8 +15,12 @@ # # What it does, per workflow file, for every list item that holds a step # whose `uses:` is dtolnay/rust-toolchain@<40-hex>: -# * the step is inspected whole — from its opening `- ` line, so a `with:` -# written above `uses:` counts (YAML mapping keys carry no order); +# * the step is inspected whole — from its opening `- ` line (a bare `-` on +# its own line opens a step too), so a `with:` written above `uses:` +# counts (YAML mapping keys carry no order); +# * only a `uses:` that is a KEY OF THE STEP MAPPING is the step's action: +# the same text nested under `env:`, inside a `run: |` script that writes +# a fixture, or under any other key is data and is left alone; # * only the step's `with:` mapping decides: `toolchain:` under `env:` or any # other key is not an action input and does not count; # * a block `with:` (with or without a trailing comment) that names no @@ -24,10 +28,19 @@ # first key, at the indentation its other keys use; # * a flow `with: { ... }` without `toolchain` gets it as its first entry; # * a step with no `with:` at all gets one, directly under `uses:`; +# * keys may be quoted (`"uses":`, "with":) — the same key to GitHub; # * a tag-pinned step (`@stable`, `@1.85.0`) and a step that already names a -# toolchain are left alone. +# toolchain are left alone; +# * a step written as a flow mapping (`- { name: x, uses: ... }`) is NOT +# rewritten: the script names the line as UNSUPPORTED and exits 2 rather +# than report the file as clean. Fix it by hand. # Idempotent: a second run changes nothing. # +# Exit 0 whether or not anything changed. Exit 2 when some file holds a +# SHA-pinned reference the parser cannot place as a block-style step key: +# that file is left untouched (other files are still fixed) and a dispatcher +# must not record the run as a clean success. +# # Usage: # fix-rust-toolchain-input.sh # fix-rust-toolchain-input.sh (every .github/workflows/*.yml) @@ -42,23 +55,51 @@ TOOLCHAIN="${TOOLCHAIN:-stable}" TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" apply_to_file() { - local f="$1" tmp + local f="$1" tmp rc [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } - grep -qE '["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?dtolnay/rust-toolchain@[0-9a-f]{40}' "$f" || return 0 + grep -qE 'dtolnay/rust-toolchain@[0-9a-f]{40}' "$f" || return 0 tmp=$(mktemp) + set +e awk -v toolchain="$TOOLCHAIN" ' function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } # Column of a key, with a list dash counted as indentation: ` - with:` -> 4. function key_indent_of(s, t) { t = s; sub(/[A-Za-z"'"'"'].*$/, "", t); gsub(/-/, " ", t); return t } function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } function spaces(n, t) { t = ""; while (n-- > 0) t = t " "; return t } + # A 40-hex pin of the action on a non-comment line, trailing comment + # stripped. `@stable` and `@1.85.0` are tags: the ref carries the + # channel and the step is not ours. + function sha_ref(s, t, m) { + if (!plain(s)) return 0 + t = s; sub(/[[:space:]]#.*$/, "", t) + if (!match(t, /dtolnay\/rust-toolchain@[0-9a-f]+/)) return 0 + m = substr(t, RSTART + 23, RLENGTH - 23) + return (length(m) == 40) + } # Emit the buffered list item, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, line, ind, uses_i, has_with, with_i, with_flow, with_indent, in_with, child_indent, has_tc, key_indent) { + function flush( i, j, line, ind, uses_i, has_with, with_i, with_flow, with_indent, in_with, child_indent, has_tc, key_indent, skey, flow_item) { if (n == 0) return + # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of + # `uses`), or for a bare `-` the indentation of the first plain line under it. + flow_item = (buf[1] ~ /^[[:space:]]*-[[:space:]]*\{/) + if (buf[1] ~ /^[[:space:]]*-[[:space:]]+[^[:space:]]/) skey = length(key_indent_of(buf[1])) + else { + skey = -1 + for (j = 2; j <= n; j++) if (plain(buf[j])) { skey = indent_of(buf[j]); break } + if (skey < 0) skey = indent_of(buf[1]) + 2 + } + # Place every SHA-pinned reference in the item: the step'"'"'s own `uses:` + # key, data nested under some other key (left alone), or something the + # parser cannot place (a flow-style step) — that is reported, not guessed. uses_i = 0 - for (i = 1; i <= n; i++) if (buf[i] ~ USES_RE) { uses_i = i; break } + for (i = 1; i <= n; i++) { + if (!sha_ref(buf[i])) continue + if (!flow_item && buf[i] ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } + if (!flow_item && i > 1 && indent_of(buf[i]) > skey) continue + unrec[++nunrec] = lnum[i] + } if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } has_with = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; child_indent = -1; has_tc = 0 for (i = 1; i <= n; i++) { @@ -67,14 +108,16 @@ apply_to_file() { if (plain(line) && ind <= with_indent) in_with = 0 else if (plain(line)) { if (child_indent < 0) child_indent = ind - if (line ~ /^[[:space:]]*["'"'"']?toolchain["'"'"']?[[:space:]]*:[[:space:]]*[^[:space:]]/) has_tc = 1 + if (line ~ TC_RE) has_tc = 1 } } - if (!in_with && !has_with && line ~ /^[[:space:]]*(-[[:space:]]+)?with:[[:space:]]*(#.*)?$/) { - has_with = 1; with_i = i; with_indent = length(key_indent_of(line)); in_with = 1 - } else if (!in_with && !has_with && line ~ /^[[:space:]]*(-[[:space:]]+)?with:[[:space:]]*\{/) { - has_with = 1; with_i = i; with_flow = 1 - if (line ~ /[{,][[:space:]]*["'"'"']?toolchain["'"'"']?[[:space:]]*:[[:space:]]*[^[:space:],}]/) has_tc = 1 + if (!in_with && !has_with && length(key_indent_of(line)) == skey) { + if (line ~ WITH_BLOCK_RE) { + has_with = 1; with_i = i; with_indent = skey; in_with = 1 + } else if (line ~ WITH_FLOW_RE) { + has_with = 1; with_i = i; with_flow = 1 + if (line ~ TC_FLOW_RE) has_tc = 1 + } } } if (has_tc) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } @@ -101,8 +144,13 @@ apply_to_file() { n = 0 } BEGIN { - n = 0; in_step = 0; fixed = 0 - USES_RE = "^[[:space:]]*(-[[:space:]]+)?[\"'"'"']?uses[\"'"'"']?[[:space:]]*:[[:space:]]*[\"'"'"']?dtolnay/rust-toolchain@[0-9a-f]+" + n = 0; in_step = 0; fixed = 0; nunrec = 0 + Q = "[\"'"'"']?" + USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-f]+" + WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" + WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" + TC_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:]]" + TC_FLOW_RE = "[{,][[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:],}]" } { if (in_step) { @@ -113,21 +161,38 @@ apply_to_file() { if ($0 ~ /^[[:space:]]*-([[:space:]]|$)/ && ind <= step_indent) { flush(); in_step = 0 } else if (plain($0) && ind < step_indent) { flush(); in_step = 0 } } - if (!in_step && $0 ~ /^[[:space:]]*-([[:space:]]|$)/) { in_step = 1; step_indent = indent_of($0); buf[++n] = $0; next } - if (in_step) { buf[++n] = $0; next } + if (!in_step && $0 ~ /^[[:space:]]*-([[:space:]]|$)/) { in_step = 1; step_indent = indent_of($0); n = 0; buf[++n] = $0; lnum[n] = NR; next } + if (in_step) { buf[++n] = $0; lnum[n] = NR; next } + if (sha_ref($0)) unrec[++nunrec] = NR print } - END { flush(); if (fixed) printf "FIXED %s steps=%d toolchain=%s\n", FILENAME, fixed, toolchain > "/dev/stderr" } - ' "$f" > "$tmp" + END { + flush() + if (nunrec) { + printf "UNSUPPORTED %s: %d SHA-pinned dtolnay/rust-toolchain reference(s) the parser cannot place as a block-style step key (a flow-style `- { ... }` step?), line(s):", FILENAME, nunrec > "/dev/stderr" + for (j = 1; j <= nunrec; j++) printf " %d", unrec[j] > "/dev/stderr" + printf ". Fix by hand; the file was not changed.\n" > "/dev/stderr" + exit 2 + } + if (fixed) printf "FIXED %s steps=%d toolchain=%s\n", FILENAME, fixed, toolchain > "/dev/stderr" + } + ' "$f" > "$tmp"; rc=$? + set -e + if [ "$rc" -ne 0 ]; then rm -f "$tmp"; return 2; fi if ! cmp -s "$f" "$tmp"; then mv "$tmp" "$f"; else rm -f "$tmp"; echo "OK (every SHA-pinned dtolnay step names a toolchain): $f"; fi } +unsupported=0 if [ -d "$TARGET" ]; then for f in "$TARGET"/.github/workflows/*.yml "$TARGET"/.github/workflows/*.yaml; do [ -f "$f" ] || continue - apply_to_file "$f" + apply_to_file "$f" || unsupported=$((unsupported + 1)) done else - apply_to_file "$TARGET" + apply_to_file "$TARGET" || unsupported=$((unsupported + 1)) +fi +if [ "$unsupported" -ne 0 ]; then + echo "fix-rust-toolchain-input: $unsupported file(s) left untouched (UNSUPPORTED above); not a clean success." >&2 + exit 2 fi diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 51c033fd..0c4b22e9 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -97,8 +97,12 @@ merge_duplicate_records() { } # Every `uses:` of an estate reusable at a full SHA, across the workflows. -# The lock is edited after the loop, once every workflow rewrite is known. -edits=$(mktemp) # workflow-keystalereplacement +# Only a YAML `uses` KEY counts — the key at the start of the line (quoted or +# not, list dash or not); the same text after a `#` is a comment, not a +# caller, and must not move anything. The lock is edited after the loop, +# once every workflow rewrite is known. +USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?" +edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edge while IFS= read -r -d '' wf; do while IFS= read -r line; do path="${line%@*}"; stale="${line##*@}" @@ -113,24 +117,39 @@ while IFS= read -r -d '' wf; do done < "$PINS_TABLE" [ -n "$replacement" ] || continue [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } - sed -i "s|\(uses[\"']\?[[:space:]]*:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" + sed -i "s|^\([[:space:]]*\(-[[:space:]]\+\)\?[\"']\?uses[\"']\?[[:space:]]*:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" echo "FIXED ${wf#"$REPO_PATH"/} $path ${stale:0:7} -> ${replacement:0:7} ($reason)" rewrites=$((rewrites + 1)) - printf '%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" >> "$edits" - done < <(grep -oE "[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" "$wf" | sed -E "s/^[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?//" | sort -u) + # A lock edge is per repository@SHA, shared by every reusable of that + # repository the workflow calls. When this workflow still calls another + # reusable of the same repository at the stale SHA (a pin the table does + # not cover), the stale edge must stay next to the new one. + repo_prefix="${path%%/.github/workflows/*}" + keep=0 + grep -qE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" "$wf" && keep=1 + printf '%s\t%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" "$keep" >> "$edits" + done < <(grep -oE "${USES_KEY_RE}hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" "$wf" | sed -E "s/${USES_KEY_RE}//" | sort -u) done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) # Lock: move each rewritten workflow's own `workflows:` edges first. if [ -f "$LOCK" ] && [ -s "$edits" ]; then sort -u "$edits" -o "$edits" - while IFS=$'\t' read -r wfkey stale new; do + while IFS=$'\t' read -r wfkey stale new keep; do grep -q "$stale" "$LOCK" || continue tmp=$(mktemp) - awk -v wfkey=" '$wfkey':" -v stale="$stale" -v new="$new" ' + awk -v wfkey=" '$wfkey':" -v stale="$stale" -v new="$new" -v keep="$keep" ' /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } sec == "w" && /^ '"'"'/ { inwf = ($0 == wfkey) } - sec == "w" && inwf && /^ - / { i = index($0, "@" stale "'"'"'"); if (i) $0 = substr($0, 1, i) new "'"'"'" } + sec == "w" && inwf && /^ - / { + i = index($0, "@" stale "'"'"'") + if (i) { + moved = substr($0, 1, i) new "'"'"'" + # keep: an uncovered caller in this workflow still pins the stale + # SHA, so the edge is added beside the old one, not replaced. + if (keep == "1") { if (!seen[moved]++) { print; $0 = moved } else next } else $0 = moved + } + } { print } ' "$LOCK" > "$tmp" if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi @@ -156,6 +175,8 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then fi if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi done < <(cut -f2,3 "$edits" | sort -u) + # A kept stale edge means the new edge was ADDED, so the stale record is + # kept too (the branch above copies it); nothing else to do here. [ "$lock_touched" -eq 1 ] && merge_duplicate_records "$LOCK" fi rm -f "$edits" diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index e015e7fa..73c67e67 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -114,6 +114,72 @@ cp "$f2" "$fx/tc2-after1.yml" bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc2" >/dev/null 2>&1 check "second run over the review cases is a no-op" "cmp -s '$fx/tc2-after1.yml' '$f2'" +# Review cases (Codex, gitbot-fleet#615): a quoted "with" key; the same text +# inside a `run: |` script and under `env:` (data, not the step's action); a +# tag ref that starts with hex digits (`@1.85.0`); bare `-` openers; and a +# flow-style step the parser must refuse out loud rather than report clean. +mkdir -p "$fx/tc3/.github/workflows" +cat > "$fx/tc3/.github/workflows/rust.yml" <<'YML' +jobs: + a: + steps: + - "uses": "dtolnay/rust-toolchain@0000000000000000000000000000000000000003" + "with": { components: clippy } + - name: write a fixture + run: | + cat > fixture.yml <<'EOF' + uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000004 + EOF + - uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000005 + env: + uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000006 + - uses: dtolnay/rust-toolchain@1.85.0 + - + with: + toolchain: nightly + uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000007 + - + uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000008 + - run: cargo test +YML +f3="$fx/tc3/.github/workflows/rust.yml" +cp "$f3" "$fx/tc3-before.yml" +set +e +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc3" >/dev/null 2>&1; rc_tc3=$? +set -e +check "a quoted \"with\" key is reused: toolchain goes into it, nothing inserted under uses:" \ + "grep -A1 '0000000000000000000000000000000000000003' '$f3' | sed -n '2p' | grep -q '^ \"with\": { toolchain: stable, components: clippy }$'" +check "a SHA ref inside a run: | script is data: left alone, nothing inserted after it" \ + "grep -A1 '0000000000000000000000000000000000000004' '$f3' | sed -n '2p' | grep -q '^ EOF$' && grep -q '^ uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000004$' '$f3'" +check "a SHA ref under env: is data: the step gets with: under its own uses:, env: is kept as is" \ + "grep -A2 '0000000000000000000000000000000000000005' '$f3' | sed -n '2p' | grep -q '^ with:$' && grep -A2 '0000000000000000000000000000000000000005' '$f3' | sed -n '3p' | grep -q '^ toolchain: stable$' && grep -A1 '0000000000000000000000000000000000000006' '$f3' | sed -n '2p' | grep -q '^ - uses: dtolnay/rust-toolchain@1.85.0$'" +check "a tag ref that starts with hex digits (@1.85.0) is a tag: untouched" \ + "grep -A1 'rust-toolchain@1.85.0' '$f3' | sed -n '2p' | grep -q '^ -$'" +check "a bare - opener: a with: toolchain: above uses: counts, nothing inserted" \ + "grep -A1 '0000000000000000000000000000000000000007' '$f3' | sed -n '2p' | grep -q '^ -$'" +check "a bare - opener with a bare uses: gets with: at the keys' column" \ + "grep -A2 '0000000000000000000000000000000000000008' '$f3' | sed -n '2p' | grep -q '^ with:$' && grep -A2 '0000000000000000000000000000000000000008' '$f3' | sed -n '3p' | grep -q '^ toolchain: stable$'" +check "exactly three steps were fixed and the run exits 0" \ + "[ \$rc_tc3 -eq 0 ] && [ \$(grep -c 'toolchain: stable' '$f3') -eq 3 ] && grep -q 'toolchain: nightly' '$f3' && grep -q 'cargo test' '$f3'" +cp "$f3" "$fx/tc3-after1.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc3" >/dev/null 2>&1 +check "second run over these cases is a no-op" "cmp -s '$fx/tc3-after1.yml' '$f3'" +if command -v mawk >/dev/null; then + mkdir -p "$fx/mawk-bin" "$fx/tc3m/.github/workflows"; ln -sf "$(command -v mawk)" "$fx/mawk-bin/awk" + cp "$fx/tc3-before.yml" "$fx/tc3m/.github/workflows/rust.yml" + PATH="$fx/mawk-bin:$PATH" bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc3m" >/dev/null 2>&1 || true + check "under mawk the same cases produce the same file" "cmp -s '$fx/tc3-after1.yml' '$fx/tc3m/.github/workflows/rust.yml'" +fi +# A flow-style step: refused out loud, file untouched, exit 2 — never "OK". +mkdir -p "$fx/tc4/.github/workflows" +printf 'jobs:\n a:\n steps:\n - { name: toolchain, uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000009 }\n - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000a\n' > "$fx/tc4/.github/workflows/x.yml" +cp "$fx/tc4/.github/workflows/x.yml" "$fx/tc4-before.yml" +set +e +out4=$(bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc4" 2>&1); rc_tc4=$? +set -e +check "a flow-style step is UNSUPPORTED: named with its line, file untouched, exit 2, never OK" \ + "[ \$rc_tc4 -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 4' <<<\"\$out4\" && ! grep -q '^OK' <<<\"\$out4\" && cmp -s '$fx/tc4-before.yml' '$fx/tc4/.github/workflows/x.yml'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -236,6 +302,47 @@ mkdir -p "$fx/sp4/.github/workflows" printf 'jobs:\n governance:\n "uses": "hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s"\n' "$STALE_B" > "$fx/sp4/.github/workflows/governance.yml" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp4" >/dev/null 2>&1; rc_sp4=$? check "a quoted \"uses\" key with a quoted value is rewritten" "[ \$rc_sp4 -eq 0 ] && grep -q \"\\\"hyperpolymath/standards/.github/workflows/governance-reusable.yml@$GOOD\\\"\" '$fx/sp4/.github/workflows/governance.yml'" +# Review cases (Codex, gitbot-fleet#615): a commented-out caller is not a +# caller; a workflow calling two reusables of one repository at the same dead +# SHA, only one covered by the table, keeps the stale lock edge beside the new. +mkdir -p "$fx/sp5/.github/workflows" +printf 'jobs:\n mirror:\n uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@%s\n # rust-ci:\n # uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" "$STALE_A" > "$fx/sp5/.github/workflows/ci.yml" +cat > "$fx/sp5/.github/workflows/actions.lock" </dev/null 2>&1; rc_sp5=$? +set -e +tar -C "$fx/sp5" -cf "$fx/sp5b.tar" .github +check "a commented-out caller of a covered reusable moves nothing: workflow and lock byte-identical, exit 0" \ + "[ \$rc_sp5 -eq 0 ] && cmp -s '$fx/sp5a.tar' '$fx/sp5b.tar'" +mkdir -p "$fx/sp6/.github/workflows" +printf 'jobs:\n rust-ci:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n mirror:\n uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@%s\n' "$STALE_A" "$STALE_A" > "$fx/sp6/.github/workflows/ci.yml" +sed 's#sp5#sp6#' "$fx/sp5/.github/workflows/actions.lock" > "$fx/sp6/.github/workflows/actions.lock" +set +e +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp6" >/dev/null 2>&1; rc_sp6=$? +set -e +l6="$fx/sp6/.github/workflows/actions.lock" +check "two reusables of one repository at one dead SHA, one covered: the covered pin moves, the other stays" \ + "grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/sp6/.github/workflows/ci.yml' && grep -q \"mirror-reusable.yml@$STALE_A\" '$fx/sp6/.github/workflows/ci.yml'" +check "the workflow's lock entry then carries BOTH edges, the stale one kept beside the new" \ + "grep -A2 \"'.github/workflows/ci.yml':\" '$l6' | grep -q \"hyperpolymath/standards@$STALE_A\" && grep -A2 \"'.github/workflows/ci.yml':\" '$l6' | grep -q \"hyperpolymath/standards@$GOOD\"" +check "both dependency records exist, each with its ref, and the run exits 3" \ + "[ \$rc_sp6 -eq 3 ] && grep -q \"ref: '$GOOD'\" '$l6' && grep -q \"ref: '$STALE_A'\" '$l6'" +tar -C "$fx/sp6" -cf "$fx/sp6a.tar" .github +bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp6" >/dev/null 2>&1; rc_sp6b=$? +tar -C "$fx/sp6" -cf "$fx/sp6b.tar" .github +check "kept-edge case: second run is a no-op and exits 0" "[ \$rc_sp6b -eq 0 ] && cmp -s '$fx/sp6a.tar' '$fx/sp6b.tar'" # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── mkdir -p "$fx/ls/.github/workflows" From b77414aedbac8de102e29695f9f9d88e737bd6c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:15:42 +0000 Subject: [PATCH 08/44] fix(fix-rust-toolchain-input): only a direct child of with: is an input A `toolchain: stable` line inside ANOTHER input's block scalar (`components: |` carrying that text) sat deeper than the first child of `with:` and still counted as the input, so a step with no real toolchain was reported fixed. Only lines at the first child's indentation count now; the step gets its own `toolchain:` as the first key of `with:`. Contract tc5 (63 total). Mirrors hypatia WF026's direct-children rule. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-rust-toolchain-input.sh | 10 +++++++--- scripts/tests/fix-workflow-pins.sh | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index aeb41de8..bd853f02 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -21,8 +21,10 @@ # * only a `uses:` that is a KEY OF THE STEP MAPPING is the step's action: # the same text nested under `env:`, inside a `run: |` script that writes # a fixture, or under any other key is data and is left alone; -# * only the step's `with:` mapping decides: `toolchain:` under `env:` or any -# other key is not an action input and does not count; +# * only the step's `with:` mapping decides, and only its DIRECT children: +# `toolchain:` under `env:` or any other key is not an action input, and +# the same text inside another input's block scalar (`components: |`) is +# text, not a key, and does not count either; # * a block `with:` (with or without a trailing comment) that names no # `toolchain:` gets `toolchain: ` (default `stable`) as its # first key, at the indentation its other keys use; @@ -107,8 +109,10 @@ apply_to_file() { if (in_with) { if (plain(line) && ind <= with_indent) in_with = 0 else if (plain(line)) { + # Only a DIRECT child of `with:` is an input: the contents of + # another input'"'"'s block scalar (`components: |`) sit deeper. if (child_indent < 0) child_indent = ind - if (line ~ TC_RE) has_tc = 1 + if (ind == child_indent && line ~ TC_RE) has_tc = 1 } } if (!in_with && !has_with && length(key_indent_of(line)) == skey) { diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 73c67e67..2dfad9a5 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -170,6 +170,14 @@ if command -v mawk >/dev/null; then PATH="$fx/mawk-bin:$PATH" bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc3m" >/dev/null 2>&1 || true check "under mawk the same cases produce the same file" "cmp -s '$fx/tc3-after1.yml' '$fx/tc3m/.github/workflows/rust.yml'" fi +# A `toolchain: stable` line inside ANOTHER input's block scalar is text, not +# an input: the step still gets its own toolchain, as the first child of with:. +mkdir -p "$fx/tc5/.github/workflows" +printf 'jobs:\n a:\n steps:\n - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000b\n with:\n components: |\n toolchain: stable\n' > "$fx/tc5/.github/workflows/x.yml" +f5="$fx/tc5/.github/workflows/x.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc5" >/dev/null 2>&1 +check "toolchain: inside another input's block scalar does not count: a real input is added first" \ + "grep -A2 '000000000000000000000000000000000000000b' '$f5' | sed -n '3p' | grep -q '^ toolchain: stable$' && grep -q '^ toolchain: stable$' '$f5' && [ \$(grep -c 'toolchain: stable' '$f5') -eq 2 ]" # A flow-style step: refused out loud, file untouched, exit 2 — never "OK". mkdir -p "$fx/tc4/.github/workflows" printf 'jobs:\n a:\n steps:\n - { name: toolchain, uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000009 }\n - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000a\n' > "$fx/tc4/.github/workflows/x.yml" From 513bf93655320e6407de9e52f6e6ef8706aff75f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:30:16 +0000 Subject: [PATCH 09/44] fix(fix-scripts): steps-only items, multi-line flow with:, transitive lock edges, reusable sources from workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-rust-toolchain-input.sh: - a list item opens a step only when its parent key is `steps:`; a block scalar outside any step (a fixture kept in `env:`, even one spelling out `steps:` inside) is text and is never touched; - a flow `with: {` spread over several lines is read to its closing brace: a toolchain inside it counts, and without one the input becomes the first entry instead of a duplicate key. fix-stale-reusable-pin.sh: - a known-dead pin the parser cannot place as a `uses` key (a flow-style job `rust: { uses: ... }`) makes the file UNSUPPORTED: untouched, named with its line, exit 2 — before this it reported OK and left the pin; - a nested `uses:` edge of another record still pointing at the stale SHA keeps the stale record (it is copied, not renamed), so a reusable that itself calls the stale revision keeps the record it needs. fix-lock-self-sha-refs.sh: - reusable-workflow sources are read from the workflows' own reusable `uses:` lines (plus standards and cicd-suite), so a SHA-pinned reusable from any repository keeps its SHA ref instead of being reported unresolved or rewritten to a tag. tests: 9 new contracts (72 total), shellcheck clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 22 ++++-- scripts/fix-rust-toolchain-input.sh | 70 +++++++++++++++---- scripts/fix-stale-reusable-pin.sh | 68 ++++++++++++------ scripts/tests/fix-workflow-pins.sh | 103 ++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 38 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index f494130b..cb2a2805 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -16,9 +16,10 @@ # a record died. standards main carried them from 2026-08-19 to 2026-10-19. # # What it does: for each record `'owner/repo@':` followed by -# `ref: ''`, where owner/repo is NOT an estate reusable-workflow source -# (hyperpolymath/standards, hyperpolymath/cicd-suite — a reusable record -# legitimately carries its SHA as ref; standards' accepted lock does), look the +# `ref: ''`, where owner/repo is NOT a reusable-workflow source (a +# reusable record legitimately carries its SHA as ref; standards' accepted +# lock does — the sources are read from the workflows' own reusable `uses:` +# lines, plus hyperpolymath/standards and hyperpolymath/cicd-suite), look the # commit up in scripts/lib/action-ref-tags.tsv, else (unless --offline) in # `git ls-remote --tags https://github.com/owner/repo`, preferring the most # specific tag; rewrite the ref line. A commit with no tag is reported and @@ -56,6 +57,19 @@ lookup_tag() { # printf '%s' "$tag" } +# Reusable-workflow sources keep their SHA as ref (standards' accepted lock +# does). Which repositories those are is read from the workflows themselves — +# every `uses:` of `owner/repo/.github/workflows/.yml@` — plus the +# two estate sources, so a reusable from ANY repository is never mistaken for +# an action record and "repaired" into a ref GitHub would then reject. +reusable_sources=$(grep -rhoE "^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@" \ + --include='*.yml' --include='*.yaml' "$REPO_PATH/.github/workflows" 2>/dev/null \ + | sed -E "s/^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?//; s#/\.github/workflows/.*\$##" | sort -u || true) +is_reusable_source() { + case "$1" in hyperpolymath/standards|hyperpolymath/cicd-suite) return 0 ;; esac + grep -qxF "$1" <<<"$reusable_sources" +} + fixed=0; unresolved=0 tmp=$(mktemp); cp "$LOCK" "$tmp" # Parse first, loop second: a parser that dies inside a process substitution @@ -76,7 +90,7 @@ if ! awk ' rm -f "$tmp" "$records"; exit 2 fi while IFS=$'\t' read -r lineno repo sha; do - case "$repo" in hyperpolymath/standards|hyperpolymath/cicd-suite) continue ;; esac + if is_reusable_source "$repo"; then continue; fi tag=$(lookup_tag "$repo" "$sha") if [ -z "$tag" ]; then echo "UNRESOLVED $repo@${sha:0:7}: no tag found for this commit (record left as is; GitHub rejects it)" >&2 diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index bd853f02..5381681e 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -13,8 +13,11 @@ # rust-ci-reusable.yml carried the defect from d73c93e0 to 49ed4715 # (fixed by standards#439, 9fe0d8ff). # -# What it does, per workflow file, for every list item that holds a step -# whose `uses:` is dtolnay/rust-toolchain@<40-hex>: +# What it does, per workflow file, for every list item under a `steps:` key +# that holds a step whose `uses:` is dtolnay/rust-toolchain@<40-hex>: +# * only a list item whose parent key is `steps:` is a step: a list item +# inside a block scalar (`run: |`, a fixture kept in `env:`) or under any +# other key is text and is never touched; # * the step is inspected whole — from its opening `- ` line (a bare `-` on # its own line opens a step too), so a `with:` written above `uses:` # counts (YAML mapping keys carry no order); @@ -28,7 +31,8 @@ # * a block `with:` (with or without a trailing comment) that names no # `toolchain:` gets `toolchain: ` (default `stable`) as its # first key, at the indentation its other keys use; -# * a flow `with: { ... }` without `toolchain` gets it as its first entry; +# * a flow `with: { ... }` — on one line or spread over several up to its +# closing brace — without `toolchain` gets it as its first entry; # * a step with no `with:` at all gets one, directly under `uses:`; # * keys may be quoted (`"uses":`, "with":) — the same key to GitHub; # * a tag-pinned step (`@stable`, `@1.85.0`) and a step that already names a @@ -39,7 +43,7 @@ # Idempotent: a second run changes nothing. # # Exit 0 whether or not anything changed. Exit 2 when some file holds a -# SHA-pinned reference the parser cannot place as a block-style step key: +# SHA-pinned step the parser cannot place as a block-style step mapping: # that file is left untouched (other files are still fixed) and a dispatcher # must not record the run as a clean success. # @@ -79,9 +83,22 @@ apply_to_file() { m = substr(t, RSTART + 23, RLENGTH - 23) return (length(m) == 40) } - # Emit the buffered list item, patched if it is a SHA-pinned dtolnay step + # YAML context outside list items: the key at each indentation, so a + # list item opens a step only when its parent key is `steps:`. + function record_key(s, ind, k, t) { + ind = indent_of(s) + t = s; sub(/^[[:space:]]*/, "", t); sub(/[[:space:]]*:.*$/, "", t); gsub(/["'"'"']/, "", t) + for (k in keyat) if (k + 0 > ind) delete keyat[k] + keyat[ind] = t + } + function parent_is_steps(ind, k, best) { + best = -1 + for (k in keyat) if (k + 0 <= ind && k + 0 > best) best = k + 0 + return (best >= 0 && keyat[best] == "steps") + } + # Emit the buffered step, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, j, line, ind, uses_i, has_with, with_i, with_flow, with_indent, in_with, child_indent, has_tc, key_indent, skey, flow_item) { + function flush( i, j, line, ind, uses_i, has_with, with_i, with_flow, with_indent, in_with, in_flow, child_indent, has_tc, key_indent, skey, flow_item) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` the indentation of the first plain line under it. @@ -92,7 +109,7 @@ apply_to_file() { for (j = 2; j <= n; j++) if (plain(buf[j])) { skey = indent_of(buf[j]); break } if (skey < 0) skey = indent_of(buf[1]) + 2 } - # Place every SHA-pinned reference in the item: the step'"'"'s own `uses:` + # Place every SHA-pinned reference in the step: the step'"'"'s own `uses:` # key, data nested under some other key (left alone), or something the # parser cannot place (a flow-style step) — that is reported, not guessed. uses_i = 0 @@ -103,9 +120,16 @@ apply_to_file() { unrec[++nunrec] = lnum[i] } if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } - has_with = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; child_indent = -1; has_tc = 0 + has_with = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; in_flow = 0; child_indent = -1; has_tc = 0 for (i = 1; i <= n; i++) { line = buf[i]; ind = indent_of(line) + if (in_flow) { + # A flow `with: {` spread over several lines: every entry up to + # the closing brace is an input. + if (line ~ TC_RE || line ~ TC_FLOW_RE) has_tc = 1 + if (index(line, "}")) in_flow = 0 + continue + } if (in_with) { if (plain(line) && ind <= with_indent) in_with = 0 else if (plain(line)) { @@ -121,6 +145,7 @@ apply_to_file() { } else if (line ~ WITH_FLOW_RE) { has_with = 1; with_i = i; with_flow = 1 if (line ~ TC_FLOW_RE) has_tc = 1 + if (!index(line, "}")) in_flow = 1 } } } @@ -129,6 +154,7 @@ apply_to_file() { line = buf[i] if (has_with && with_flow && i == with_i) { if (line ~ /\{[[:space:]]*\}/) sub(/\{[[:space:]]*\}/, "{ toolchain: " toolchain " }", line) + else if (line ~ /\{[[:space:]]*$/) sub(/\{[[:space:]]*$/, "{ toolchain: " toolchain ",", line) else sub(/\{[[:space:]]*/, "{ toolchain: " toolchain ", ", line) print line; fixed++ } else if (has_with && !with_flow && i == with_i) { @@ -148,8 +174,11 @@ apply_to_file() { n = 0 } BEGIN { - n = 0; in_step = 0; fixed = 0; nunrec = 0 + n = 0; in_step = 0; in_scalar = 0; fixed = 0; nunrec = 0 Q = "[\"'"'"']?" + LIST_RE = "^[[:space:]]*-([[:space:]]|$)" + KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" + SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-f]+" WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" @@ -157,17 +186,30 @@ apply_to_file() { TC_FLOW_RE = "[{,][[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:],}]" } { + ind = indent_of($0) + # A block scalar outside any step (`FIXTURE: |` under `env:`) is text + # until a plain line at its key'"'"'s indentation or shallower. + if (!in_step && in_scalar) { + if (plain($0) && ind <= scalar_indent) in_scalar = 0 + else { print; next } + } if (in_step) { - # The item ends at the next list item at its own indentation (or + # The step ends at the next list item at its own indentation (or # shallower), or at a shallower key. Blank and comment lines and # deeper lines belong to it. - ind = indent_of($0) - if ($0 ~ /^[[:space:]]*-([[:space:]]|$)/ && ind <= step_indent) { flush(); in_step = 0 } + if ($0 ~ LIST_RE && ind <= step_indent) { flush(); in_step = 0 } else if (plain($0) && ind < step_indent) { flush(); in_step = 0 } } - if (!in_step && $0 ~ /^[[:space:]]*-([[:space:]]|$)/) { in_step = 1; step_indent = indent_of($0); n = 0; buf[++n] = $0; lnum[n] = NR; next } if (in_step) { buf[++n] = $0; lnum[n] = NR; next } - if (sha_ref($0)) unrec[++nunrec] = NR + if ($0 ~ LIST_RE) { + if (parent_is_steps(ind)) { in_step = 1; step_indent = ind; n = 0; buf[++n] = $0; lnum[n] = NR; next } + # A list item whose parent is not `steps:` (a matrix, a fixture) is data. + print; next + } + if (plain($0) && $0 ~ KEY_RE) { + record_key($0) + if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } + } else if (sha_ref($0) && parent_is_steps(ind)) unrec[++nunrec] = NR print } END { diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 0c4b22e9..1e39ec5e 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -26,9 +26,11 @@ # afterwards; the script says so when it touches a lock. # # Idempotent. Rewrites nothing it does not recognise. Exit 0 whether or not -# anything changed; exit 2 when the table is unreadable; exit 3 when the pins -# were rewritten but actions.lock was touched and still needs `gh actions-lock` -# — a dispatcher must not record that as a clean success. +# anything changed; exit 2 when the table is unreadable or a file carries a +# known-dead pin the parser cannot place as a `uses` key (that file is left +# untouched and named); exit 3 when the pins were rewritten but actions.lock +# was touched and still needs `gh actions-lock` — a dispatcher must not +# record either as a clean success. # # Usage: # fix-stale-reusable-pin.sh @@ -96,26 +98,46 @@ merge_duplicate_records() { if ! cmp -s "$lock" "$tmp"; then mv "$tmp" "$lock"; else rm -f "$tmp"; fi } +# The table row for a reusable path at a stale SHA: sets replacement/reason. +lookup_replacement() { # + local t_path t_stale t_new t_reason + replacement=""; reason="" + while IFS=$'\t' read -r t_path t_stale t_new t_reason; do + [[ -z "$t_path" || "$t_path" == \#* ]] && continue + [[ "$t_stale" == "$2" ]] || continue + if [[ "$t_path" == "$1" ]] || { [[ "$t_path" == */\* ]] && [[ "$1" == "${t_path%\*}"* ]]; }; then + replacement="$t_new"; reason="$t_reason"; return 0 + fi + done < "$PINS_TABLE" + return 1 +} + # Every `uses:` of an estate reusable at a full SHA, across the workflows. # Only a YAML `uses` KEY counts — the key at the start of the line (quoted or # not, list dash or not); the same text after a `#` is a comment, not a -# caller, and must not move anything. The lock is edited after the loop, -# once every workflow rewrite is known. +# caller, and must not move anything. A covered pin the parser cannot place +# that way (a flow-style job `rust: { uses: ... }`) makes the whole file +# UNSUPPORTED: left untouched, named on stderr, exit 2 — never "OK". The lock +# is edited after the loop, once every workflow rewrite is known. USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?" +REUSABLE_REF_RE="hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edge +unsupported=0 while IFS= read -r -d '' wf; do + skip=0 + while IFS= read -r numbered; do + lineno="${numbered%%:*}"; ref=$(grep -oE "$REUSABLE_REF_RE" <<<"${numbered#*:}" | head -1) + [ -n "$ref" ] || continue + if lookup_replacement "${ref%@*}" "${ref##*@}"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key at the start of its line (flow-style job mapping?). Fix by hand; the file was not changed." >&2 + skip=1 + fi + done < <(grep -nE "$REUSABLE_REF_RE" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" | grep -vE "^[0-9]+:${USES_KEY_RE#^}" || true) + if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi while IFS= read -r line; do path="${line%@*}"; stale="${line##*@}" [[ "$stale" =~ ^[0-9a-f]{40}$ ]] || continue - replacement=""; reason="" - while IFS=$'\t' read -r t_path t_stale t_new t_reason; do - [[ -z "$t_path" || "$t_path" == \#* ]] && continue - [[ "$t_stale" == "$stale" ]] || continue - if [[ "$t_path" == "$path" ]] || { [[ "$t_path" == */\* ]] && [[ "$path" == "${t_path%\*}"* ]]; }; then - replacement="$t_new"; reason="$t_reason"; break - fi - done < "$PINS_TABLE" - [ -n "$replacement" ] || continue + lookup_replacement "$path" "$stale" || continue [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } sed -i "s|^\([[:space:]]*\(-[[:space:]]\+\)\?[\"']\?uses[\"']\?[[:space:]]*:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" echo "FIXED ${wf#"$REPO_PATH"/} $path ${stale:0:7} -> ${replacement:0:7} ($reason)" @@ -128,7 +150,7 @@ while IFS= read -r -d '' wf; do keep=0 grep -qE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" "$wf" && keep=1 printf '%s\t%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" "$keep" >> "$edits" - done < <(grep -oE "${USES_KEY_RE}hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" "$wf" | sed -E "s/${USES_KEY_RE}//" | sort -u) + done < <(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}" "$wf" | sed -E "s/${USES_KEY_RE}//" | sort -u) done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) # Lock: move each rewritten workflow's own `workflows:` edges first. @@ -155,12 +177,13 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi done < "$edits" # Then the records: rename a stale record nothing references any more; - # copy it under the replacement key while a workflow still pins the stale SHA. + # copy it under the replacement key while an edge still points at the + # stale SHA — a workflow's own edge, or another record's nested `uses:` + # (a reusable that itself calls the stale revision keeps needing it). while IFS=$'\t' read -r stale new; do grep -q "$stale" "$LOCK" || continue still=$(awk -v stale="$stale" ' - /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && index($0, "@" stale "'"'"'") { n++ } END { print n + 0 }' "$LOCK") + /^ +- '"'"'/ && index($0, "@" stale "'"'"'") { n++ } END { print n + 0 }' "$LOCK") tmp=$(mktemp) if [ "$still" -eq 0 ]; then sed "s/$stale/$new/g" "$LOCK" > "$tmp" @@ -181,8 +204,13 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then fi rm -f "$edits" -if [ "$rewrites" -eq 0 ]; then echo "OK (no known-dead reusable pins): $REPO_PATH"; else echo "Rewrote $rewrites pin(s) in $REPO_PATH"; fi +if [ "$rewrites" -eq 0 ] && [ "$unsupported" -eq 0 ]; then echo "OK (no known-dead reusable pins): $REPO_PATH"; elif [ "$rewrites" -gt 0 ]; then echo "Rewrote $rewrites pin(s) in $REPO_PATH"; fi if [ "$lock_touched" -eq 1 ]; then echo "WARN: actions.lock named a stale SHA and was rewritten to the replacement; its nested uses: list was NOT recomputed — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing. Exit 3: not a clean success." >&2 - exit 3 fi +if [ "$unsupported" -ne 0 ]; then + echo "fix-stale-reusable-pin: $unsupported file(s) left untouched (UNSUPPORTED above); not a clean success." >&2 + exit 2 +fi +[ "$lock_touched" -eq 1 ] && exit 3 +exit 0 diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 2dfad9a5..43ff925d 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -178,6 +178,46 @@ f5="$fx/tc5/.github/workflows/x.yml" bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc5" >/dev/null 2>&1 check "toolchain: inside another input's block scalar does not count: a real input is added first" \ "grep -A2 '000000000000000000000000000000000000000b' '$f5' | sed -n '3p' | grep -q '^ toolchain: stable$' && grep -q '^ toolchain: stable$' '$f5' && [ \$(grep -c 'toolchain: stable' '$f5') -eq 2 ]" +# A flow `with: {` spread over several lines is read to its closing brace: +# a toolchain inside it counts; without one it gets the input as first entry. +# A list item whose parent is not `steps:` — a fixture kept in a top-level +# `env:` block scalar, even one that spells out `steps:` inside — is text. +mkdir -p "$fx/tc6/.github/workflows" +cat > "$fx/tc6/.github/workflows/x.yml" <<'YML' +env: + FIXTURE: | + jobs: + a: + steps: + - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000c +jobs: + a: + steps: + - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000d + with: { + toolchain: nightly, + } + - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000e + with: { + components: clippy, + } + - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000f +YML +f6="$fx/tc6/.github/workflows/x.yml" +set +e +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc6" >/dev/null 2>&1; rc_tc6=$? +set -e +check "a fixture in a top-level env: block scalar is text: its dtolnay line is untouched, nothing inserted" \ + "grep -A1 '000000000000000000000000000000000000000c' '$f6' | sed -n '2p' | grep -q '^jobs:$' && grep -q '^ - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000c$' '$f6'" +check "a multi-line flow with: naming a toolchain counts: untouched" \ + "grep -A1 '000000000000000000000000000000000000000d' '$f6' | sed -n '2p' | grep -q '^ with: {$' && [ \$(grep -c 'toolchain: nightly' '$f6') -eq 1 ]" +check "a multi-line flow with: without a toolchain gets it as its first entry" \ + "grep -A1 '000000000000000000000000000000000000000e' '$f6' | sed -n '2p' | grep -q '^ with: { toolchain: stable,$' && grep -A2 '000000000000000000000000000000000000000e' '$f6' | sed -n '3p' | grep -q '^ components: clippy,$'" +check "the real steps were fixed, the fixture was not, exit 0" \ + "[ \$rc_tc6 -eq 0 ] && [ \$(grep -c 'toolchain: stable' '$f6') -eq 2 ] && grep -A2 '000000000000000000000000000000000000000f' '$f6' | sed -n '2p' | grep -q '^ with:$'" +cp "$f6" "$fx/tc6-after1.yml" +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc6" >/dev/null 2>&1 +check "second run over these cases is a no-op" "cmp -s '$fx/tc6-after1.yml' '$f6'" # A flow-style step: refused out loud, file untouched, exit 2 — never "OK". mkdir -p "$fx/tc4/.github/workflows" printf 'jobs:\n a:\n steps:\n - { name: toolchain, uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000009 }\n - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000a\n' > "$fx/tc4/.github/workflows/x.yml" @@ -351,6 +391,47 @@ tar -C "$fx/sp6" -cf "$fx/sp6a.tar" .github bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp6" >/dev/null 2>&1; rc_sp6b=$? tar -C "$fx/sp6" -cf "$fx/sp6b.tar" .github check "kept-edge case: second run is a no-op and exits 0" "[ \$rc_sp6b -eq 0 ] && cmp -s '$fx/sp6a.tar' '$fx/sp6b.tar'" +# A flow-style job mapping carrying a known-dead pin: the parser cannot place +# it as a `uses` key, so the file is refused out loud — never "OK". +mkdir -p "$fx/sp7/.github/workflows" +printf 'jobs:\n mirror:\n uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@%s\n rust: { uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s }\n' "$STALE_A" "$STALE_A" > "$fx/sp7/.github/workflows/ci.yml" +cp "$fx/sp7/.github/workflows/ci.yml" "$fx/sp7-before.yml" +set +e +out7=$(bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp7" 2>&1); rc_sp7=$? +set -e +check "a flow-style job with a known-dead pin is UNSUPPORTED: named with its line, file untouched, exit 2, never OK" \ + "[ \$rc_sp7 -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:4:' <<<\"\$out7\" && ! grep -q '^OK' <<<\"\$out7\" && cmp -s '$fx/sp7-before.yml' '$fx/sp7/.github/workflows/ci.yml'" +# A nested `uses:` edge of ANOTHER record still pointing at the stale SHA (a +# reusable that itself calls the stale revision) keeps the stale record alive. +mkdir -p "$fx/sp8/.github/workflows" +printf 'jobs:\n governance:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STALE_B" > "$fx/sp8/.github/workflows/governance.yml" +cat > "$fx/sp8/.github/workflows/actions.lock" </dev/null 2>&1; rc_sp8=$? +set -e +l8="$fx/sp8/.github/workflows/actions.lock" +check "transitive edge: the caller's own edge moves, the other record's nested edge stays at the stale SHA" \ + "grep -A1 \"'.github/workflows/governance.yml':\" '$l8' | grep -q \"standards@$GOOD\" && grep -A6 \"'hyperpolymath/cicd-suite@1111\" '$l8' | grep -q \" - 'hyperpolymath/standards@$STALE_B'\"" +check "transitive edge: the stale record is kept beside the replacement's, exit 3" \ + "[ \$rc_sp8 -eq 3 ] && grep -q \"^ 'hyperpolymath/standards@$STALE_B':\" '$l8' && grep -q \"^ 'hyperpolymath/standards@$GOOD':\" '$l8'" # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── mkdir -p "$fx/ls/.github/workflows" @@ -394,6 +475,28 @@ cp "$fx/ls/.github/workflows/actions.lock" "$fx/ls1.lock" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls" --offline >/dev/null 2>&1 check "second run is a no-op and exits 0" "cmp -s '$fx/ls1.lock' '$fx/ls/.github/workflows/actions.lock'" check "a repository without a lock is OK, not an error" "mkdir -p '$fx/nolock' && bash '$fleet/scripts/fix-lock-self-sha-refs.sh' '$fx/nolock' --offline | grep -q 'OK (no actions.lock)'" +# A reusable source is whatever the workflows call as a reusable, not a +# hard-coded pair of names: its record keeps its SHA ref and is not "unresolved". +mkdir -p "$fx/ls4/.github/workflows" +printf 'jobs:\n corpus:\n uses: hyperpolymath/proven-tests-and-benches/.github/workflows/corpus-check.yml@2222222222222222222222222222222222222222\n' > "$fx/ls4/.github/workflows/ci.yml" +cat > "$fx/ls4/.github/workflows/actions.lock" <<'LOCK' +version: 'v0.0.3' +workflows: + '.github/workflows/ci.yml': + - 'hyperpolymath/proven-tests-and-benches@2222222222222222222222222222222222222222' +dependencies: + 'hyperpolymath/proven-tests-and-benches@2222222222222222222222222222222222222222': + ref: '2222222222222222222222222222222222222222' + commit: 'sha1-2222222222222222222222222222222222222222' + owner_id: 6759885 + repo_id: 2 +LOCK +cp "$fx/ls4/.github/workflows/actions.lock" "$fx/ls4-before.lock" +set +e +bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls4" --offline > "$fx/ls4.out" 2>&1; rc4=$? +set -e +check "a reusable source read from the workflows keeps its SHA ref: untouched, not UNRESOLVED, exit 0" \ + "[ \$rc4 -eq 0 ] && ! grep -q UNRESOLVED '$fx/ls4.out' && cmp -s '$fx/ls4-before.lock' '$fx/ls4/.github/workflows/actions.lock'" # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser From 6cd11aa5d1e57b3447cf8526487cb5e0ce132bc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:45:44 +0000 Subject: [PATCH 10/44] fix(fix-scripts): brace depth, per-revision lock classes, quoted records, safe tags, YAML context for stale pins fix-rust-toolchain-input.sh: - a flow `with: {` closes when its brace depth returns to zero, not at the first `}`: an expression `${{ matrix.x }}` inside it no longer hides a toolchain written after it (which would have been duplicated); - a `with:` whose value the parser does not read (`with: &anchor`, `with: *alias`) is UNSUPPORTED rather than shadowed by a second `with:`; - a one-line `steps: [ { uses: ... } ]` key line carrying a SHA pin is UNSUPPORTED instead of reporting the file clean. fix-lock-self-sha-refs.sh: - a record's class is per repository@revision, read from how the workflows use it: a reusable call keeps its SHA ref, a composite action from the same repository at another revision is repaired; records no workflow uses fall back to the two estate sources by name; - double-quoted record keys and refs are parsed and repaired with their quote style kept; - a resolved tag carrying `&`, `|` or a quote is refused (UNRESOLVED) rather than written through sed into YAML. fix-stale-reusable-pin.sh: - a YAML-context pass marks job-level `uses:` keys, data `uses:` keys and block-scalar lines; only a key of a job under `jobs:` is rewritten (per line), the same text under `env:` or inside a `run: |` fixture is left byte-identical and never moves the lock; a covered pin the parser cannot place is still UNSUPPORTED. tests: 11 new contracts (81 total), shellcheck clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 71 ++++++++++----- scripts/fix-rust-toolchain-input.sh | 31 +++++-- scripts/fix-stale-reusable-pin.sh | 94 +++++++++++++++----- scripts/tests/fix-workflow-pins.sh | 133 ++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+), 50 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index cb2a2805..7e035663 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -16,11 +16,12 @@ # a record died. standards main carried them from 2026-08-19 to 2026-10-19. # # What it does: for each record `'owner/repo@':` followed by -# `ref: ''`, where owner/repo is NOT a reusable-workflow source (a -# reusable record legitimately carries its SHA as ref; standards' accepted -# lock does — the sources are read from the workflows' own reusable `uses:` -# lines, plus hyperpolymath/standards and hyperpolymath/cicd-suite), look the -# commit up in scripts/lib/action-ref-tags.tsv, else (unless --offline) in +# `ref: ''` (either quote style), where the workflows use owner/repo at +# that revision as an ACTION — a reusable-workflow call at that revision +# legitimately keeps its SHA as ref, standards' accepted lock does; a record no +# workflow uses directly falls back to hyperpolymath/standards and +# hyperpolymath/cicd-suite by name — look the commit up in +# scripts/lib/action-ref-tags.tsv, else (unless --offline) in # `git ls-remote --tags https://github.com/owner/repo`, preferring the most # specific tag; rewrite the ref line. A commit with no tag is reported and # left alone — a wrong tag is worse than a known-bad one. @@ -57,17 +58,31 @@ lookup_tag() { # printf '%s' "$tag" } -# Reusable-workflow sources keep their SHA as ref (standards' accepted lock -# does). Which repositories those are is read from the workflows themselves — -# every `uses:` of `owner/repo/.github/workflows/.yml@` — plus the -# two estate sources, so a reusable from ANY repository is never mistaken for -# an action record and "repaired" into a ref GitHub would then reject. -reusable_sources=$(grep -rhoE "^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@" \ - --include='*.yml' --include='*.yaml' "$REPO_PATH/.github/workflows" 2>/dev/null \ - | sed -E "s/^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?//; s#/\.github/workflows/.*\$##" | sort -u || true) -is_reusable_source() { - case "$1" in hyperpolymath/standards|hyperpolymath/cicd-suite) return 0 ;; esac - grep -qxF "$1" <<<"$reusable_sources" +# A record's class is read from how the workflows USE that repository at that +# revision: `uses: owner/repo/.github/workflows/.yml@` is a reusable +# call (the record keeps its SHA ref — standards' accepted lock does), while +# `uses: owner/repo[/path]@` is an action (the SHA ref is the defect). +# One repository can be both at different revisions (cicd-suite ships gates +# AND composite actions), so the class is per repository@revision. A record no +# workflow uses directly (a nested dependency, a tree without workflows) falls +# back to the two estate reusable sources by name. +usage_of() { # -> reusable | action | unknown + local key="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$1" + local end="@$2([\"'[:space:]]|$)" + if grep -rqE "${key}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml${end}" --include='*.yml' --include='*.yaml' "$REPO_PATH/.github/workflows" 2>/dev/null; then + echo reusable + elif grep -rqE "${key}(/[A-Za-z0-9_./-]+)?${end}" --include='*.yml' --include='*.yaml' "$REPO_PATH/.github/workflows" 2>/dev/null; then + echo action + else + echo unknown + fi +} +keeps_sha_ref() { # + case "$(usage_of "$1" "$2")" in + reusable) return 0 ;; + action) return 1 ;; + *) case "$1" in hyperpolymath/standards|hyperpolymath/cicd-suite) return 0 ;; esac; return 1 ;; + esac } fixed=0; unresolved=0 @@ -76,28 +91,40 @@ tmp=$(mktemp); cp "$LOCK" "$tmp" # would look like "no records" and the script would report OK. POSIX awk only # (Debian/Ubuntu's default awk is mawk: no match() capture array, no interval # expressions), so the SHA is checked for content and length separately. +# A record key and its ref may be single- or double-quoted (gh actions-lock +# writes single quotes; a hand-added record — exactly where this defect +# comes from — may not), and the quote style is kept on the rewrite. records=$(mktemp) if ! awk ' - /^ '"'"'[^'"'"'@]+@[0-9a-f]+'"'"':$/ { - s = $0; sub(/^ '"'"'/, "", s); sub(/'"'"':$/, "", s) + /^ ["'"'"'][^"'"'"'@]+@[0-9a-f]+["'"'"']:$/ { + s = $0; sub(/^ ["'"'"']/, "", s); sub(/["'"'"']:$/, "", s) i = index(s, "@"); repo = substr(s, 1, i - 1); sha = substr(s, i + 1) if (length(sha) == 40) { key_line = NR; key_repo = repo; key_sha = sha; next } } - key_line && NR == key_line + 1 && $0 == " ref: '"'"'" key_sha "'"'"'" { print key_line "\t" key_repo "\t" key_sha } + key_line && NR == key_line + 1 && /^ ref: ["'"'"']/ { + r = $0; sub(/^ ref: /, "", r); gsub(/["'"'"']/, "", r) + if (r == key_sha) print key_line "\t" key_repo "\t" key_sha + } { key_line = 0 } ' "$LOCK" > "$records"; then echo "fix-lock-self-sha-refs: could not parse $LOCK (awk failed); lock left unchanged" >&2 rm -f "$tmp" "$records"; exit 2 fi while IFS=$'\t' read -r lineno repo sha; do - if is_reusable_source "$repo"; then continue; fi + if keeps_sha_ref "$repo" "$sha"; then continue; fi tag=$(lookup_tag "$repo" "$sha") if [ -z "$tag" ]; then echo "UNRESOLVED $repo@${sha:0:7}: no tag found for this commit (record left as is; GitHub rejects it)" >&2 unresolved=$((unresolved + 1)); continue fi - # The ref line is the one directly after the record key. - sed -i "$((lineno + 1))s|ref: '$sha'|ref: '$tag'|" "$tmp" + # A tag name Git allows may still carry `&`, `|` or a quote; such a tag is + # not written into a sed replacement or a YAML scalar from here. + if ! [[ "$tag" =~ ^[A-Za-z0-9][A-Za-z0-9._/+-]*$ ]]; then + echo "UNRESOLVED $repo@${sha:0:7}: resolved tag '$tag' carries characters this fixer will not write into the lock (record left as is)" >&2 + unresolved=$((unresolved + 1)); continue + fi + # The ref line is the one directly after the record key; its quote style is kept. + sed -i "$((lineno + 1))s|ref: \(['\"]\)$sha\1|ref: \1$tag\1|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 5381681e..3346e150 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -37,7 +37,9 @@ # * keys may be quoted (`"uses":`, "with":) — the same key to GitHub; # * a tag-pinned step (`@stable`, `@1.85.0`) and a step that already names a # toolchain are left alone; -# * a step written as a flow mapping (`- { name: x, uses: ... }`) is NOT +# * a step written as a flow mapping (`- { name: x, uses: ... }`), a +# one-line `steps: [ { uses: ... } ]`, or a `with:` whose value this +# parser does not read (`with: &anchor`, `with: *alias`) is NOT # rewritten: the script names the line as UNSUPPORTED and exits 2 rather # than report the file as clean. Fix it by hand. # Idempotent: a second run changes nothing. @@ -73,6 +75,10 @@ apply_to_file() { function key_indent_of(s, t) { t = s; sub(/[A-Za-z"'"'"'].*$/, "", t); gsub(/-/, " ", t); return t } function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } function spaces(n, t) { t = ""; while (n-- > 0) t = t " "; return t } + # Net brace depth of a line: a flow mapping closes when it returns to + # zero, not at the first `}` — an expression `${{ matrix.x }}` inside it + # opens and closes its own braces. + function brace_delta(s, a, b) { a = s; b = s; return gsub(/\{/, "", a) - gsub(/\}/, "", b) } # A 40-hex pin of the action on a non-comment line, trailing comment # stripped. `@stable` and `@1.85.0` are tags: the ref carries the # channel and the step is not ours. @@ -98,7 +104,7 @@ apply_to_file() { } # Emit the buffered step, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, j, line, ind, uses_i, has_with, with_i, with_flow, with_indent, in_with, in_flow, child_indent, has_tc, key_indent, skey, flow_item) { + function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` the indentation of the first plain line under it. @@ -120,14 +126,15 @@ apply_to_file() { unrec[++nunrec] = lnum[i] } if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } - has_with = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; in_flow = 0; child_indent = -1; has_tc = 0 + has_with = 0; with_bad = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; in_flow = 0; child_indent = -1; has_tc = 0 for (i = 1; i <= n; i++) { line = buf[i]; ind = indent_of(line) if (in_flow) { # A flow `with: {` spread over several lines: every entry up to - # the closing brace is an input. + # the brace that closes it is an input. if (line ~ TC_RE || line ~ TC_FLOW_RE) has_tc = 1 - if (index(line, "}")) in_flow = 0 + flow_depth += brace_delta(line) + if (flow_depth <= 0) in_flow = 0 continue } if (in_with) { @@ -145,11 +152,17 @@ apply_to_file() { } else if (line ~ WITH_FLOW_RE) { has_with = 1; with_i = i; with_flow = 1 if (line ~ TC_FLOW_RE) has_tc = 1 - if (!index(line, "}")) in_flow = 1 + flow_depth = brace_delta(line) + if (flow_depth > 0) in_flow = 1 + } else if (line ~ WITH_ANY_RE) { + # `with: &anchor`, `with: *alias`, or any value shape this + # parser does not read: a second `with:` would be worse than + # none, so the step is reported, not rewritten. + has_with = 1; with_bad = 1; unrec[++nunrec] = lnum[i] } } } - if (has_tc) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } + if (has_tc || with_bad) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } for (i = 1; i <= n; i++) { line = buf[i] if (has_with && with_flow && i == with_i) { @@ -182,6 +195,7 @@ apply_to_file() { USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-f]+" WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" + WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" TC_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:]]" TC_FLOW_RE = "[{,][[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:],}]" } @@ -209,6 +223,9 @@ apply_to_file() { if (plain($0) && $0 ~ KEY_RE) { record_key($0) if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } + # A key line that itself carries the pin (`steps: [ { uses: ... } ]` + # on one line) is a step this parser cannot place. + if (sha_ref($0)) unrec[++nunrec] = NR } else if (sha_ref($0) && parent_is_steps(ind)) unrec[++nunrec] = NR print } diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 1e39ec5e..36b36329 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -112,45 +112,97 @@ lookup_replacement() { # return 1 } +# The YAML context of a workflow, one marker per source line that matters: +# K line n is a `uses:` key of a job under `jobs:` — the one place a +# reusable is called; +# D line n is a `uses:` key under some other key (`env:`) — data; +# S line n is inside a block scalar (`run: |`, a fixture in `env:`) — text. +# A line with none of these that still carries a known-dead pin is one the +# parser cannot place (a flow-style job `rust: { uses: ... }`). +yaml_context() { # + awk ' + function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } + function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } + BEGIN { + Q = "[\"'"'"']?" + KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" + ITEM_KEY_RE = "^[[:space:]]*-[[:space:]]+" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" + SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" + } + { + ind = indent_of($0) + if (in_scalar) { if (plain($0) && ind <= scalar_indent) in_scalar = 0; else { print "S" NR; next } } + if (!plain($0)) next + if ($0 ~ KEY_RE) { + key = $0; sub(/^[[:space:]]*/, "", key); sub(/[[:space:]]*:.*$/, "", key); gsub(/["'"'"']/, "", key) + for (k in keyat) if (k + 0 >= ind) delete keyat[k] + if (key == "uses") { + p = -1; for (k in keyat) if (k + 0 < ind && k + 0 > p) p = k + 0 + g = -1; for (k in keyat) if (k + 0 < p && k + 0 > g) g = k + 0 + if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "K" NR; else print "D" NR + } + keyat[ind] = key + if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } + } else if ($0 ~ ITEM_KEY_RE && $0 ~ SCALAR_RE) { + # `- run: |`: the scalar hangs off the item'"'"'s key column. + in_scalar = 1; scalar_indent = ind + 2 + } + } + ' "$1" +} + # Every `uses:` of an estate reusable at a full SHA, across the workflows. -# Only a YAML `uses` KEY counts — the key at the start of the line (quoted or -# not, list dash or not); the same text after a `#` is a comment, not a -# caller, and must not move anything. A covered pin the parser cannot place -# that way (a flow-style job `rust: { uses: ... }`) makes the whole file -# UNSUPPORTED: left untouched, named on stderr, exit 2 — never "OK". The lock -# is edited after the loop, once every workflow rewrite is known. +# Only a `uses:` that is a key of a job under `jobs:` is a call: the same text +# under `env:`, inside a `run: |` script or a fixture, or after a `#` is data +# and must not move anything. A covered pin the parser cannot place at all (a +# flow-style job `rust: { uses: ... }`) makes the whole file UNSUPPORTED: left +# untouched, named on stderr, exit 2 — never "OK". The lock is edited after +# the loop, once every workflow rewrite is known. USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?" REUSABLE_REF_RE="hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edge unsupported=0 while IFS= read -r -d '' wf; do + ctx=$(yaml_context "$wf") + klines=$(grep -o '^K[0-9]*' <<<"$ctx" | tr -d K | sort -n || true) + placed=$(grep -oE '^[KDS][0-9]*' <<<"$ctx" | tr -d KDS || true) skip=0 while IFS= read -r numbered; do - lineno="${numbered%%:*}"; ref=$(grep -oE "$REUSABLE_REF_RE" <<<"${numbered#*:}" | head -1) + lineno="${numbered%%:*}" + grep -qx "$lineno" <<<"$placed" && continue + ref=$(grep -oE "$REUSABLE_REF_RE" <<<"${numbered#*:}" | head -1) [ -n "$ref" ] || continue if lookup_replacement "${ref%@*}" "${ref##*@}"; then - echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key at the start of its line (flow-style job mapping?). Fix by hand; the file was not changed." >&2 + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 skip=1 fi - done < <(grep -nE "$REUSABLE_REF_RE" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" | grep -vE "^[0-9]+:${USES_KEY_RE#^}" || true) + done < <(grep -nE "$REUSABLE_REF_RE" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi - while IFS= read -r line; do - path="${line%@*}"; stale="${line##*@}" - [[ "$stale" =~ ^[0-9a-f]{40}$ ]] || continue + pending=() + for lineno in $klines; do + line=$(sed -n "${lineno}p" "$wf") + ref=$(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}" <<<"$line" | sed -E "s/${USES_KEY_RE}//" | head -1) + [ -n "$ref" ] || continue + path="${ref%@*}"; stale="${ref##*@}" lookup_replacement "$path" "$stale" || continue [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } - sed -i "s|^\([[:space:]]*\(-[[:space:]]\+\)\?[\"']\?uses[\"']\?[[:space:]]*:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" - echo "FIXED ${wf#"$REPO_PATH"/} $path ${stale:0:7} -> ${replacement:0:7} ($reason)" + sed -i "${lineno}s|^\([[:space:]]*\(-[[:space:]]\+\)\?[\"']\?uses[\"']\?[[:space:]]*:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" + echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason)" rewrites=$((rewrites + 1)) - # A lock edge is per repository@SHA, shared by every reusable of that - # repository the workflow calls. When this workflow still calls another - # reusable of the same repository at the stale SHA (a pin the table does - # not cover), the stale edge must stay next to the new one. - repo_prefix="${path%%/.github/workflows/*}" + pending+=("$stale"$'\t'"$replacement"$'\t'"${path%%/.github/workflows/*}") + done + # A lock edge is per repository@SHA, shared by every reusable of that + # repository the workflow calls. When a job of this workflow still calls + # another reusable of the same repository at the stale SHA (a pin the + # table does not cover), the stale edge must stay next to the new one. + for entry in ${pending[@]+"${pending[@]}"}; do + IFS=$'\t' read -r stale replacement repo_prefix <<<"$entry" keep=0 - grep -qE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" "$wf" && keep=1 + for other in $klines; do + sed -n "${other}p" "$wf" | grep -qE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" && keep=1 + done printf '%s\t%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" "$keep" >> "$edits" - done < <(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}" "$wf" | sed -E "s/${USES_KEY_RE}//" | sort -u) + done done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) # Lock: move each rewritten workflow's own `workflows:` edges first. diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 43ff925d..b9da503d 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -218,6 +218,55 @@ check "the real steps were fixed, the fixture was not, exit 0" \ cp "$f6" "$fx/tc6-after1.yml" bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc6" >/dev/null 2>&1 check "second run over these cases is a no-op" "cmp -s '$fx/tc6-after1.yml' '$f6'" +# An expression inside a multi-line flow with: (`${{ matrix.component }}`) +# opens and closes its own braces: the mapping ends when the depth returns to +# zero, so a toolchain written after the expression still counts. +mkdir -p "$fx/tc7/.github/workflows" +cat > "$fx/tc7/.github/workflows/x.yml" <<'YML' +jobs: + a: + steps: + - uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000010 + with: { + components: ${{ matrix.component }}, + toolchain: nightly, + } + - uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000011 + with: { + components: ${{ matrix.component }}, + } +YML +f7="$fx/tc7/.github/workflows/x.yml" +set +e +bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc7" >/dev/null 2>&1; rc_tc7=$? +set -e +check "a toolchain after an expression's braces in a multi-line flow with: counts: untouched" \ + "grep -A1 '0000000000000000000000000000000000000010' '$f7' | sed -n '2p' | grep -q '^ with: {$' && [ \$(grep -c 'toolchain: nightly' '$f7') -eq 1 ]" +check "the same shape without a toolchain gets it as first entry, the expression kept" \ + "[ \$rc_tc7 -eq 0 ] && grep -A1 '0000000000000000000000000000000000000011' '$f7' | sed -n '2p' | grep -q '^ with: { toolchain: stable,$' && [ \$(grep -c 'components: \${{ matrix.component }},' '$f7') -eq 2 ] && [ \$(grep -c 'toolchain: stable' '$f7') -eq 1 ]" +# A `with:` whose value the parser does not read (an anchor, an alias) and a +# one-line `steps: [ { uses: ... } ]` are refused out loud, file untouched. +mkdir -p "$fx/tc8/.github/workflows" "$fx/tc9/.github/workflows" +cat > "$fx/tc8/.github/workflows/x.yml" <<'YML' +jobs: + a: + steps: + - uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000012 + with: &rust-inputs + components: clippy + - uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000013 + with: *rust-inputs +YML +printf 'jobs:\n a:\n steps: [ { uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000014 } ]\n' > "$fx/tc9/.github/workflows/x.yml" +cp "$fx/tc8/.github/workflows/x.yml" "$fx/tc8-before.yml"; cp "$fx/tc9/.github/workflows/x.yml" "$fx/tc9-before.yml" +set +e +out8=$(bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc8" 2>&1); rc_tc8=$? +out9=$(bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc9" 2>&1); rc_tc9=$? +set -e +check "with: &anchor and with: *alias are UNSUPPORTED (no second with:), file untouched, exit 2" \ + "[ \$rc_tc8 -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 5 8' <<<\"\$out8\" && cmp -s '$fx/tc8-before.yml' '$fx/tc8/.github/workflows/x.yml'" +check "a one-line steps: [ { uses: ... } ] is UNSUPPORTED, file untouched, exit 2, never OK" \ + "[ \$rc_tc9 -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 3' <<<\"\$out9\" && ! grep -q '^OK' <<<\"\$out9\" && cmp -s '$fx/tc9-before.yml' '$fx/tc9/.github/workflows/x.yml'" # A flow-style step: refused out loud, file untouched, exit 2 — never "OK". mkdir -p "$fx/tc4/.github/workflows" printf 'jobs:\n a:\n steps:\n - { name: toolchain, uses: dtolnay/rust-toolchain@0000000000000000000000000000000000000009 }\n - uses: dtolnay/rust-toolchain@000000000000000000000000000000000000000a\n' > "$fx/tc4/.github/workflows/x.yml" @@ -432,6 +481,41 @@ check "transitive edge: the caller's own edge moves, the other record's nested e "grep -A1 \"'.github/workflows/governance.yml':\" '$l8' | grep -q \"standards@$GOOD\" && grep -A6 \"'hyperpolymath/cicd-suite@1111\" '$l8' | grep -q \" - 'hyperpolymath/standards@$STALE_B'\"" check "transitive edge: the stale record is kept beside the replacement's, exit 3" \ "[ \$rc_sp8 -eq 3 ] && grep -q \"^ 'hyperpolymath/standards@$STALE_B':\" '$l8' && grep -q \"^ 'hyperpolymath/standards@$GOOD':\" '$l8'" +# Only a `uses:` key of a job is a call: the same text under `env:` or inside +# a `run: |` fixture is data, left byte-identical, and never moves the lock. +mkdir -p "$fx/sp9/.github/workflows" +cat > "$fx/sp9/.github/workflows/ci.yml" < caller.yml <<'EOF' + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@$STALE_B + EOF + governance: + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@$STALE_B +YML +cat > "$fx/sp9/.github/workflows/actions.lock" </dev/null 2>&1; rc_sp9=$? +set -e +check "only the job-level call moves; the env: copy and the run: | fixture line are untouched" \ + "sed -n '11p' '$fx/sp9/.github/workflows/ci.yml' | grep -q \"governance-reusable.yml@$GOOD\" && sed -n '4p;8p' '$fx/sp9/.github/workflows/ci.yml' | grep -c \"@$STALE_B\" | grep -qx 2" +check "the lock follows the call: edge and record at the replacement, stale gone, exit 3" \ + "[ \$rc_sp9 -eq 3 ] && grep -q \"hyperpolymath/standards@$GOOD\" '$fx/sp9/.github/workflows/actions.lock' && ! grep -q \"$STALE_B\" '$fx/sp9/.github/workflows/actions.lock'" # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── mkdir -p "$fx/ls/.github/workflows" @@ -497,6 +581,55 @@ bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls4" --offline > "$fx/ls4.o set -e check "a reusable source read from the workflows keeps its SHA ref: untouched, not UNRESOLVED, exit 0" \ "[ \$rc4 -eq 0 ] && ! grep -q UNRESOLVED '$fx/ls4.out' && cmp -s '$fx/ls4-before.lock' '$fx/ls4/.github/workflows/actions.lock'" +# One repository, two revisions, two classes: a reusable call keeps its SHA +# ref; a composite action from the same repository at another revision is +# repaired. The class is per repository@revision, read from the workflows. +SHA_A=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; SHA_B=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +mkdir -p "$fx/ls5/.github/workflows" +printf 'jobs:\n gate:\n uses: hyperpolymath/cicd-suite/.github/workflows/rust-ci.yml@%s\n check:\n steps:\n - uses: hyperpolymath/cicd-suite/actions/manifest-check@%s\n' "$SHA_A" "$SHA_B" > "$fx/ls5/.github/workflows/ci.yml" +cat > "$fx/ls5/.github/workflows/actions.lock" < "$fx/ls5-tags.tsv" +set +e +TAG_MAP="$fx/ls5-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls5" --offline > "$fx/ls5.out" 2>&1; rc5=$? +set -e +check "same repository, two revisions: the reusable call keeps its SHA ref, the action record gets its tag" \ + "[ \$rc5 -eq 0 ] && grep -A1 \"'hyperpolymath/cicd-suite@$SHA_A':\" '$fx/ls5/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_A'\" && grep -A1 \"'hyperpolymath/cicd-suite@$SHA_B':\" '$fx/ls5/.github/workflows/actions.lock' | grep -q \"ref: 'v1.2.3'\" && ! grep -q UNRESOLVED '$fx/ls5.out'" +# A double-quoted record is the same record: repaired, quote style kept. +mkdir -p "$fx/ls6/.github/workflows" +printf 'version: "v0.0.3"\nworkflows:\n ".github/workflows/ci.yml":\n - "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"\ndependencies:\n "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1":\n ref: "3d3c42e5aac5ba805825da76410c181273ba90b1"\n commit: "sha1-3d3c42e5aac5ba805825da76410c181273ba90b1"\n owner_id: 44036562\n repo_id: 197814629\n' > "$fx/ls6/.github/workflows/actions.lock" +set +e +bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls6" --offline > "$fx/ls6.out" 2>&1; rc6=$? +set -e +check "a double-quoted self-SHA record is parsed and repaired, keeping its quotes" \ + "[ \$rc6 -eq 0 ] && grep -q 'ref: \"v7.0.1\"' '$fx/ls6/.github/workflows/actions.lock' && grep -q 'FIXED actions/checkout' '$fx/ls6.out'" +# A tag Git allows but this fixer will not write (`&`, `|`, a quote) is refused. +SHA_C=cccccccccccccccccccccccccccccccccccccccc +mkdir -p "$fx/ls7/.github/workflows" +printf "version: 'v0.0.3'\nworkflows: {}\ndependencies:\n 'nobody/weird@%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: 1\n repo_id: 1\n" "$SHA_C" "$SHA_C" "$SHA_C" > "$fx/ls7/.github/workflows/actions.lock" +printf 'nobody/weird\t%s\trelease&stable\n' "$SHA_C" > "$fx/ls7-tags.tsv" +cp "$fx/ls7/.github/workflows/actions.lock" "$fx/ls7-before.lock" +set +e +TAG_MAP="$fx/ls7-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls7" --offline > "$fx/ls7.out" 2>&1; rc7=$? +set -e +check "a tag with characters the fixer will not write is UNRESOLVED, the record untouched, exit 1" \ + "[ \$rc7 -eq 1 ] && grep -q 'carries characters' '$fx/ls7.out' && cmp -s '$fx/ls7-before.lock' '$fx/ls7/.github/workflows/actions.lock'" # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser From 620091fea43a137f1a9c8d846eee5bf04ccef9f5 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:48 +0000 Subject: [PATCH 11/44] docs(fix-scripts): document workflow pin repair helpers and test utilities --- scripts/fix-lock-self-sha-refs.sh | 9 ++++++++- scripts/fix-rust-toolchain-input.sh | 7 +++++++ scripts/fix-stale-reusable-pin.sh | 12 +++++++++--- scripts/tests/fix-workflow-pins.sh | 8 ++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 7e035663..77dc3b3b 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -44,9 +44,11 @@ LOCK="$REPO_PATH/.github/workflows/actions.lock" [ -f "$LOCK" ] || { echo "OK (no actions.lock): $REPO_PATH"; exit 0; } -# Prefer the most specific tag: more dotted numeric components, then longer. +# Read tag names from stdin and print the most specific: more dots, then longer. best_tag() { awk '{ n = gsub(/\./, ".", $0); print n "\t" length($0) "\t" $0 }' | sort -t $'\t' -k1,1nr -k2,2nr | head -1 | cut -f3; } +# Print the tag for owner/repo $1 at SHA $2 from TAG_MAP, falling back to +# remote tags unless OFFLINE is set. Print nothing when no tag is found. lookup_tag() { # local repo="$1" sha="$2" tag tag=$(awk -F'\t' -v r="$repo" -v s="$sha" '$1 == r && $2 == s { print $3; exit }' "$TAG_MAP" 2>/dev/null || true) @@ -66,6 +68,8 @@ lookup_tag() { # # AND composite actions), so the class is per repository@revision. A record no # workflow uses directly (a nested dependency, a tree without workflows) falls # back to the two estate reusable sources by name. +# For owner/repo $1 at SHA $2, print reusable, action, or unknown; reusable +# takes precedence when both kinds of use exist. usage_of() { # -> reusable | action | unknown local key="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$1" local end="@$2([\"'[:space:]]|$)" @@ -77,6 +81,9 @@ usage_of() { # -> reusable | action | unknown echo unknown fi } + +# Return 0 if owner/repo $1 at SHA $2 should keep its SHA ref, or 1 otherwise. +# Use workflow classification, then the estate source fallback for unknown use. keeps_sha_ref() { # case "$(usage_of "$1" "$2")" in reusable) return 0 ;; diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 3346e150..6f632f7f 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -62,6 +62,9 @@ set -euo pipefail TOOLCHAIN="${TOOLCHAIN:-stable}" TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" +# Add missing TOOLCHAIN inputs to SHA-pinned Rust toolchain steps in file $1. +# Return 0 for a repair or no-op; return 2 and leave the file untouched if +# parsing fails or a pinned reference uses an unsupported layout. apply_to_file() { local f="$1" tmp rc [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } @@ -70,10 +73,13 @@ apply_to_file() { tmp=$(mktemp) set +e awk -v toolchain="$TOOLCHAIN" ' + # Return the number of leading spaces in source line s. function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } # Column of a key, with a list dash counted as indentation: ` - with:` -> 4. function key_indent_of(s, t) { t = s; sub(/[A-Za-z"'"'"'].*$/, "", t); gsub(/-/, " ", t); return t } + # Return true when s is neither blank nor a full-line comment. function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } + # Return a string of n spaces for an inserted YAML line. function spaces(n, t) { t = ""; while (n-- > 0) t = t " "; return t } # Net brace depth of a line: a flow mapping closes when it returns to # zero, not at the first `}` — an expression `${{ matrix.x }}` inside it @@ -97,6 +103,7 @@ apply_to_file() { for (k in keyat) if (k + 0 > ind) delete keyat[k] keyat[ind] = t } + # Return true if the nearest recorded key at or above ind is steps. function parent_is_steps(ind, k, best) { best = -1 for (k in keyat) if (k + 0 <= ind && k + 0 > best) best = k + 0 diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 36b36329..8221a160 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -56,11 +56,13 @@ rewrites=0; lock_touched=0 # so after substitution the lock can hold the same dependency key twice. A # duplicate key is a lockfile GitHub refuses. Keep the first record, fold the # others' nested `uses:` into it (deduplicated, sorted as gh actions-lock sorts), -# and drop them. gh-actions-lock's 4-space layout is preserved. +# and drop them. Rewrite lockfile $1 in place, preserving gh-actions-lock's +# 4-space layout and leaving the file alone when the output is identical. merge_duplicate_records() { local lock="$1" tmp tmp=$(mktemp) awk ' + # Print dependency cur using its saved header and sorted, unique uses. function emit_record( i, n, out) { if (!cur) return # Rebuild the record: header lines, then a sorted, unique uses: list. @@ -98,7 +100,9 @@ merge_duplicate_records() { if ! cmp -s "$lock" "$tmp"; then mv "$tmp" "$lock"; else rm -f "$tmp"; fi } -# The table row for a reusable path at a stale SHA: sets replacement/reason. +# Find the first PINS_TABLE row for reusable path $1 at stale SHA $2, including +# wildcard paths. Set replacement/reason and return 0 on a match; otherwise +# clear both variables and return 1. lookup_replacement() { # local t_path t_stale t_new t_reason replacement=""; reason="" @@ -112,7 +116,7 @@ lookup_replacement() { # return 1 } -# The YAML context of a workflow, one marker per source line that matters: +# Print YAML context markers for workflow file $1, one per line that matters: # K line n is a `uses:` key of a job under `jobs:` — the one place a # reusable is called; # D line n is a `uses:` key under some other key (`env:`) — data; @@ -121,7 +125,9 @@ lookup_replacement() { # # parser cannot place (a flow-style job `rust: { uses: ... }`). yaml_context() { # awk ' + # Return the number of leading spaces in source line s. function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } + # Return true when s is neither blank nor a full-line comment. function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } BEGIN { Q = "[\"'"'"']?" diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index b9da503d..fb596476 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -10,8 +10,14 @@ fleet="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" fx="$(mktemp -d)" trap 'rm -rf "$fx"' EXIT pass=0; fail=0 + +# Increment the pass count and print assertion label $1 to stdout. ok() { pass=$((pass + 1)); echo " ok $1"; } + +# Increment the failure count and print assertion label $1 to stderr. bad() { fail=$((fail + 1)); echo " FAIL $1" >&2; } + +# Evaluate trusted fixture assertion $2 and record its result under label $1. check(){ if eval "$2"; then ok "$1"; else bad "$1"; fi; } # ── 1. fix-rust-toolchain-input.sh ─────────────────────────────────────────── @@ -93,6 +99,8 @@ jobs: YML f2="$fx/tc2/.github/workflows/rust.yml" bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc2" >/dev/null 2>&1 +# Print each match of pattern $1 in fixture $f2 with $2 following lines, +# merging overlapping groups as grep -A does; return grep's exit status. after() { grep -A"$2" -- "$1" "$f2"; } # the N lines after the first match check "a with: with a trailing comment is reused: toolchain: goes under it, no second with:" \ "after 6c977a6c 2 | sed -n '2p' | grep -q '^ with: # pinned inputs$' && after 6c977a6c 2 | sed -n '3p' | grep -q '^ toolchain: stable$'" From f1ddd2a5a57e76d4c3eb72860b9093d764e4253c Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:58:04 +0000 Subject: [PATCH 12/44] test(fix-scripts): expand workflow pin fixer coverage for configuration, tag resolution, failure handling, and registry routes --- scripts/tests/fix-workflow-pins.sh | 280 +++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index fb596476..b06cf323 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -9,6 +9,8 @@ set -euo pipefail fleet="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" fx="$(mktemp -d)" trap 'rm -rf "$fx"' EXIT +# Keep the fixers' own temporary files inside the fixture, including failures. +export TMPDIR="$fx" pass=0; fail=0 # Increment the pass count and print assertion label $1 to stdout. @@ -19,6 +21,8 @@ bad() { fail=$((fail + 1)); echo " FAIL $1" >&2; } # Evaluate trusted fixture assertion $2 and record its result under label $1. check(){ if eval "$2"; then ok "$1"; else bad "$1"; fi; } +# Capture both channels and the exit status, including expected failures. +run_fix() { rc=0; out=$("$@" 2>&1) || rc=$?; } # ── 1. fix-rust-toolchain-input.sh ─────────────────────────────────────────── mkdir -p "$fx/tc/.github/workflows" @@ -285,6 +289,62 @@ set -e check "a flow-style step is UNSUPPORTED: named with its line, file untouched, exit 2, never OK" \ "[ \$rc_tc4 -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 4' <<<\"\$out4\" && ! grep -q '^OK' <<<\"\$out4\" && cmp -s '$fx/tc4-before.yml' '$fx/tc4/.github/workflows/x.yml'" +# Custom toolchains must work with empty mappings, quoted keys, reordered +# inputs, .yaml files and paths containing spaces. Existing expressions stay. +tc_custom="$fx/tc custom/.github/workflows" +mkdir -p "$tc_custom" +cat > "$tc_custom/rust.yaml" <<'YML' +jobs: + check: + steps: + - 'uses': 'dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + 'with': { } + - uses: dtolnay/rust-toolchain@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + with: # intentionally empty + - uses: dtolnay/rust-toolchain@cccccccccccccccccccccccccccccccccccccccc + with: + toolchain: ${{ matrix.rust }} + - with: {} + uses: dtolnay/rust-toolchain@dddddddddddddddddddddddddddddddddddddddd +YML +cat > "$fx/tc-custom-expected.yaml" <<'YML' +jobs: + check: + steps: + - 'uses': 'dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + 'with': { toolchain: 1.85.0 } + - uses: dtolnay/rust-toolchain@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + with: # intentionally empty + toolchain: 1.85.0 + - uses: dtolnay/rust-toolchain@cccccccccccccccccccccccccccccccccccccccc + with: + toolchain: ${{ matrix.rust }} + - with: { toolchain: 1.85.0 } + uses: dtolnay/rust-toolchain@dddddddddddddddddddddddddddddddddddddddd +YML +run_fix env TOOLCHAIN=1.85.0 bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc custom" +check "custom toolchain fills empty inputs and preserves expressions, quotes and comments exactly" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc-custom-expected.yaml' '$tc_custom/rust.yaml'" +run_fix env TOOLCHAIN=beta bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$tc_custom/rust.yaml" +check "direct-file mode preserves existing inputs even when the requested toolchain changes" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc-custom-expected.yaml' '$tc_custom/rust.yaml'" + +# An unsupported file must not prevent a supported sibling from being fixed, +# and a sibling's success must not hide the overall error. Nested files and +# files without a YAML extension are outside repository-mode discovery. +tc_mixed="$fx/tc-mixed/.github/workflows" +mkdir -p "$tc_mixed/nested" +cp "$fx/tc4-before.yml" "$tc_mixed/a-unsupported.yml" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' > "$tc_mixed/z-supported.yaml" +cp "$tc_mixed/z-supported.yaml" "$tc_mixed/nested/ignored.yml" +cp "$tc_mixed/z-supported.yaml" "$tc_mixed/ignored.txt" +cp "$tc_mixed/z-supported.yaml" "$fx/tc-mixed-before.yaml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc-mixed" +check "repository mode fixes a supported .yaml sibling while returning UNSUPPORTED for another file" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*a-unsupported.yml' <<<\"\$out\" && grep -q '^ toolchain: stable$' '$tc_mixed/z-supported.yaml' && cmp -s '$fx/tc4-before.yml' '$tc_mixed/a-unsupported.yml'" +check "repository mode leaves nested workflows and non-YAML files byte-identical" \ + "cmp -s '$fx/tc-mixed-before.yaml' '$tc_mixed/nested/ignored.yml' && cmp -s '$fx/tc-mixed-before.yaml' '$tc_mixed/ignored.txt'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -525,6 +585,96 @@ check "only the job-level call moves; the env: copy and the run: | fixture line check "the lock follows the call: edge and record at the replacement, stale gone, exit 3" \ "[ \$rc_sp9 -eq 3 ] && grep -q \"hyperpolymath/standards@$GOOD\" '$fx/sp9/.github/workflows/actions.lock' && ! grep -q \"$STALE_B\" '$fx/sp9/.github/workflows/actions.lock'" +# A private table selects the replacement, independent of the shipped estate +# history. A wildcard is scoped to its repository and unknown SHAs stay put. +sp_custom="$fx/sp custom/.github/workflows" +mkdir -p "$sp_custom" +printf '# fixture table\n\nhyperpolymath/example/.github/workflows/*\t%s\t%s\tfixture replacement\n' \ + "$STALE_A" "$GOOD" > "$fx/custom-pins.tsv" +cat > "$sp_custom/ci.yaml" < "$fx/sp-custom-expected.yaml" < "$fx/sp-tag/.github/workflows/ci.yaml" +cp "$fx/sp-tag/.github/workflows/ci.yaml" "$fx/sp-tag-before.yaml" +run_fix env PINS_TABLE="$fx/custom-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-tag" +check "a tag-pinned reusable is a successful no-op (observed exit $rc)" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/sp-tag-before.yaml' '$fx/sp-tag/.github/workflows/ci.yaml'" + +# Invalid configuration must fail before either the caller or its lock moves. +sp_invalid="$fx/sp-invalid/.github/workflows" +mkdir -p "$sp_invalid" +cp "$fx/sp0/.github/workflows/rust-ci.yml" "$sp_invalid/rust-ci.yml" +cp "$fx/sp5/.github/workflows/actions.lock" "$sp_invalid/actions.lock" +cp "$sp_invalid/actions.lock" "$fx/sp-invalid-before.lock" +run_fix env PINS_TABLE="$fx/missing-table.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-invalid" +check "a missing table exits 2 with a diagnostic and preserves the workflow and lock" \ + "[ \$rc -eq 2 ] && grep -q 'table not readable' <<<\"\$out\" && cmp -s '$fx/sp0/.github/workflows/rust-ci.yml' '$sp_invalid/rust-ci.yml' && cmp -s '$fx/sp-invalid-before.lock' '$sp_invalid/actions.lock'" +for replacement in 123456789012345678901234567890123456789 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA stable; do + printf 'hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml\t%s\t%s\tinvalid replacement\n' \ + "$STALE_A" "$replacement" > "$fx/invalid-pins.tsv" + run_fix env PINS_TABLE="$fx/invalid-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-invalid" + check "invalid replacement $replacement exits 2 without changing caller or lock" \ + "[ \$rc -eq 2 ] && grep -q 'bad replacement SHA' <<<\"\$out\" && cmp -s '$fx/sp0/.github/workflows/rust-ci.yml' '$sp_invalid/rust-ci.yml' && cmp -s '$fx/sp-invalid-before.lock' '$sp_invalid/actions.lock'" +done +run_fix env PINS_TABLE="$fx/empty.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/no-workflows" +check "a repository without workflows is a successful no-op" \ + "[ \$rc -eq 0 ] && grep -q 'OK (no workflows)' <<<\"\$out\" && [ ! -e '$fx/no-workflows' ]" + +# Exit 2 takes precedence over the exit 3 warning from a successfully repaired +# sibling's lock. The unsupported workflow and its stale edge must survive. +sp_mixed="$fx/sp-mixed/.github/workflows" +mkdir -p "$sp_mixed" +cp "$fx/sp7-before.yml" "$sp_mixed/ci.yml" +cp "$fx/sp0/.github/workflows/rust-ci.yml" "$sp_mixed/rust-ci.yaml" +cat > "$sp_mixed/actions.lock" < "$fx/ls/.github/workflows/actions.lock" <<'LOCK' @@ -639,6 +789,117 @@ set -e check "a tag with characters the fixer will not write is UNRESOLVED, the record untouched, exit 1" \ "[ \$rc7 -eq 1 ] && grep -q 'carries characters' '$fx/ls7.out' && cmp -s '$fx/ls7-before.lock' '$fx/ls7/.github/workflows/actions.lock'" +# Exercise tag resolution through a local Git stub: even the online branch +# stays offline, and the call log proves when a lookup did or did not occur. +write_action_lock() { # + cat > "$1" < "$fx/git-bin/git" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$PIN_TEST_GIT_LOG" +if [ "$#" -ne 3 ] || [ "$1" != ls-remote ] || [ "$2" != --tags ] || [ "$3" != https://github.com/fixture/action ]; then + exit 99 +fi +cat "$PIN_TEST_GIT_RESPONSE" +exit "${PIN_TEST_GIT_STATUS:-0}" +SH +chmod +x "$fx/git-bin/git" +: > "$fx/git-calls" +cat > "$fx/git-response" < "$fx/lookup-tags.tsv" +write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_A" +write_action_lock "$fx/lookup-expected.lock" fixture/action "$SHA_A" release/v3.2.1+build-4 +run_lookup() { + run_fix env PATH="$fx/git-bin:$PATH" PIN_TEST_GIT_LOG="$fx/git-calls" \ + PIN_TEST_GIT_RESPONSE="$fx/git-response" TAG_MAP="$fx/lookup-tags.tsv" "$@" \ + bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$lookup_repo" "${lookup_mode[@]}" +} +lookup_mode=() +run_lookup +check "tag map matches repository and revision exactly, accepts safe punctuation and avoids Git" \ + "[ \$rc -eq 0 ] && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/lookup-expected.lock' '$lookup_lock'" + +# Empty map: --offline refuses the unresolved record without consulting Git. +: > "$fx/lookup-tags.tsv" +write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_A" +cp "$lookup_lock" "$fx/lookup-before.lock" +lookup_mode=(--offline) +run_lookup +check "offline mode never calls Git, leaves an unknown record byte-identical and exits 1" \ + "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED fixture/action' <<<\"\$out\" && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/lookup-before.lock' '$lookup_lock'" + +# The highest specificity wins before length; an annotated tag resolves via +# its peeled commit, and unrelated commits must not influence the choice. +lookup_mode=() +write_action_lock "$fx/lookup-expected.lock" fixture/action "$SHA_A" v2.4.7 +run_lookup +check "Git fallback chooses the most specific matching tag including peeled annotated tags" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/lookup-expected.lock' '$lookup_lock'" +printf '%s\n' 'ls-remote --tags https://github.com/fixture/action' > "$fx/expected-git-calls" +check "Git fallback requests only the action repository's tags, once" \ + "cmp -s '$fx/expected-git-calls' '$fx/git-calls'" +: > "$fx/git-calls" +run_lookup +check "resolved lock is idempotent and does not perform another Git lookup" \ + "[ \$rc -eq 0 ] && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/lookup-expected.lock' '$lookup_lock'" + +# A transport failure and a successful response containing no matching commit +# both leave the lock unchanged and explicitly unresolved. +printf '%s\trefs/tags/v99.99.99\n' "$SHA_C" > "$fx/git-response" +for git_status in 0 128; do + write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_A" + : > "$fx/git-calls" + run_lookup PIN_TEST_GIT_STATUS="$git_status" + check "Git response with no usable tag (status $git_status) exits 1 and preserves the lock" \ + "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED fixture/action' <<<\"\$out\" && cmp -s '$fx/expected-git-calls' '$fx/git-calls' && cmp -s '$fx/lookup-before.lock' '$lookup_lock'" +done + +# Sed/YAML metacharacters must never enter a lock, even from a local map. +lookup_mode=(--offline) +: > "$fx/git-calls" +for unsafe_tag in 'release|stable' "release'candidate" 'release"candidate' 'release&stable'; do + write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_A" + printf 'fixture/action\t%s\t%s\n' "$SHA_A" "$unsafe_tag" > "$fx/lookup-tags.tsv" + run_lookup + check "unsafe mapped tag $unsafe_tag is refused without changing the lock" \ + "[ \$rc -eq 1 ] && grep -q 'carries characters' <<<\"\$out\" && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/lookup-before.lock' '$lookup_lock'" +done + +# Similar-looking records are non-defects: a 41-digit key and a full SHA key +# whose ref names another SHA are not self-SHA action records. +write_action_lock "$lookup_lock" fixture/action "${SHA_A}a" "${SHA_A}a" +cp "$lookup_lock" "$fx/non-self-before.lock" +run_lookup +check "a 41-hex record key is untouched and never reported unresolved" \ + "[ \$rc -eq 0 ] && ! grep -q UNRESOLVED <<<\"\$out\" && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/non-self-before.lock' '$lookup_lock'" +write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_B" +cp "$lookup_lock" "$fx/non-self-before.lock" +run_lookup +check "a ref different from its record's SHA is untouched" \ + "[ \$rc -eq 0 ] && ! grep -q UNRESOLVED <<<\"\$out\" && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/non-self-before.lock' '$lookup_lock'" + # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser # cannot read instead of reporting OK. @@ -666,5 +927,24 @@ set -e check "a parser failure exits 2, says so, and leaves the lock untouched (never OK)" \ "[ \$rc3 -eq 2 ] && grep -q 'could not parse' '$fx/ls3.out' && ! grep -q '^OK' '$fx/ls3.out' && cmp -s '$fx/ls3.lock' '$fx/ls3/.github/workflows/actions.lock'" +# ── 4. Registry routes for the three new fixers ───────────────────────────── +# jq is also used by dispatch-paths.sh in the same CI job. Assert only the +# routes introduced here so unrelated registry maintenance stays independent. +while IFS=$'\t' read -r section key expected; do + check "registry $section/$key selects $expected" \ + "jq -e --arg section '$section' --arg key '$key' --arg expected '$expected' '.registry[\$section][\$key] == \$expected' '$fleet/scripts/fix-script-registry.json' >/dev/null" +done <<'TSV' +by_category ToolchainInputMissing fix-rust-toolchain-input.sh +by_category StaleReusablePin fix-stale-reusable-pin.sh +by_category LockSelfShaRef fix-lock-self-sha-refs.sh +by_recipe WF026 fix-rust-toolchain-input.sh +by_recipe workflow_audit/sha_pinned_toolchain_without_input fix-rust-toolchain-input.sh +by_recipe WF027 fix-stale-reusable-pin.sh +by_recipe workflow_audit/known_broken_reusable_revision fix-stale-reusable-pin.sh +by_recipe PIN-003 fix-stale-reusable-pin.sh +by_recipe PI006 fix-lock-self-sha-refs.sh +by_recipe pin_integrity/self_sha_lock_record fix-lock-self-sha-refs.sh +TSV + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] From 1a111e49437f0195ef8da82115d4a96d2e2b4b9e Mon Sep 17 00:00:00 2001 From: hyperpolymath Date: Mon, 19 Oct 2026 18:11:42 +0000 Subject: [PATCH 13/44] fix(fix-stale-reusable-pin): a tag-pinned job-level call is a no-op, not a silent exit 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job-level `uses:` pinned by tag (`check.yaml@v1`) carries no SHA for the table to move. The ref extraction grepped for a 40-hex ref, found none, and under `set -eo pipefail` the failing command substitution ended the run with exit 1 before the `[ -n "$ref" ] || continue` guard could run — no FIXED, no UNSUPPORTED, no OK. The pipeline now tolerates no match and the guard does its job. Caught by the contract "a tag-pinned reusable is a successful no-op" added in f1ddd2a; the suite is 118/118 under gawk and mawk. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-stale-reusable-pin.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 8221a160..a5df0793 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -187,7 +187,10 @@ while IFS= read -r -d '' wf; do pending=() for lineno in $klines; do line=$(sed -n "${lineno}p" "$wf") - ref=$(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}" <<<"$line" | sed -E "s/${USES_KEY_RE}//" | head -1) + # A job-level call pinned by tag (`@v1`) carries no SHA: nothing for a + # SHA table to move, so it is a no-op here — not a grep exit 1 that + # `set -e` would turn into a silent exit 1 of the whole run. + ref=$(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}" <<<"$line" | sed -E "s/${USES_KEY_RE}//" | head -1 || true) [ -n "$ref" ] || continue path="${ref%@*}"; stale="${ref##*@}" lookup_replacement "$path" "$stale" || continue From e3eca1cb18360c8410f0aa64a30eb1c42803c64a Mon Sep 17 00:00:00 2001 From: hyperpolymath Date: Mon, 19 Oct 2026 18:18:35 +0000 Subject: [PATCH 14/44] fix(fix-scripts): quote-agnostic lock merging, YAML context for record classes, empty toolchain inputs, SHA boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-stale-reusable-pin.sh: - the duplicate-record merger reads both quote styles (`'a@sha':` and `"a@sha":` are one record; the first spelling is written back) and keeps any line it has no rule for under the record it follows, instead of dropping every line it did not recognise from `dependencies:`; - a dead SHA is only a dead SHA at a boundary (quote, space, `#`, end of value): a ref that merely starts with one (`-patched`) is another ref the table never named, neither a candidate nor rewritten into `-patched`. The rewrite is an ERE with a `~` delimiter, since a `$` inside a BRE group is literal to sed. fix-lock-self-sha-refs.sh: - a record's class (reusable call keeps its SHA ref; action record is repaired) is read from real `uses:` keys only — a job's key under `jobs:` or a step's key under `steps:` — found by the same YAML-context pass the other fixers use. The same path inside a `run: |` script or a fixture under `env:` is text and can no longer make an action record look like a reusable call and hide its self-SHA ref from repair. fix-rust-toolchain-input.sh: - a `toolchain:` key present with no value (`toolchain:`, `null`, `~`), in block or flow form, inline or on a continuation line, is filled where it stands, never written a second time; a trailing comment survives. An empty key with a mapping nested under it is UNSUPPORTED (exit 2, untouched). Contracts tc10, tc11, sp10, sp11, ls8 (125 total), gawk and mawk; shellcheck clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 71 +++++++++++++-- scripts/fix-rust-toolchain-input.sh | 43 +++++++-- scripts/fix-stale-reusable-pin.sh | 38 +++++--- scripts/tests/fix-workflow-pins.sh | 135 ++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 24 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 77dc3b3b..4d200bbe 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -68,14 +68,73 @@ lookup_tag() { # # AND composite actions), so the class is per repository@revision. A record no # workflow uses directly (a nested dependency, a tree without workflows) falls # back to the two estate reusable sources by name. +# +# Only a `uses:` that is a real key counts: a job's key under `jobs:` is a +# reusable call (R), a step's key under `steps:` is an action (A). The same +# text inside a `run: |` script, a fixture under `env:`, or any other key is +# data, so it can neither keep a defective action record nor repair one. +# The sites are read once, with the YAML context the other fixers use. +uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "Aowner/repo[/path]@ref" + local wf + for wf in "$REPO_PATH"/.github/workflows/*.yml "$REPO_PATH"/.github/workflows/*.yaml; do + [ -f "$wf" ] || continue + awk ' + function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } + function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } + function keyname(s) { sub(/^[[:space:]]*(-[[:space:]]+)?/, "", s); sub(/[[:space:]]*:.*$/, "", s); gsub(/["'"'"']/, "", s); return s } + function ref_of(s) { sub(/^[[:space:]]*(-[[:space:]]+)?["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?/, "", s); sub(/["'"'"'[:space:]#].*$/, "", s); return s } + function nearest_below(ind, k, p) { p = -1; for (k in keyat) if (k + 0 < ind && k + 0 > p) p = k + 0; return p } + BEGIN { + Q = "[\"'"'"']?" + KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" + ITEM_RE = "^[[:space:]]*-([[:space:]]|$)" + SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" + USES_RE = "^" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q + item_steps = 0; item_indent = -1 + } + { + ind = indent_of($0) + if (in_scalar) { if (plain($0) && ind <= scalar_indent) in_scalar = 0; else next } + if (!plain($0)) next + if ($0 ~ ITEM_RE) { + # A list item: its parent is the nearest key at its indentation or + # shallower (`steps:\n- uses:` puts the dash under the key). + p = -1; for (k in keyat) if (k + 0 <= ind && k + 0 > p) p = k + 0 + for (k in keyat) if (k + 0 > ind) delete keyat[k] + item_steps = (p >= 0 && keyat[p] == "steps"); item_indent = ind + t = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", t) + if (t ~ USES_RE) { if (item_steps) print "A\t" ref_of(t) } + else if (t ~ KEY_RE) { + keyat[ind + 2] = keyname(t) + if (t ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind + 2 } + } + next + } + if ($0 ~ KEY_RE) { + key = keyname($0) + for (k in keyat) if (k + 0 >= ind) delete keyat[k] + if (ind <= item_indent) item_steps = 0 + if (key == "uses") { + p = nearest_below(ind); g = (p >= 0) ? nearest_below(p) : -1 + if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "R\t" ref_of($0) + else if (item_steps && ind > item_indent) print "A\t" ref_of($0) + } + keyat[ind] = key + if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } + } + } + ' "$wf" + done +} +SITES=$(mktemp) +uses_sites > "$SITES" + # For owner/repo $1 at SHA $2, print reusable, action, or unknown; reusable # takes precedence when both kinds of use exist. usage_of() { # -> reusable | action | unknown - local key="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$1" - local end="@$2([\"'[:space:]]|$)" - if grep -rqE "${key}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml${end}" --include='*.yml' --include='*.yaml' "$REPO_PATH/.github/workflows" 2>/dev/null; then + if grep -qE "^R $1/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@$2$" "$SITES"; then echo reusable - elif grep -rqE "${key}(/[A-Za-z0-9_./-]+)?${end}" --include='*.yml' --include='*.yaml' "$REPO_PATH/.github/workflows" 2>/dev/null; then + elif grep -qE "^A $1(/[A-Za-z0-9_./-]+)?@$2$" "$SITES"; then echo action else echo unknown @@ -115,7 +174,7 @@ if ! awk ' { key_line = 0 } ' "$LOCK" > "$records"; then echo "fix-lock-self-sha-refs: could not parse $LOCK (awk failed); lock left unchanged" >&2 - rm -f "$tmp" "$records"; exit 2 + rm -f "$tmp" "$records" "$SITES"; exit 2 fi while IFS=$'\t' read -r lineno repo sha; do if keeps_sha_ref "$repo" "$sha"; then continue; fi @@ -135,7 +194,7 @@ while IFS=$'\t' read -r lineno repo sha; do echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" -rm -f "$records" +rm -f "$records" "$SITES" if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; else rm -f "$tmp"; fi if [ "$fixed" -eq 0 ] && [ "$unresolved" -eq 0 ]; then echo "OK (no self-SHA action records): $LOCK"; fi diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 6f632f7f..0b3833f3 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -111,7 +111,7 @@ apply_to_file() { } # Emit the buffered step, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item) { + function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_nested, m, pre, post, delim, comment) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` the indentation of the first plain line under it. @@ -134,12 +134,16 @@ apply_to_file() { } if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } has_with = 0; with_bad = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; in_flow = 0; child_indent = -1; has_tc = 0 + tc_empty_i = 0; tc_empty_flow = 0; tc_nested = 0 for (i = 1; i <= n; i++) { line = buf[i]; ind = indent_of(line) if (in_flow) { # A flow `with: {` spread over several lines: every entry up to - # the brace that closes it is an input. - if (line ~ TC_RE || line ~ TC_FLOW_RE) has_tc = 1 + # the brace that closes it is an input. An EMPTY `toolchain:` + # entry (`toolchain: ,` / `toolchain: null`) is the key with no + # value: it is given one in place, never duplicated. + if (line ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + else if (line ~ TC_RE || line ~ TC_FLOW_RE) has_tc = 1 flow_depth += brace_delta(line) if (flow_depth <= 0) in_flow = 0 continue @@ -150,7 +154,11 @@ apply_to_file() { # Only a DIRECT child of `with:` is an input: the contents of # another input'"'"'s block scalar (`components: |`) sit deeper. if (child_indent < 0) child_indent = ind - if (ind == child_indent && line ~ TC_RE) has_tc = 1 + if (ind == child_indent && line ~ TC_EMPTY_RE) tc_empty_i = i + else if (ind == child_indent && line ~ TC_RE) has_tc = 1 + # An empty `toolchain:` followed by deeper lines is a nested + # mapping, not an empty value: nothing this fixer can repair. + else if (tc_empty_i && !tc_empty_flow && ind > child_indent && i == tc_empty_i + 1) tc_nested = 1 } } if (!in_with && !has_with && length(key_indent_of(line)) == skey) { @@ -158,7 +166,8 @@ apply_to_file() { has_with = 1; with_i = i; with_indent = skey; in_with = 1 } else if (line ~ WITH_FLOW_RE) { has_with = 1; with_i = i; with_flow = 1 - if (line ~ TC_FLOW_RE) has_tc = 1 + if (line ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + else if (line ~ TC_FLOW_RE) has_tc = 1 flow_depth = brace_delta(line) if (flow_depth > 0) in_flow = 1 } else if (line ~ WITH_ANY_RE) { @@ -169,15 +178,31 @@ apply_to_file() { } } } + if (tc_nested) { unrec[++nunrec] = lnum[tc_empty_i]; with_bad = 1 } if (has_tc || with_bad) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } for (i = 1; i <= n; i++) { line = buf[i] - if (has_with && with_flow && i == with_i) { + if (tc_empty_i && i == tc_empty_i) { + # The key is there with no value: fill it where it stands. + if (tc_empty_flow) { + match(line, TC_FLOW_EMPTY_RE) + pre = substr(line, 1, RSTART - 1); m = substr(line, RSTART, RLENGTH); post = substr(line, RSTART + RLENGTH) + delim = substr(m, length(m)) + sub(/[[:space:]]*(null|~)?[[:space:]]*[,}]$/, "", m) + line = pre m " " toolchain (delim == "}" ? " }" : ",") post + } else { + comment = "" + if (match(line, /[[:space:]]+#.*$/)) comment = substr(line, RSTART) + sub(/:[[:space:]]*(null|~)?[[:space:]]*(#.*)?$/, "", line) + line = line ": " toolchain comment + } + print line; fixed++ + } else if (has_with && with_flow && i == with_i && !tc_empty_i) { if (line ~ /\{[[:space:]]*\}/) sub(/\{[[:space:]]*\}/, "{ toolchain: " toolchain " }", line) else if (line ~ /\{[[:space:]]*$/) sub(/\{[[:space:]]*$/, "{ toolchain: " toolchain ",", line) else sub(/\{[[:space:]]*/, "{ toolchain: " toolchain ", ", line) print line; fixed++ - } else if (has_with && !with_flow && i == with_i) { + } else if (has_with && !with_flow && i == with_i && !tc_empty_i) { print line print spaces(child_indent >= 0 ? child_indent : with_indent + 2) "toolchain: " toolchain fixed++ @@ -205,6 +230,10 @@ apply_to_file() { WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" TC_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:]]" TC_FLOW_RE = "[{,][[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:],}]" + # The key present with no value (`toolchain:`, `toolchain: null`, `~`): + # not an input the action can read, and not a key to write twice. + TC_EMPTY_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*(null|~)?[[:space:]]*(#.*)?$" + TC_FLOW_EMPTY_RE = "(^|[{,])[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*(null|~)?[[:space:]]*[,}]" } { ind = indent_of($0) diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index a5df0793..18ba079b 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -66,7 +66,7 @@ merge_duplicate_records() { function emit_record( i, n, out) { if (!cur) return # Rebuild the record: header lines, then a sorted, unique uses: list. - print cur + print spelled[cur] for (i = 1; i <= nhead[cur]; i++) print head[cur, i] n = 0 for (u in uses_of) if (index(u, cur SUBSEP) == 1) n++ @@ -74,22 +74,31 @@ merge_duplicate_records() { print " uses:" fflush() cmd = "sort" - for (u in uses_of) if (index(u, cur SUBSEP) == 1) print " - " substr(u, length(cur) + 2) | cmd + for (u in uses_of) if (index(u, cur SUBSEP) == 1) print " - " uses_of[u] | cmd close(cmd) } } + # A record key is the dependency between its quotes; the key is + # compared unquoted so `'a@sha':` and `"a@sha":` are one record, and + # the first spelling seen is the one written back. + function unq(s) { gsub(/["'"'"']/, "", s); return s } BEGIN { indep = 0; cur = "" } /^dependencies:[[:space:]]*$/ { indep = 1; print; next } !indep { print; next } - /^ '"'"'[^'"'"']+'"'"':$/ { - key = $0 - if (!(key in seen)) { seen[key] = 1; order[++norder] = key; nhead[key] = 0; first = 1 } else { first = 0 } + /^ ["'"'"'][^"'"'"']+["'"'"']:$/ { + key = unq($0) + if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } cur = key; next } /^ uses:[[:space:]]*$/ { next } - /^ - '"'"'[^'"'"']+'"'"'$/ { u = $0; sub(/^ - /, "", u); uses_of[cur SUBSEP u] = 1; next } + /^ - ["'"'"'][^"'"'"']+["'"'"']$/ { u = $0; sub(/^ - /, "", u); uses_of[cur SUBSEP unq(u)] = u; next } /^ [a-z_]+: / { if (first) { head[cur, ++nhead[cur]] = $0 } ; next } - { next } + { + # A line the merger does not understand is not the merger'"'"'s to drop: + # it is kept where it stands, under the record it follows. + if (first) { head[cur, ++nhead[cur]] = $0 } + next + } END { for (k = 1; k <= norder; k++) { cur = order[k] @@ -165,6 +174,10 @@ yaml_context() { # # untouched, named on stderr, exit 2 — never "OK". The lock is edited after # the loop, once every workflow rewrite is known. USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?" +# A SHA ref ends at a quote, whitespace, a comment or the end of the value: a +# tag that merely STARTS with a dead SHA (`-patched`) is a different ref +# the table never named, and must not be rewritten into `-patched`. +REF_END="([\"'[:space:]#]|$)" REUSABLE_REF_RE="hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edge unsupported=0 @@ -176,13 +189,14 @@ while IFS= read -r -d '' wf; do while IFS= read -r numbered; do lineno="${numbered%%:*}" grep -qx "$lineno" <<<"$placed" && continue - ref=$(grep -oE "$REUSABLE_REF_RE" <<<"${numbered#*:}" | head -1) + ref=$(grep -oE "${REUSABLE_REF_RE}${REF_END}" <<<"${numbered#*:}" | head -1 | sed -E "s/[\"'[:space:]#]\$//" || true) + [ -n "$ref" ] || continue [ -n "$ref" ] || continue if lookup_replacement "${ref%@*}" "${ref##*@}"; then echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 skip=1 fi - done < <(grep -nE "$REUSABLE_REF_RE" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) + done < <(grep -nE "${REUSABLE_REF_RE}${REF_END}" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() for lineno in $klines; do @@ -190,12 +204,14 @@ while IFS= read -r -d '' wf; do # A job-level call pinned by tag (`@v1`) carries no SHA: nothing for a # SHA table to move, so it is a no-op here — not a grep exit 1 that # `set -e` would turn into a silent exit 1 of the whole run. - ref=$(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}" <<<"$line" | sed -E "s/${USES_KEY_RE}//" | head -1 || true) + ref=$(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$line" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]#]\$//" | head -1 || true) [ -n "$ref" ] || continue path="${ref%@*}"; stale="${ref##*@}" lookup_replacement "$path" "$stale" || continue [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } - sed -i "${lineno}s|^\([[:space:]]*\(-[[:space:]]\+\)\?[\"']\?uses[\"']\?[[:space:]]*:[[:space:]]*[\"']\?$path\)@$stale|\1@$replacement|" "$wf" + # ERE: a `$` inside a BRE group is literal to sed, so the boundary + # alternation needs -E (and a delimiter the pattern does not use). + sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path)@$stale([\"'[:space:]#]|\$)~\1@$replacement\3~" "$wf" echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason)" rewrites=$((rewrites + 1)) pending+=("$stale"$'\t'"$replacement"$'\t'"${path%%/.github/workflows/*}") diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index b06cf323..09e6a77c 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -345,6 +345,70 @@ check "repository mode fixes a supported .yaml sibling while returning UNSUPPORT check "repository mode leaves nested workflows and non-YAML files byte-identical" \ "cmp -s '$fx/tc-mixed-before.yaml' '$tc_mixed/nested/ignored.yml' && cmp -s '$fx/tc-mixed-before.yaml' '$tc_mixed/ignored.txt'" +# tc10: the key present with no value (`toolchain:`, `null`, `~`) is filled +# where it stands — block or flow, inline or on a continuation line — never +# written a second time; a trailing comment survives. +mkdir -p "$fx/tc10/.github/workflows" +cat > "$fx/tc10/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: + components: clippy + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + components: clippy + toolchain: null # chosen at runtime + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: , components: clippy } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { components: clippy, toolchain: ~ } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + toolchain: null, + components: clippy, + } +YML +cat > "$fx/tc10-expected.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: stable + components: clippy + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + components: clippy + toolchain: stable # chosen at runtime + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: stable, components: clippy } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { components: clippy, toolchain: stable } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + toolchain: stable, + components: clippy, + } +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc10" +check "an empty toolchain: input is filled in place in every shape, never duplicated" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc10-expected.yml' '$fx/tc10/.github/workflows/x.yml' && [ \$(grep -c 'toolchain:' '$fx/tc10/.github/workflows/x.yml') -eq 5 ]" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc10" +check "filled inputs are inputs: the second run is byte-identical and OK" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc10-expected.yml' '$fx/tc10/.github/workflows/x.yml' && grep -q '^OK' <<<\"\$out\"" + +# tc11: an empty `toolchain:` with a mapping nested under it is not an empty +# value; the fixer refuses rather than write a value over a mapping. +mkdir -p "$fx/tc11/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n with:\n toolchain:\n channel: stable\n' > "$fx/tc11/.github/workflows/x.yml" +cp "$fx/tc11/.github/workflows/x.yml" "$fx/tc11-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc11" +check "a toolchain: key with a nested mapping is UNSUPPORTED: file untouched, exit 2" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6' <<<\"\$out\" && cmp -s '$fx/tc11-before.yml' '$fx/tc11/.github/workflows/x.yml'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -675,6 +739,44 @@ check "unsupported stale-pin callers take exit-code precedence over a repaired s check "mixed migration preserves the unsupported caller's edge and adds the supported .yaml caller's replacement" \ "grep -A1 \"'.github/workflows/ci.yml':\" '$sp_mixed/actions.lock' | grep -q \"standards@$STALE_A\" && grep -A1 \"'.github/workflows/rust-ci.yaml':\" '$sp_mixed/actions.lock' | grep -q \"standards@$GOOD\" && grep -q \"^ 'hyperpolymath/standards@$STALE_A':\" '$sp_mixed/actions.lock' && grep -q \"^ 'hyperpolymath/standards@$GOOD':\" '$sp_mixed/actions.lock'" +# sp10: the lock merger reads both quote styles and keeps what it does not +# understand. Before this a double-quoted record (a hand-added one) was +# silently dropped from `dependencies:` by any stale-pin repair. +mkdir -p "$fx/sp10/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp10/.github/workflows/rust-ci.yml" +cat > "$fx/sp10/.github/workflows/actions.lock" <-patched`) is another +# ref the table never named: left alone, while the real dead pin beside it moves. +mkdir -p "$fx/sp11/.github/workflows" +printf 'jobs:\n patched:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s-patched\n dead:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" "$STALE_A" > "$fx/sp11/.github/workflows/ci.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp11" +check "a tag that begins with a dead SHA is not that SHA: untouched, exit 0, the dead pin beside it moved" \ + "[ \$rc -eq 0 ] && sed -n '3p' '$fx/sp11/.github/workflows/ci.yml' | grep -q \"@$STALE_A-patched\$\" && sed -n '5p' '$fx/sp11/.github/workflows/ci.yml' | grep -q \"@$GOOD\$\" && ! grep -q 'UNSUPPORTED' <<<\"\$out\"" + # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── mkdir -p "$fx/ls/.github/workflows" cat > "$fx/ls/.github/workflows/actions.lock" <<'LOCK' @@ -900,6 +1002,39 @@ run_lookup check "a ref different from its record's SHA is untouched" \ "[ \$rc -eq 0 ] && ! grep -q UNRESOLVED <<<\"\$out\" && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/non-self-before.lock' '$lookup_lock'" +# ls8: a record's class comes from real `uses:` keys. The same reusable path +# inside a `run: |` script or a fixture under `env:` is text: it cannot make an +# action record "a reusable call" and so hide its self-SHA ref from repair. +mkdir -p "$fx/ls8/.github/workflows" +cat > "$fx/ls8/.github/workflows/ci.yml" < "$fx/ls8/.github/workflows/actions.lock" < "$fx/ls8-tags.tsv" +TAG_MAP="$fx/ls8-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls8" --offline > "$fx/ls8.out" 2>&1; rc8=$? +check "a reusable path inside run: | or env: is text: the action record it names is still repaired" \ + "[ \$rc8 -eq 0 ] && grep -q \"ref: 'v1.0.0'\" '$fx/ls8/.github/workflows/actions.lock' && ! grep -q UNRESOLVED '$fx/ls8.out'" + # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser # cannot read instead of reporting OK. From 3174f1c341c66bc5c0cd8b81b9f6a71f7e184a47 Mon Sep 17 00:00:00 2001 From: hyperpolymath Date: Mon, 19 Oct 2026 18:24:16 +0000 Subject: [PATCH 15/44] fix(fix-scripts): table validated first, lock edges per repository@SHA, missing edges reported, whole records read, braces outside quotes fix-stale-reusable-pin.sh: - every table row is validated before any caller is touched, so a malformed row found mid-run can no longer leave earlier callers rewritten and the lock never reached (exit 2, nothing changed); - a lock edge and a record are per repository@SHA: moving the covered repository's edge no longer rewrites an unrelated repository's edge that sits at the same SHA, and the record rename touches only that record's key, ref: and commit: lines instead of every occurrence of the SHA in the file; - a rewritten caller whose lock entry never had the matching edge was already out of sync with its lock: the caller still moves, the missing edge is named, and the run ends 3 (the lock needs `gh actions-lock`), never 0, so dispatch cannot record a clean repair over a stale lock. fix-lock-self-sha-refs.sh: - a record is read to its end: `ref:` after `commit:` and the ids is still the record's ref (YAML mapping order is not fixed, and a hand-added record is exactly where this defect comes from); each finding carries the line of the ref: itself. fix-rust-toolchain-input.sh: - only braces outside quoted scalars and comments count toward a flow mapping's depth, so `components: "}"` or a `# }` comment no longer ends a multi-line `with: {` early (which hid a later toolchain and produced a duplicate key); an empty-input rewrite that cannot find its match on the real line refuses instead of guessing. Contracts tc12, sp12, sp13, sp14, ls9 (130 total), gawk and mawk; shellcheck clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 17 ++-- scripts/fix-rust-toolchain-input.sh | 6 +- scripts/fix-stale-reusable-pin.sh | 86 ++++++++++++++------ scripts/tests/fix-workflow-pins.sh | 119 ++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 32 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 4d200bbe..eee07081 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -160,18 +160,23 @@ tmp=$(mktemp); cp "$LOCK" "$tmp" # A record key and its ref may be single- or double-quoted (gh actions-lock # writes single quotes; a hand-added record — exactly where this defect # comes from — may not), and the quote style is kept on the rewrite. +# A record is read to its end (the next record key, or the end of the 8-space +# block): YAML mapping order is not fixed, so `ref:` may follow `commit:` and +# the other fields — exactly as a hand-added record might write it. Each +# finding carries the line of the `ref:` itself. records=$(mktemp) if ! awk ' /^ ["'"'"'][^"'"'"'@]+@[0-9a-f]+["'"'"']:$/ { s = $0; sub(/^ ["'"'"']/, "", s); sub(/["'"'"']:$/, "", s) i = index(s, "@"); repo = substr(s, 1, i - 1); sha = substr(s, i + 1) - if (length(sha) == 40) { key_line = NR; key_repo = repo; key_sha = sha; next } + if (length(sha) == 40) { inrec = 1; key_repo = repo; key_sha = sha } else inrec = 0 + next } - key_line && NR == key_line + 1 && /^ ref: ["'"'"']/ { + /^[^[:space:]]/ || /^ [^[:space:]]/ { inrec = 0 } + inrec && /^ ref: ["'"'"']/ { r = $0; sub(/^ ref: /, "", r); gsub(/["'"'"']/, "", r) - if (r == key_sha) print key_line "\t" key_repo "\t" key_sha + if (r == key_sha) print NR "\t" key_repo "\t" key_sha } - { key_line = 0 } ' "$LOCK" > "$records"; then echo "fix-lock-self-sha-refs: could not parse $LOCK (awk failed); lock left unchanged" >&2 rm -f "$tmp" "$records" "$SITES"; exit 2 @@ -189,8 +194,8 @@ while IFS=$'\t' read -r lineno repo sha; do echo "UNRESOLVED $repo@${sha:0:7}: resolved tag '$tag' carries characters this fixer will not write into the lock (record left as is)" >&2 unresolved=$((unresolved + 1)); continue fi - # The ref line is the one directly after the record key; its quote style is kept. - sed -i "$((lineno + 1))s|ref: \(['\"]\)$sha\1|ref: \1$tag\1|" "$tmp" + # lineno is the ref: line itself; its quote style is kept. + sed -i "${lineno}s|ref: \(['\"]\)$sha\1|ref: \1$tag\1|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 0b3833f3..79517092 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -84,7 +84,10 @@ apply_to_file() { # Net brace depth of a line: a flow mapping closes when it returns to # zero, not at the first `}` — an expression `${{ matrix.x }}` inside it # opens and closes its own braces. - function brace_delta(s, a, b) { a = s; b = s; return gsub(/\{/, "", a) - gsub(/\}/, "", b) } + # ... and only braces OUTSIDE quoted scalars and comments count: a + # `components: "}"` or a `# }` is text, not the end of the mapping. + function code_only(s, t) { t = s; gsub(/"([^"\\]|\\.)*"/, "\"\"", t); gsub(/'"'"'[^'"'"']*'"'"'/, "'"'"''"'"'", t); sub(/(^|[[:space:]])#.*$/, "", t); return t } + function brace_delta(s, a, b) { a = code_only(s); b = a; return gsub(/\{/, "", a) - gsub(/\}/, "", b) } # A 40-hex pin of the action on a non-comment line, trailing comment # stripped. `@stable` and `@1.85.0` are tags: the ref carries the # channel and the step is not ours. @@ -179,6 +182,7 @@ apply_to_file() { } } if (tc_nested) { unrec[++nunrec] = lnum[tc_empty_i]; with_bad = 1 } + if (tc_empty_i && tc_empty_flow && !match(buf[tc_empty_i], TC_FLOW_EMPTY_RE)) { unrec[++nunrec] = lnum[tc_empty_i]; with_bad = 1 } if (has_tc || with_bad) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } for (i = 1; i <= n; i++) { line = buf[i] diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 18ba079b..0594f3bb 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -47,6 +47,21 @@ PINS_TABLE="${PINS_TABLE:-$SCRIPT_DIR/lib/stale-reusable-pins.tsv}" REPO_PATH="${1:?Usage: fix-stale-reusable-pin.sh }" [ -r "$PINS_TABLE" ] || { echo "fix-stale-reusable-pin: table not readable: $PINS_TABLE" >&2; exit 2; } + +# Every row is checked before any caller is touched: a malformed row found +# mid-run would leave earlier callers rewritten and the lock never reached. +validate_table() { + local n=0 v_path v_stale v_new bad=0 + while IFS=$'\t' read -r v_path v_stale v_new _; do + n=$((n + 1)) + case "$v_path" in ''|'#'*) continue ;; esac + [[ "$v_stale" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad stale SHA '$v_stale'" >&2; bad=1; } + [[ "$v_new" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad replacement SHA '$v_new' for ${v_stale:0:7}" >&2; bad=1; } + [[ "$v_path" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.*-]+$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad reusable path '$v_path'" >&2; bad=1; } + done < "$PINS_TABLE" + [ "$bad" -eq 0 ] || { echo "fix-stale-reusable-pin: table $PINS_TABLE is invalid; nothing was changed" >&2; exit 2; } +} +validate_table [ -d "$REPO_PATH/.github/workflows" ] || { echo "OK (no workflows): $REPO_PATH"; exit 0; } LOCK="$REPO_PATH/.github/workflows/actions.lock" @@ -179,7 +194,8 @@ USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space: # the table never named, and must not be rewritten into `-patched`. REF_END="([\"'[:space:]#]|$)" REUSABLE_REF_RE="hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" -edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edge +edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edgerepository +lock_stale=0 # a rewritten caller whose lock entry never had the edge: the lock was already out of sync unsupported=0 while IFS= read -r -d '' wf; do ctx=$(yaml_context "$wf") @@ -226,24 +242,37 @@ while IFS= read -r -d '' wf; do for other in $klines; do sed -n "${other}p" "$wf" | grep -qE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" && keep=1 done - printf '%s\t%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" "$keep" >> "$edits" + printf '%s\t%s\t%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" "$keep" "$repo_prefix" >> "$edits" done done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) -# Lock: move each rewritten workflow's own `workflows:` edges first. +# Lock: move each rewritten workflow's own `workflows:` edges first. An edge +# is per repository@SHA: an unrelated repository that happens to sit at the +# same SHA keeps its edge and its record. A rewritten caller whose lock entry +# has no such edge at all was already out of sync with its lock; that is +# said, and the run ends 3 (the lock needs `gh actions-lock`), never 0. if [ -f "$LOCK" ] && [ -s "$edits" ]; then sort -u "$edits" -o "$edits" - while IFS=$'\t' read -r wfkey stale new keep; do - grep -q "$stale" "$LOCK" || continue + while IFS=$'\t' read -r wfkey stale new keep repo; do + if ! awk -v wfkey=" '$wfkey':" -v edge="$repo@$stale" ' + /^workflows:[[:space:]]*$/ { sec = "w" } + /^dependencies:[[:space:]]*$/ { sec = "d" } + sec == "w" && /^ ["'"'"']/ { inwf = ($0 == wfkey) } + sec == "w" && inwf && /^ - / && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"")) { found = 1 } + END { exit !found }' "$LOCK"; then + echo "WARN: actions.lock has no edge '$repo@${stale:0:7}' under '$wfkey': the lock was already out of sync with this caller — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 + lock_stale=1 + continue + fi tmp=$(mktemp) - awk -v wfkey=" '$wfkey':" -v stale="$stale" -v new="$new" -v keep="$keep" ' + awk -v wfkey=" '$wfkey':" -v stale="$stale" -v new="$new" -v keep="$keep" -v repo="$repo" ' /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && /^ '"'"'/ { inwf = ($0 == wfkey) } + sec == "w" && /^ ["'"'"']/ { inwf = ($0 == wfkey) } sec == "w" && inwf && /^ - / { - i = index($0, "@" stale "'"'"'") + i = index($0, "'"'"'" repo "@" stale "'"'"'") if (i) { - moved = substr($0, 1, i) new "'"'"'" + moved = substr($0, 1, i + length(repo)) "@" new "'"'"'" # keep: an uncovered caller in this workflow still pins the stale # SHA, so the edge is added beside the old one, not replaced. if (keep == "1") { if (!seen[moved]++) { print; $0 = moved } else next } else $0 = moved @@ -257,24 +286,27 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # copy it under the replacement key while an edge still points at the # stale SHA — a workflow's own edge, or another record's nested `uses:` # (a reusable that itself calls the stale revision keeps needing it). - while IFS=$'\t' read -r stale new; do - grep -q "$stale" "$LOCK" || continue - still=$(awk -v stale="$stale" ' - /^ +- '"'"'/ && index($0, "@" stale "'"'"'") { n++ } END { print n + 0 }' "$LOCK") + while IFS=$'\t' read -r stale new repo; do + grep -q "$repo@$stale" "$LOCK" || continue + still=$(awk -v edge="$repo@$stale" ' + /^ +- ["'"'"']/ && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"")) { n++ } END { print n + 0 }' "$LOCK") tmp=$(mktemp) - if [ "$still" -eq 0 ]; then - sed "s/$stale/$new/g" "$LOCK" > "$tmp" - else - awk -v stale="$stale" -v new="$new" ' - /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "d" && /^ '"'"'/ { inrec = (index($0, "@" stale "'"'"':") > 0) } - sec == "d" && inrec { c = $0; gsub(stale, new, c); copy[++nc] = c } - { print } - END { for (i = 1; i <= nc; i++) print copy[i] }' "$LOCK" > "$tmp" - echo "NOTE: actions.lock keeps the record for ${stale:0:7}: a workflow the table does not cover still pins it; the replacement got its own record." >&2 - fi + # Only THIS repository'"'"'s record moves: its key line, and its ref: and + # commit: lines. Another repository at the same SHA is a different + # record and keeps its SHA. + awk -v stale="$stale" -v new="$new" -v repo="$repo" -v still="$still" ' + /^dependencies:[[:space:]]*$/ { sec = "d" } + sec == "d" && /^ ["'"'"']/ { inrec = (index($0, repo "@" stale) > 0) } + sec == "d" && inrec && (/^ ["'"'"']/ || /^ (ref|commit):/) { + if (still == 0) gsub(stale, new) + else { c = $0; gsub(stale, new, c); copy[++nc] = c } + } + sec == "d" && inrec && still != 0 && !(/^ ["'"'"']/ || /^ (ref|commit):/) { copy[++nc] = $0 } + { print } + END { for (i = 1; i <= nc; i++) print copy[i] }' "$LOCK" > "$tmp" + [ "$still" -eq 0 ] || echo "NOTE: actions.lock keeps the record for ${stale:0:7}: a workflow the table does not cover still pins it; the replacement got its own record." >&2 if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi - done < <(cut -f2,3 "$edits" | sort -u) + done < <(cut -f2,3,5 "$edits" | sort -u) # A kept stale edge means the new edge was ADDED, so the stale record is # kept too (the branch above copies it); nothing else to do here. [ "$lock_touched" -eq 1 ] && merge_duplicate_records "$LOCK" @@ -289,5 +321,9 @@ if [ "$unsupported" -ne 0 ]; then echo "fix-stale-reusable-pin: $unsupported file(s) left untouched (UNSUPPORTED above); not a clean success." >&2 exit 2 fi +if [ "$lock_stale" -eq 1 ]; then + echo "fix-stale-reusable-pin: a rewritten caller had no matching lock edge (WARN above); the lock is out of sync. Exit 3: not a clean success." >&2 + exit 3 +fi [ "$lock_touched" -eq 1 ] && exit 3 exit 0 diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 09e6a77c..171872ec 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -409,6 +409,42 @@ run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc11" check "a toolchain: key with a nested mapping is UNSUPPORTED: file untouched, exit 2" \ "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6' <<<\"\$out\" && cmp -s '$fx/tc11-before.yml' '$fx/tc11/.github/workflows/x.yml'" +# tc12: braces inside quoted scalars and comments are text. A `"}"` value or +# a `# }` comment inside a multi-line flow `with:` must not end the mapping +# early (and hide a later toolchain, or misplace the insertion). +mkdir -p "$fx/tc12/.github/workflows" +cat > "$fx/tc12/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + components: "}", # a } in a comment + toolchain: nightly, + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + components: '{', + } +YML +cat > "$fx/tc12-expected.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + components: "}", # a } in a comment + toolchain: nightly, + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: stable, + components: '{', + } +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc12" +check "quoted braces and comment braces do not close a flow with: — the named toolchain is seen, the missing one added once" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc12-expected.yml' '$fx/tc12/.github/workflows/x.yml'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -777,6 +813,68 @@ run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp11" check "a tag that begins with a dead SHA is not that SHA: untouched, exit 0, the dead pin beside it moved" \ "[ \$rc -eq 0 ] && sed -n '3p' '$fx/sp11/.github/workflows/ci.yml' | grep -q \"@$STALE_A-patched\$\" && sed -n '5p' '$fx/sp11/.github/workflows/ci.yml' | grep -q \"@$GOOD\$\" && ! grep -q 'UNSUPPORTED' <<<\"\$out\"" +# sp12: the whole table is validated before any caller is touched: a good +# row followed by a malformed one leaves every workflow and the lock as they +# were, exit 2. +mkdir -p "$fx/sp12/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp12/.github/workflows/rust-ci.yml" +cp "$fx/sp12/.github/workflows/rust-ci.yml" "$fx/sp12-before.yml" +cp "$fx/sp5/.github/workflows/actions.lock" "$fx/sp12/.github/workflows/actions.lock" +cp "$fx/sp12/.github/workflows/actions.lock" "$fx/sp12-before.lock" +printf 'hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml\t%s\t%s\tgood row\nhyperpolymath/standards/.github/workflows/*\t%s\tnot-a-sha\tbad row after a good one\n' \ + "$STALE_A" "$GOOD" "$STALE_B" > "$fx/sp12-pins.tsv" +run_fix env PINS_TABLE="$fx/sp12-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp12" +check "a malformed row anywhere in the table stops the run before any caller or lock is touched: exit 2" \ + "[ \$rc -eq 2 ] && grep -q 'table row 2: bad replacement SHA' <<<\"\$out\" && cmp -s '$fx/sp12-before.yml' '$fx/sp12/.github/workflows/rust-ci.yml' && cmp -s '$fx/sp12-before.lock' '$fx/sp12/.github/workflows/actions.lock'" + +# sp13: a lock edge is per repository@SHA. An unrelated repository that sits +# at the same SHA keeps its edge and its record when the covered one moves. +mkdir -p "$fx/sp13/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n other:\n uses: octo-org/example/.github/workflows/ci.yml@%s\n' "$STALE_A" "$STALE_A" > "$fx/sp13/.github/workflows/ci.yml" +cat > "$fx/sp13/.github/workflows/actions.lock" < "$fx/sp14/.github/workflows/ci.yml" +cat > "$fx/sp14/.github/workflows/actions.lock" < "$fx/ls/.github/workflows/actions.lock" <<'LOCK' @@ -1035,6 +1133,27 @@ TAG_MAP="$fx/ls8-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ check "a reusable path inside run: | or env: is text: the action record it names is still repaired" \ "[ \$rc8 -eq 0 ] && grep -q \"ref: 'v1.0.0'\" '$fx/ls8/.github/workflows/actions.lock' && ! grep -q UNRESOLVED '$fx/ls8.out'" +# ls9: a record is read to its end: `ref:` after `commit:` and the ids is +# still the record's ref, and still repaired. +mkdir -p "$fx/ls9/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: foo/bar@%s\n' "$SHA_A" > "$fx/ls9/.github/workflows/ci.yml" +cat > "$fx/ls9/.github/workflows/actions.lock" < "$fx/ls9-tags.tsv" +TAG_MAP="$fx/ls9-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls9" --offline > "$fx/ls9.out" 2>&1; rc9=$? +check "a ref: that is not the first line of its record is still the record's ref: repaired, exit 0" \ + "[ \$rc9 -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls9/.github/workflows/actions.lock' && ! grep -q \"ref: '$SHA_A'\" '$fx/ls9/.github/workflows/actions.lock' && grep -q '^FIXED' '$fx/ls9.out'" + # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser # cannot read instead of reporting OK. From 6672c11dfccec232d7799559e3a2352bab063aef Mon Sep 17 00:00:00 2001 From: hyperpolymath Date: Mon, 19 Oct 2026 18:26:25 +0000 Subject: [PATCH 16/44] fix(fix-scripts): a SHA pin ends at a boundary; an unquoted lock ref is a ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-rust-toolchain-input.sh: the 40 hex characters of a dtolnay pin must end the ref — `@-patched` is a tag that merely begins with one and carries its own channel, so it is not given a toolchain input. Contract tc13. fix-lock-self-sha-refs.sh: a plain-scalar `ref: ` (no quotes) is the same ref to GitHub as a quoted one; it is found and repaired, and the absence of quotes is kept — before this it was passed as "no self-SHA action records". Contract ls10. 132 contracts, gawk and mawk; shellcheck clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 9 +++++---- scripts/fix-rust-toolchain-input.sh | 7 +++++-- scripts/tests/fix-workflow-pins.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index eee07081..16295817 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -173,8 +173,9 @@ if ! awk ' next } /^[^[:space:]]/ || /^ [^[:space:]]/ { inrec = 0 } - inrec && /^ ref: ["'"'"']/ { - r = $0; sub(/^ ref: /, "", r); gsub(/["'"'"']/, "", r) + inrec && /^ ref: / { + # Quoted either way, or a plain scalar: the same ref to GitHub. + r = $0; sub(/^ ref: /, "", r); sub(/[[:space:]]+#.*$/, "", r); gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) if (r == key_sha) print NR "\t" key_repo "\t" key_sha } ' "$LOCK" > "$records"; then @@ -194,8 +195,8 @@ while IFS=$'\t' read -r lineno repo sha; do echo "UNRESOLVED $repo@${sha:0:7}: resolved tag '$tag' carries characters this fixer will not write into the lock (record left as is)" >&2 unresolved=$((unresolved + 1)); continue fi - # lineno is the ref: line itself; its quote style is kept. - sed -i "${lineno}s|ref: \(['\"]\)$sha\1|ref: \1$tag\1|" "$tmp" + # lineno is the ref: line itself; its quote style (or none) is kept. + sed -i "${lineno}s|ref: \(['\"]\{0,1\}\)$sha\1|ref: \1$tag\1|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 79517092..f1b38b00 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -91,12 +91,15 @@ apply_to_file() { # A 40-hex pin of the action on a non-comment line, trailing comment # stripped. `@stable` and `@1.85.0` are tags: the ref carries the # channel and the step is not ours. - function sha_ref(s, t, m) { + # The 40 hex characters must END the ref: `@-patched` is a tag + # that merely begins with one, and carries its own channel. + function sha_ref(s, t, m, nxt) { if (!plain(s)) return 0 t = s; sub(/[[:space:]]#.*$/, "", t) if (!match(t, /dtolnay\/rust-toolchain@[0-9a-f]+/)) return 0 m = substr(t, RSTART + 23, RLENGTH - 23) - return (length(m) == 40) + nxt = substr(t, RSTART + RLENGTH, 1) + return (length(m) == 40 && (nxt == "" || nxt ~ /["'"'"'[:space:],}\]]/)) } # YAML context outside list items: the key at each indentation, so a # list item opens a step only when its parent key is `steps:`. diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 171872ec..a8ff782b 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -445,6 +445,14 @@ run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc12" check "quoted braces and comment braces do not close a flow with: — the named toolchain is seen, the missing one added once" \ "[ \$rc -eq 0 ] && cmp -s '$fx/tc12-expected.yml' '$fx/tc12/.github/workflows/x.yml'" +# tc13: a ref that merely begins with 40 hex characters (`@-patched`) is +# a tag, carries its own channel, and is not ours. +mkdir -p "$fx/tc13/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772-patched\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # pinned\n' > "$fx/tc13/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc13" +check "a tag beginning with a SHA is a tag: untouched; the real SHA pin beside it is fixed" \ + "[ \$rc -eq 0 ] && sed -n '4p' '$fx/tc13/.github/workflows/x.yml' | grep -q 'patched\$' && sed -n '5p' '$fx/tc13/.github/workflows/x.yml' | grep -q '# pinned\$' && [ \$(grep -c 'toolchain: stable' '$fx/tc13/.github/workflows/x.yml') -eq 1 ] && sed -n '6,7p' '$fx/tc13/.github/workflows/x.yml' | grep -q '^ toolchain: stable$'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -1154,6 +1162,27 @@ TAG_MAP="$fx/ls9-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ check "a ref: that is not the first line of its record is still the record's ref: repaired, exit 0" \ "[ \$rc9 -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls9/.github/workflows/actions.lock' && ! grep -q \"ref: '$SHA_A'\" '$fx/ls9/.github/workflows/actions.lock' && grep -q '^FIXED' '$fx/ls9.out'" +# ls10: a plain-scalar `ref: ` (no quotes) is the same ref: found and +# repaired, the absence of quotes kept. +mkdir -p "$fx/ls10/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: foo/bar@%s\n' "$SHA_A" > "$fx/ls10/.github/workflows/ci.yml" +cat > "$fx/ls10/.github/workflows/actions.lock" < "$fx/ls10-tags.tsv" +TAG_MAP="$fx/ls10-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls10" --offline > "$fx/ls10.out" 2>&1; rc10=$? +check "an unquoted self-SHA ref is found and repaired, unquoted" \ + "[ \$rc10 -eq 0 ] && grep -q '^ ref: v1.0.0$' '$fx/ls10/.github/workflows/actions.lock' && grep -q '^FIXED' '$fx/ls10.out'" + # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser # cannot read instead of reporting OK. From bf7ef59285853056ba02c7f647b870580159d745 Mon Sep 17 00:00:00 2001 From: hyperpolymath Date: Mon, 19 Oct 2026 18:38:01 +0000 Subject: [PATCH 17/44] fix(fix-scripts): owners from the table, exact record keys, both edge quote styles, inputs at the mapping's level, flow-style jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-stale-reusable-pin.sh: - the candidate scan is built from the owners the table names, so a private table for another owner (`octo/example/.github/workflows/*`) finds and moves that owner's callers instead of reporting OK; - a lock record is the one whose unquoted key IS repo@sha: a key that merely contains it (`evilhyperpolymath/standards@`) is another record and keeps its ref and commit; - a double-quoted workflows: edge moves like a single-quoted one, keeping its quotes, so the record phase does not read it as still live. fix-rust-toolchain-input.sh: - inputs are read at the `with:` mapping's own level: quoted values are blanked (quoted keys stay keys), comments dropped, nested mappings removed, and a continuation line deeper than the mapping names nothing — so `components: "clippy, toolchain: nightly"` or `env: { toolchain: x }` no longer passes as the step's toolchain. fix-lock-self-sha-refs.sh: - a flow-style job (`call: { uses: owner/repo/.github/workflows/x.yml@sha }`) is a reusable call and keeps its SHA ref; a flow-style step (`- { uses: owner/act@sha }`, `steps: [ { uses: ... } ]`) is an action and is repaired. Contracts tc14, sp15, sp16, ls11 (136 total), gawk and mawk; shellcheck clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 17 ++++++ scripts/fix-rust-toolchain-input.sh | 32 ++++++++-- scripts/fix-stale-reusable-pin.sh | 22 +++++-- scripts/tests/fix-workflow-pins.sh | 93 +++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 12 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 16295817..a8137a6b 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -84,6 +84,12 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A p) p = k + 0; return p } + # The `uses` entry of a flow mapping written on the key'"'"'s own line + # (`call: { uses: ... }`, `- { uses: ... }`), or "". + function flow_uses(s, r) { + if (!match(s, /(^|[{,])[[:space:]]*["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?[^"'"'"'[:space:],}]+/)) return "" + r = substr(s, RSTART, RLENGTH); sub(/^.*uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?/, "", r); return r + } BEGIN { Q = "[\"'"'"']?" KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" @@ -104,6 +110,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 0 && keyat[p] == "steps"); item_indent = ind t = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", t) if (t ~ USES_RE) { if (item_steps) print "A\t" ref_of(t) } + else if (t ~ /^\{/) { r = flow_uses(t); if (r != "" && item_steps) print "A\t" r } else if (t ~ KEY_RE) { keyat[ind + 2] = keyname(t) if (t ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind + 2 } @@ -118,6 +125,16 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 0) ? nearest_below(p) : -1 if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "R\t" ref_of($0) else if (item_steps && ind > item_indent) print "A\t" ref_of($0) + } else if ($0 ~ /:[[:space:]]*\{/) { + # A flow mapping on the key'"'"'s line: a job written + # `call: { uses: ... }` is a reusable call; `steps: [ { uses: ... } ]` + # or a flow mapping inside a step is an action. + r = flow_uses($0) + if (r != "") { + p = nearest_below(ind) + if (p >= 0 && keyat[p] == "jobs") print "R\t" r + else if (key == "steps" || (item_steps && ind > item_indent)) print "A\t" r + } } keyat[ind] = key if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index f1b38b00..e51a396f 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -88,6 +88,22 @@ apply_to_file() { # `components: "}"` or a `# }` is text, not the end of the mapping. function code_only(s, t) { t = s; gsub(/"([^"\\]|\\.)*"/, "\"\"", t); gsub(/'"'"'[^'"'"']*'"'"'/, "'"'"''"'"'", t); sub(/(^|[[:space:]])#.*$/, "", t); return t } function brace_delta(s, a, b) { a = code_only(s); b = a; return gsub(/\{/, "", a) - gsub(/\}/, "", b) } + function nbrace(s, a) { a = s; return gsub(/\{/, "", a) } + # The inputs a flow line names at the `with:` mapping'"'"'s own level: quoted + # VALUES are blanked (a `"clippy, toolchain: nightly"` is text, a quoted + # key is still a key), comments dropped, and nested mappings removed — + # on the opener the outer `{` is kept, on a continuation line at depth 1 + # every balanced group is nested. A line deeper than that names nothing. + function inputs_view(s, keep_outer, t) { + t = s + gsub(/:[[:space:]]*"([^"\\]|\\.)*"/, ": \"\"", t); gsub(/:[[:space:]]*'"'"'[^'"'"']*'"'"'/, ": '"'"''"'"'", t) + sub(/(^|[[:space:]])#.*$/, "", t) + while (match(t, /\{[^{}]*\}/)) { + if (keep_outer && nbrace(t) <= 1) break + t = substr(t, 1, RSTART - 1) substr(t, RSTART + RLENGTH) + } + return t + } # A 40-hex pin of the action on a non-comment line, trailing comment # stripped. `@stable` and `@1.85.0` are tags: the ref carries the # channel and the step is not ours. @@ -117,7 +133,7 @@ apply_to_file() { } # Emit the buffered step, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_nested, m, pre, post, delim, comment) { + function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_nested, m, pre, post, delim, comment, v) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` the indentation of the first plain line under it. @@ -147,9 +163,12 @@ apply_to_file() { # A flow `with: {` spread over several lines: every entry up to # the brace that closes it is an input. An EMPTY `toolchain:` # entry (`toolchain: ,` / `toolchain: null`) is the key with no - # value: it is given one in place, never duplicated. - if (line ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } - else if (line ~ TC_RE || line ~ TC_FLOW_RE) has_tc = 1 + # value: it is given one in place, never duplicated. Only a line + # at the mapping'"'"'s own depth names inputs, and only outside + # quoted values and nested mappings. + v = (flow_depth == 1) ? inputs_view(line, 0) : "" + if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + else if (v ~ TC_RE || v ~ TC_FLOW_RE) has_tc = 1 flow_depth += brace_delta(line) if (flow_depth <= 0) in_flow = 0 continue @@ -172,8 +191,9 @@ apply_to_file() { has_with = 1; with_i = i; with_indent = skey; in_with = 1 } else if (line ~ WITH_FLOW_RE) { has_with = 1; with_i = i; with_flow = 1 - if (line ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } - else if (line ~ TC_FLOW_RE) has_tc = 1 + v = inputs_view(line, 1) + if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + else if (v ~ TC_FLOW_RE) has_tc = 1 flow_depth = brace_delta(line) if (flow_depth > 0) in_flow = 1 } else if (line ~ WITH_ANY_RE) { diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 0594f3bb..b17aedf2 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -50,16 +50,23 @@ REPO_PATH="${1:?Usage: fix-stale-reusable-pin.sh }" # Every row is checked before any caller is touched: a malformed row found # mid-run would leave earlier callers rewritten and the lock never reached. +# The owners the table names are the owners a caller may be pinned to; the +# candidate scan is built from them, so a private table for another owner +# (`octo/example/.github/workflows/*`) finds its callers. +TABLE_OWNERS_RE="" validate_table() { - local n=0 v_path v_stale v_new bad=0 + local n=0 v_path v_stale v_new bad=0 owners="" while IFS=$'\t' read -r v_path v_stale v_new _; do n=$((n + 1)) case "$v_path" in ''|'#'*) continue ;; esac [[ "$v_stale" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad stale SHA '$v_stale'" >&2; bad=1; } [[ "$v_new" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad replacement SHA '$v_new' for ${v_stale:0:7}" >&2; bad=1; } [[ "$v_path" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.*-]+$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad reusable path '$v_path'" >&2; bad=1; } + owners="$owners${v_path%%/*}"$'\n' done < "$PINS_TABLE" [ "$bad" -eq 0 ] || { echo "fix-stale-reusable-pin: table $PINS_TABLE is invalid; nothing was changed" >&2; exit 2; } + TABLE_OWNERS_RE=$(printf '%s' "$owners" | sed -E 's/[.]/\\./g' | sort -u | paste -sd'|' -) + [ -n "$TABLE_OWNERS_RE" ] || TABLE_OWNERS_RE="hyperpolymath" } validate_table [ -d "$REPO_PATH/.github/workflows" ] || { echo "OK (no workflows): $REPO_PATH"; exit 0; } @@ -193,7 +200,7 @@ USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space: # tag that merely STARTS with a dead SHA (`-patched`) is a different ref # the table never named, and must not be rewritten into `-patched`. REF_END="([\"'[:space:]#]|$)" -REUSABLE_REF_RE="hyperpolymath/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" +REUSABLE_REF_RE="(${TABLE_OWNERS_RE})/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edgerepository lock_stale=0 # a rewritten caller whose lock entry never had the edge: the lock was already out of sync unsupported=0 @@ -207,7 +214,6 @@ while IFS= read -r -d '' wf; do grep -qx "$lineno" <<<"$placed" && continue ref=$(grep -oE "${REUSABLE_REF_RE}${REF_END}" <<<"${numbered#*:}" | head -1 | sed -E "s/[\"'[:space:]#]\$//" || true) [ -n "$ref" ] || continue - [ -n "$ref" ] || continue if lookup_replacement "${ref%@*}" "${ref##*@}"; then echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 skip=1 @@ -270,9 +276,11 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then /^dependencies:[[:space:]]*$/ { sec = "d" } sec == "w" && /^ ["'"'"']/ { inwf = ($0 == wfkey) } sec == "w" && inwf && /^ - / { - i = index($0, "'"'"'" repo "@" stale "'"'"'") + # Either quote style; the edge keeps the one it has. + q = "'"'"'"; i = index($0, q repo "@" stale q) + if (!i) { q = "\""; i = index($0, q repo "@" stale q) } if (i) { - moved = substr($0, 1, i + length(repo)) "@" new "'"'"'" + moved = substr($0, 1, i + length(repo)) "@" new q # keep: an uncovered caller in this workflow still pins the stale # SHA, so the edge is added beside the old one, not replaced. if (keep == "1") { if (!seen[moved]++) { print; $0 = moved } else next } else $0 = moved @@ -296,7 +304,9 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # record and keeps its SHA. awk -v stale="$stale" -v new="$new" -v repo="$repo" -v still="$still" ' /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "d" && /^ ["'"'"']/ { inrec = (index($0, repo "@" stale) > 0) } + # The record is the one whose unquoted key IS repo@stale: a key that + # merely contains it (`evilhyperpolymath/standards@`) is another record. + sec == "d" && /^ ["'"'"']/ { k = $0; sub(/^ /, "", k); sub(/:$/, "", k); gsub(/["'"'"']/, "", k); inrec = (k == repo "@" stale) } sec == "d" && inrec && (/^ ["'"'"']/ || /^ (ref|commit):/) { if (still == 0) gsub(stale, new) else { c = $0; gsub(stale, new, c); copy[++nc] = c } diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index a8ff782b..e2f92a49 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -453,6 +453,31 @@ run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc13" check "a tag beginning with a SHA is a tag: untouched; the real SHA pin beside it is fixed" \ "[ \$rc -eq 0 ] && sed -n '4p' '$fx/tc13/.github/workflows/x.yml' | grep -q 'patched\$' && sed -n '5p' '$fx/tc13/.github/workflows/x.yml' | grep -q '# pinned\$' && [ \$(grep -c 'toolchain: stable' '$fx/tc13/.github/workflows/x.yml') -eq 1 ] && sed -n '6,7p' '$fx/tc13/.github/workflows/x.yml' | grep -q '^ toolchain: stable$'" +# tc14: inputs are read at the `with:` mapping's own level. `toolchain:` +# text inside a quoted value or a nested mapping is not an input; a quoted +# toolchain value still is. +mkdir -p "$fx/tc14/.github/workflows" +cat > "$fx/tc14/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { components: "clippy, toolchain: nightly" } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { env: { toolchain: nightly } } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { + toolchain: nightly, + }, + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { components: "clippy", toolchain: "1.85.0" } +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc14" +check "toolchain: inside a quoted value or a nested mapping is not an input: the three steps are fixed, the quoted real one is left" \ + "[ \$rc -eq 0 ] && [ \$(grep -c 'toolchain: stable' '$fx/tc14/.github/workflows/x.yml') -eq 3 ] && sed -n '5p' '$fx/tc14/.github/workflows/x.yml' | grep -q '^ with: { toolchain: stable, components: \"clippy, toolchain: nightly\" }$' && sed -n '7p' '$fx/tc14/.github/workflows/x.yml' | grep -q '^ with: { toolchain: stable, env: { toolchain: nightly } }$' && sed -n '9p' '$fx/tc14/.github/workflows/x.yml' | grep -q '^ with: { toolchain: stable,$' && sed -n '15p' '$fx/tc14/.github/workflows/x.yml' | grep -q 'toolchain: \"1.85.0\" }$'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -883,6 +908,41 @@ run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp14" check "a rewritten caller with no lock edge is reported and the run ends 3, lock untouched, caller moved" \ "[ \$rc -eq 3 ] && grep -q 'has no edge' <<<\"\$out\" && grep -q \"@$GOOD\" '$fx/sp14/.github/workflows/ci.yml' && cmp -s '$fx/sp14-before.lock' '$fx/sp14/.github/workflows/actions.lock'" +# sp15: the candidate scan is built from the owners the table names, so a +# private table for another owner finds its callers. +mkdir -p "$fx/sp15/.github/workflows" +printf 'octo/example/.github/workflows/*\t%s\t%s\tprivate owner\n' "$STALE_A" "$GOOD" > "$fx/sp15-pins.tsv" +printf 'jobs:\n ci:\n uses: octo/example/.github/workflows/ci.yml@%s\n' "$STALE_A" > "$fx/sp15/.github/workflows/ci.yml" +run_fix env PINS_TABLE="$fx/sp15-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp15" +check "a table row for another owner finds and moves that owner's caller" \ + "[ \$rc -eq 0 ] && grep -q \"octo/example/.github/workflows/ci.yml@$GOOD\$\" '$fx/sp15/.github/workflows/ci.yml' && grep -q '^FIXED' <<<\"\$out\"" + +# sp16: a double-quoted lock edge moves like a single-quoted one and keeps +# its quotes; a record whose key merely CONTAINS repo@sha +# (`evilhyperpolymath/standards@`) is another record and stays. +mkdir -p "$fx/sp16/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp16/.github/workflows/ci.yml" +cat > "$fx/sp16/.github/workflows/actions.lock" < "$fx/ls/.github/workflows/actions.lock" <<'LOCK' @@ -1183,6 +1243,39 @@ TAG_MAP="$fx/ls10-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx check "an unquoted self-SHA ref is found and repaired, unquoted" \ "[ \$rc10 -eq 0 ] && grep -q '^ ref: v1.0.0$' '$fx/ls10/.github/workflows/actions.lock' && grep -q '^FIXED' '$fx/ls10.out'" +# ls11: a flow-style job (`call: { uses: ... }`) is a reusable call and keeps +# its SHA ref; a flow-style step (`- { uses: ... }`) is an action and is repaired. +mkdir -p "$fx/ls11/.github/workflows" +cat > "$fx/ls11/.github/workflows/ci.yml" < "$fx/ls11/.github/workflows/actions.lock" < "$fx/ls11-tags.tsv" +TAG_MAP="$fx/ls11-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls11" --offline > "$fx/ls11.out" 2>&1; rc11=$? +check "a flow-style job is a reusable call (SHA ref kept); a flow-style step is an action (repaired)" \ + "[ \$rc11 -eq 0 ] && grep -A1 \"^ 'octo/example@$SHA_A':\$\" '$fx/ls11/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_A'\" && grep -A1 \"^ 'octo/act@$SHA_B':\$\" '$fx/ls11/.github/workflows/actions.lock' | grep -q \"ref: 'v1.2.3'\" && ! grep -q UNRESOLVED '$fx/ls11.out'" + # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser # cannot read instead of reporting OK. From 37368cd7eca0351fce6e5e723b82e9af23e7ecbd Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:48:09 +0000 Subject: [PATCH 18/44] test(fix-scripts): cover SHA boundaries, atomic refusal, pin precedence, lock merging, and tag resolution --- scripts/tests/fix-workflow-pins.sh | 301 +++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index e2f92a49..64dcc3fe 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -478,6 +478,92 @@ run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc14" check "toolchain: inside a quoted value or a nested mapping is not an input: the three steps are fixed, the quoted real one is left" \ "[ \$rc -eq 0 ] && [ \$(grep -c 'toolchain: stable' '$fx/tc14/.github/workflows/x.yml') -eq 3 ] && sed -n '5p' '$fx/tc14/.github/workflows/x.yml' | grep -q '^ with: { toolchain: stable, components: \"clippy, toolchain: nightly\" }$' && sed -n '7p' '$fx/tc14/.github/workflows/x.yml' | grep -q '^ with: { toolchain: stable, env: { toolchain: nightly } }$' && sed -n '9p' '$fx/tc14/.github/workflows/x.yml' | grep -q '^ with: { toolchain: stable,$' && sed -n '15p' '$fx/tc14/.github/workflows/x.yml' | grep -q 'toolchain: \"1.85.0\" }$'" +# Ref length is part of the contract: abbreviated and overlong hex refs are +# not full SHA pins, and another action at the same SHA is not dtolnay. +tc_boundary="$fx/tc-boundary/.github/workflows" +mkdir -p "$tc_boundary" +cat > "$tc_boundary/ci.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - uses: other/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +YML +cat > "$fx/tc-boundary-expected.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - uses: other/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + with: + toolchain: stable +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc-boundary" +check "only the exact 40-hex dtolnay pin is repaired, including the last step at EOF" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc-boundary-expected.yml' '$tc_boundary/ci.yml'" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$tc_boundary/ci.yml" +check "SHA boundary controls remain unchanged on a second direct-file run" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc-boundary-expected.yml' '$tc_boundary/ci.yml' && ! grep -q '^FIXED' <<<\"\$out\"" + +# Refusing an unsupported step is atomic for its file, even when a preceding +# supported step could be repaired. No partial output may replace the file. +tc_atomic="$fx/tc-atomic/.github/workflows" +mkdir -p "$tc_atomic" +cat > "$tc_atomic/ci.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - { uses: dtolnay/rust-toolchain@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb } +YML +cp "$tc_atomic/ci.yml" "$fx/tc-atomic-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc-atomic" +check "an unsupported step prevents earlier supported repairs in the same file" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 5' <<<\"\$out\" && ! grep -q '^FIXED' <<<\"\$out\" && cmp -s '$fx/tc-atomic-before.yml' '$tc_atomic/ci.yml'" + +# Indentless step sequences and a following job use different key columns. +# A folded run scalar is data; a quoted null input in the next job is filled. +tc_indent="$fx/tc-indent/.github/workflows" +mkdir -p "$tc_indent" +cat > "$tc_indent/ci.yaml" <<'YML' +jobs: + first: + steps: + - with: + components: clippy + uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - run: >- + echo dtolnay/rust-toolchain@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + second: + steps: + - uses: dtolnay/rust-toolchain@cccccccccccccccccccccccccccccccccccccccc + with: + 'toolchain': null # keep this comment +YML +cat > "$fx/tc-indent-expected.yaml" <<'YML' +jobs: + first: + steps: + - with: + toolchain: stable + components: clippy + uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + - run: >- + echo dtolnay/rust-toolchain@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + second: + steps: + - uses: dtolnay/rust-toolchain@cccccccccccccccccccccccccccccccccccccccc + with: + 'toolchain': stable # keep this comment +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc-indent" +check "indentless steps and a following job get inputs at their own key columns, preserving scalar data" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc-indent-expected.yaml' '$tc_indent/ci.yaml'" + # ── 2. fix-stale-reusable-pin.sh ───────────────────────────────────────────── STALE_A=d135b05bfc647d0c0fbfedc7e80f37ea50f49236 # Window A (table row for rust-ci-reusable only) STALE_B=8f2ee50841e216cd8c192eeb68953118190f105c # Window B (table row for every standards reusable) @@ -943,6 +1029,126 @@ run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp16" check "a double-quoted edge and record move with their quotes; a key that merely contains repo@sha is left alone" \ "[ \$rc -eq 3 ] && grep -q \"^ - \\\"hyperpolymath/standards@$GOOD\\\"\$\" '$fx/sp16/.github/workflows/actions.lock' && grep -q \"^ \\\"hyperpolymath/standards@$GOOD\\\":\$\" '$fx/sp16/.github/workflows/actions.lock' && grep -q \"^ ref: \\\"$GOOD\\\"\$\" '$fx/sp16/.github/workflows/actions.lock' && grep -A1 \"^ 'evilhyperpolymath/standards@$STALE_A':\$\" '$fx/sp16/.github/workflows/actions.lock' | grep -q \"ref: '$STALE_A'\" && [ \$(grep -c \"$STALE_A\" '$fx/sp16/.github/workflows/actions.lock') -eq 3 ]" +# A supported call beside an unsupported flow-style job must remain stale: +# the file and its existing lock edge are one refused migration. +sp_atomic="$fx/sp-atomic/.github/workflows" +mkdir -p "$sp_atomic" +cat > "$sp_atomic/ci.yml" < "$sp_atomic/actions.lock" < "$fx/ordered-pins.tsv" + chosen="$GOOD" + else + printf 'fixture/reusable/.github/workflows/*\t%s\t%s\twildcard first\nfixture/reusable/.github/workflows/ci.yml\t%s\t%s\texact second\n' \ + "$STALE_A" "$alternate" "$STALE_A" "$GOOD" > "$fx/ordered-pins.tsv" + chosen="$alternate" + fi + printf 'jobs:\n call:\n uses: fixture/reusable/.github/workflows/ci.yml@%s\n' "$STALE_A" > "$sp_order/ci.yml" + printf 'jobs:\n call:\n uses: fixture/reusable/.github/workflows/ci.yml@%s\n' "$chosen" > "$fx/sp-order-expected.yml" + run_fix env PINS_TABLE="$fx/ordered-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-order" + check "overlapping table rows choose the first match when $first_match comes first" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/sp-order-expected.yml' '$sp_order/ci.yml'" + run_fix env PINS_TABLE="$fx/ordered-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-order" + check "the $first_match-first replacement is a stable no-op on the next run" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/sp-order-expected.yml' '$sp_order/ci.yml' && ! grep -q '^FIXED' <<<\"\$out\"" +done + +# A replacement may already have a dependency record. Merging must preserve +# its spelling and metadata and produce a sorted union without duplicate uses. +sp_existing="$fx/sp-existing/.github/workflows" +mkdir -p "$sp_existing" +printf 'jobs:\n call:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$sp_existing/ci.yml" +cat > "$sp_existing/actions.lock" < "$fx/sp-existing-expected.lock" < "$fx/sp-existing-expected.yml" +check "the caller follows the existing replacement and the run reports the incomplete closure" \ + "cmp -s '$fx/sp-existing-expected.yml' '$sp_existing/ci.yml' && grep -q 'nested uses: list was NOT recomputed' <<<\"\$out\"" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-existing" +check "migration into an existing record is byte-identical on the second run and exits 0" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/sp-existing-expected.lock' '$sp_existing/actions.lock' && cmp -s '$fx/sp-existing-expected.yml' '$sp_existing/ci.yml'" + +# Validate stale refs and reusable paths as well as replacement refs. Invalid +# rows after a matching good row must still stop all mutations up front. +while IFS=$'\t' read -r invalid_path invalid_sha diagnostic; do + printf 'hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml\t%s\t%s\tgood row\n%s\t%s\t%s\tbad row\n' \ + "$STALE_A" "$GOOD" "$invalid_path" "$invalid_sha" "$GOOD" > "$fx/invalid-row-pins.tsv" + run_fix env PINS_TABLE="$fx/invalid-row-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp12" + check "$diagnostic in a later table row refuses the run before caller or lock changes" \ + "[ \$rc -eq 2 ] && grep -q 'table row 2: $diagnostic' <<<\"\$out\" && cmp -s '$fx/sp12-before.yml' '$fx/sp12/.github/workflows/rust-ci.yml' && cmp -s '$fx/sp12-before.lock' '$fx/sp12/.github/workflows/actions.lock'" +done <<'TSV' +fixture/reusable/.github/workflows/ci.yml 123456789012345678901234567890123456789 bad stale SHA +fixture/reusable/.github/workflows/../ci.yml aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bad reusable path +TSV + # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── mkdir -p "$fx/ls/.github/workflows" cat > "$fx/ls/.github/workflows/actions.lock" <<'LOCK' @@ -1168,6 +1374,30 @@ run_lookup check "a ref different from its record's SHA is untouched" \ "[ \$rc -eq 0 ] && ! grep -q UNRESOLVED <<<\"\$out\" && [ ! -s '$fx/git-calls' ] && cmp -s '$fx/non-self-before.lock' '$lookup_lock'" +# Equal specificity is resolved by tag length. A missing optional map still +# allows the Git fallback, whose response remains controlled by the stub. +lookup_mode=() +write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_A" +printf '%s\trefs/tags/v2.4.7\n%s\trefs/tags/v2.4.7-rc1\n' "$SHA_A" "$SHA_A" > "$fx/git-response" +write_action_lock "$fx/lookup-length-expected.lock" fixture/action "$SHA_A" v2.4.7-rc1 +: > "$fx/lookup-tags.tsv" +: > "$fx/git-calls" +run_lookup +check "Git fallback breaks equal dot-count ties by tag length" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/lookup-length-expected.lock' '$lookup_lock' && cmp -s '$fx/expected-git-calls' '$fx/git-calls'" +write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_A" +rm "$fx/lookup-tags.tsv" +: > "$fx/git-calls" +run_lookup +check "a missing tag map falls back to Git and repairs the record" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/lookup-length-expected.lock' '$lookup_lock' && cmp -s '$fx/expected-git-calls' '$fx/git-calls'" +write_action_lock "$lookup_lock" fixture/action "$SHA_A" "$SHA_A" +printf '%s\trefs/tags/v2.4.7&unsafe\n' "$SHA_A" > "$fx/git-response" +: > "$fx/git-calls" +run_lookup +check "unsafe tags from Git are refused just like unsafe mapped tags" \ + "[ \$rc -eq 1 ] && grep -q 'carries characters' <<<\"\$out\" && cmp -s '$fx/lookup-before.lock' '$lookup_lock' && cmp -s '$fx/expected-git-calls' '$fx/git-calls'" + # ls8: a record's class comes from real `uses:` keys. The same reusable path # inside a `run: |` script or a fixture under `env:` is text: it cannot make an # action record "a reusable call" and so hide its self-SHA ref from repair. @@ -1276,6 +1506,77 @@ TAG_MAP="$fx/ls11-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx check "a flow-style job is a reusable call (SHA ref kept); a flow-style step is an action (repaired)" \ "[ \$rc11 -eq 0 ] && grep -A1 \"^ 'octo/example@$SHA_A':\$\" '$fx/ls11/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_A'\" && grep -A1 \"^ 'octo/act@$SHA_B':\$\" '$fx/ls11/.github/workflows/actions.lock' | grep -q \"ref: 'v1.2.3'\" && ! grep -q UNRESOLVED '$fx/ls11.out'" +# At the same revision a reusable call takes precedence over an action use, +# even across workflow files and when a tag is available. Removing the call +# exposes the action use, including its subdirectory, for repair. +ls_shared="$fx/ls-shared/.github/workflows" +mkdir -p "$ls_shared" +printf 'jobs:\n call:\n uses: fixture/dual/.github/workflows/ci.yml@%s\n' "$SHA_A" > "$ls_shared/reusable.yml" +printf 'jobs:\n build:\n steps:\n - name: Nested action\n uses: fixture/dual/actions/check@%s\n' "$SHA_A" > "$ls_shared/action.yaml" +write_action_lock "$ls_shared/actions.lock" fixture/dual "$SHA_A" "$SHA_A" +cp "$ls_shared/actions.lock" "$fx/ls-shared-before.lock" +printf 'fixture/dual\t%s\tv1.2.3\n' "$SHA_A" > "$fx/ls-shared-tags.tsv" +run_fix env TAG_MAP="$fx/ls-shared-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls-shared" --offline +check "a reusable and action at the same revision keep their shared SHA ref despite an available tag" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/ls-shared-before.lock' '$ls_shared/actions.lock' && ! grep -qE '^(FIXED|UNRESOLVED)' <<<\"\$out\"" +rm "$ls_shared/reusable.yml" +write_action_lock "$fx/ls-shared-expected.lock" fixture/dual "$SHA_A" v1.2.3 +run_fix env TAG_MAP="$fx/ls-shared-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls-shared" --offline +check "without the reusable call the subdirectory action in a .yaml workflow gets its tag" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/ls-shared-expected.lock' '$ls_shared/actions.lock' && grep -q '^FIXED fixture/dual' <<<\"\$out\"" + +# A missing tag and an unsafe tag bracket a resolvable record. Neither may +# stop that repair, alter neighboring records, or hide the unresolved status. +ls_partial="$fx/ls-partial/.github/workflows" +mkdir -p "$ls_partial" +cat > "$ls_partial/actions.lock" < "$fx/ls-partial-expected.lock" < "$fx/ls-partial-tags.tsv" +run_fix env TAG_MAP="$fx/ls-partial-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls-partial" --offline +check "mixed records repair the resolvable ref in place and preserve both unresolved records" \ + "[ \$rc -eq 1 ] && cmp -s '$fx/ls-partial-expected.lock' '$ls_partial/actions.lock' && [ \$(grep -c '^FIXED' <<<\"\$out\") -eq 1 ] && [ \$(grep -c '^UNRESOLVED' <<<\"\$out\") -eq 2 ] && grep -q '^Rewrote 1 record(s)' <<<\"\$out\"" +run_fix env TAG_MAP="$fx/ls-partial-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls-partial" --offline +check "a partially resolved lock is byte-identical on rerun and still exits 1" \ + "[ \$rc -eq 1 ] && cmp -s '$fx/ls-partial-expected.lock' '$ls_partial/actions.lock' && ! grep -qE '^(FIXED|OK)' <<<\"\$out\" && [ \$(grep -c '^UNRESOLVED' <<<\"\$out\") -eq 2 ]" +printf 'fixture/missing\t%s\tv1.0.0\nfixture/unsafe\t%s\tv3.0.0\n' "$SHA_A" "$SHA_C" > "$fx/ls-partial-tags.tsv" +run_fix env TAG_MAP="$fx/ls-partial-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls-partial" --offline +check "supplying safe tags resolves the remaining records without revisiting the repaired one" \ + "[ \$rc -eq 0 ] && [ \$(grep -c '^FIXED' <<<\"\$out\") -eq 2 ] && ! grep -q '^UNRESOLVED' <<<\"\$out\" && grep -q \"ref: 'v1.0.0' # no tag\" '$ls_partial/actions.lock' && grep -q \"ref: 'v3.0.0'\" '$ls_partial/actions.lock' && grep -q 'ref: \"v2.3.4\" # keep quote style and comment' '$ls_partial/actions.lock'" +cp "$ls_partial/actions.lock" "$fx/ls-partial-resolved.lock" +run_fix env TAG_MAP="$fx/ls-partial-tags.tsv" bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls-partial" --offline +check "a fully resolved mixed lock is an idempotent success" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/ls-partial-resolved.lock' '$ls_partial/actions.lock' && grep -q '^OK (no self-SHA action records)' <<<\"\$out\"" + # Portability (review): Debian/Ubuntu's default awk is mawk. Run the same # fixture through mawk when it is installed, and refuse a lock the parser # cannot read instead of reporting OK. From ec941ef09488eeb208932261e66c8a4f4cc06759 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:04:43 +0000 Subject: [PATCH 19/44] fix(fix-scripts): null spellings, next-line delimiters, exact step keys, inline jobs maps, lock-edge dedupe and one fixer per checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review rounds 10 and 11 on #615 (Codex, CodeRabbit): fix-rust-toolchain-input.sh - The key with no value is YAML null in every unquoted spelling (`null`, `Null`, `NULL`, `~`, nothing) or the empty string (`""`, `''`): filled in place, never duplicated. `"null"` quoted is the string, a value. inputs_view keeps an empty quoted value empty and turns a non-empty one into `"v"`, so the two are told apart on a flow line. - A multi-line flow `with:` whose empty entry has its delimiter on the next line (`toolchain: null` then `}`) is filled where it stands; a bare `toolchain:` there waits for the next token, which must be `}` or `,` — anything else is a value spread over lines and the step is reported, not guessed. - An empty block `toolchain:` is a nested mapping when the next PLAIN line sits deeper, comment and blank lines between them notwithstanding. - Only a pin the parser cannot place AS A STEP is unsupported: a workflow-level `env:` string naming the action is data, a key of a flow-style step spread over lines is still a step. - Upper-case hex is the same pin. fix-lock-self-sha-refs.sh - A step's `uses:` is the key at the item's own key column (`- name:` puts it under `name`; a bare dash learns the column from its first key); a `uses:` nested under `with:`/`env:` is an input and names no action site. - The whole `jobs: { … }` mapping on one line is a job map: each `uses:` in it is a reusable call. Every `uses:` of a one-line `steps: [ … ]` is an action site, not only the first. fix-stale-reusable-pin.sh - The only wildcard a table row may carry is a terminal `/*`. - Upper-case hex callers are looked up in lower case and rewritten as written; the lock edge (lower case) moves. - The lock's workflow key is compared without regard to quote style. - An edge the workflow entry already lists (a job already at the replacement) is never written a second time: the lock is read once for the existing edges, once to move the stale one. scripts/lib/pin-fixer-lock.sh (new): one workflow-pin fixer per checkout at a time. All three fixers rewrite .github/workflows/*.yml and actions.lock of the same tree; under `dispatch-runner.sh --parallel N` two manifest entries for one repository can start together, so each fixer takes a flock keyed by the checkout's real path before it reads anything, waits PIN_FIXER_LOCK_WAIT seconds (default 600) for a holder, and exits 2 having changed nothing if the holder stays. 15 new contracts in scripts/tests/fix-workflow-pins.sh (174 in all), run under gawk and mawk. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 41 +++- scripts/fix-rust-toolchain-input.sh | 81 +++++-- scripts/fix-stale-reusable-pin.sh | 49 +++- scripts/lib/pin-fixer-lock.sh | 35 +++ scripts/tests/fix-workflow-pins.sh | 339 ++++++++++++++++++++++++++++ 5 files changed, 500 insertions(+), 45 deletions(-) create mode 100644 scripts/lib/pin-fixer-lock.sh diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index a8137a6b..a1eacbb7 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -43,6 +43,9 @@ OFFLINE=0; [ "${2:-}" = "--offline" ] && OFFLINE=1 LOCK="$REPO_PATH/.github/workflows/actions.lock" [ -f "$LOCK" ] || { echo "OK (no actions.lock): $REPO_PATH"; exit 0; } +# One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh). +. "$SCRIPT_DIR/lib/pin-fixer-lock.sh" +pin_fixer_lock "$REPO_PATH" # Read tag names from stdin and print the most specific: more dots, then longer. best_tag() { awk '{ n = gsub(/\./, ".", $0); print n "\t" length($0) "\t" $0 }' | sort -t $'\t' -k1,1nr -k2,2nr | head -1 | cut -f3; } @@ -84,11 +87,16 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A p) p = k + 0; return p } - # The `uses` entry of a flow mapping written on the key'"'"'s own line - # (`call: { uses: ... }`, `- { uses: ... }`), or "". - function flow_uses(s, r) { - if (!match(s, /(^|[{,])[[:space:]]*["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?[^"'"'"'[:space:],}]+/)) return "" - r = substr(s, RSTART, RLENGTH); sub(/^.*uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?/, "", r); return r + # Every `uses` entry of the flow mappings written on the key'"'"'s own + # line (`call: { uses: ... }`, `- { uses: ... }`, `jobs: { a: { uses: ... }, + # b: { uses: ... } }`), into out[1..n]; returns n. + function flow_uses_all(s, out, n, r, t) { + n = 0; split("", out); t = s + while (match(t, /(^|[{,])[[:space:]]*["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?[^"'"'"'[:space:],}]+/)) { + r = substr(t, RSTART, RLENGTH); sub(/^.*uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?/, "", r) + out[++n] = r; t = substr(t, RSTART + RLENGTH) + } + return n } BEGIN { Q = "[\"'"'"']?" @@ -109,8 +117,13 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A ind) delete keyat[k] item_steps = (p >= 0 && keyat[p] == "steps"); item_indent = ind t = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", t) + # The step'"'"'s own keys sit at the column of its first one (`- name:` + # puts `uses:` under `name`); a bare dash learns it from the next + # key line. A deeper `uses:` belongs to `with:`, `env:` or some + # other input and is data. + item_key = (t == "") ? -1 : length($0) - length(t) if (t ~ USES_RE) { if (item_steps) print "A\t" ref_of(t) } - else if (t ~ /^\{/) { r = flow_uses(t); if (r != "" && item_steps) print "A\t" r } + else if (t ~ /^\{/) { m = flow_uses_all(t, fu); if (item_steps) for (j = 1; j <= m; j++) print "A\t" fu[j] } else if (t ~ KEY_RE) { keyat[ind + 2] = keyname(t) if (t ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind + 2 } @@ -121,19 +134,23 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= ind) delete keyat[k] if (ind <= item_indent) item_steps = 0 + if (item_steps && item_key < 0 && ind > item_indent) item_key = ind if (key == "uses") { p = nearest_below(ind); g = (p >= 0) ? nearest_below(p) : -1 if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "R\t" ref_of($0) - else if (item_steps && ind > item_indent) print "A\t" ref_of($0) + else if (item_steps && ind == item_key) print "A\t" ref_of($0) } else if ($0 ~ /:[[:space:]]*\{/) { # A flow mapping on the key'"'"'s line: a job written - # `call: { uses: ... }` is a reusable call; `steps: [ { uses: ... } ]` + # `call: { uses: ... }`, or the whole `jobs: { ... }` mapping + # on one line, is a reusable call; `steps: [ { uses: ... } ]` # or a flow mapping inside a step is an action. - r = flow_uses($0) - if (r != "") { + m = flow_uses_all($0, fu) + if (m) { p = nearest_below(ind) - if (p >= 0 && keyat[p] == "jobs") print "R\t" r - else if (key == "steps" || (item_steps && ind > item_indent)) print "A\t" r + for (j = 1; j <= m; j++) { + if (key == "jobs" || (p >= 0 && keyat[p] == "jobs")) print "R\t" fu[j] + else if (key == "steps" || (item_steps && ind == item_key)) print "A\t" fu[j] + } } } keyat[ind] = key diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index e51a396f..12afb1f4 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -61,6 +61,11 @@ set -euo pipefail TOOLCHAIN="${TOOLCHAIN:-stable}" TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh): +# a single workflow file is locked by the checkout it belongs to. +. "$SCRIPT_DIR/lib/pin-fixer-lock.sh" +if [ -d "$TARGET" ]; then pin_fixer_lock "$TARGET"; else pin_fixer_lock "${TARGET%/.github/workflows/*}"; fi # Add missing TOOLCHAIN inputs to SHA-pinned Rust toolchain steps in file $1. # Return 0 for a repair or no-op; return 2 and leave the file untouched if @@ -68,7 +73,7 @@ TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" apply_to_file() { local f="$1" tmp rc [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } - grep -qE 'dtolnay/rust-toolchain@[0-9a-f]{40}' "$f" || return 0 + grep -qE 'dtolnay/rust-toolchain@[0-9a-fA-F]{40}' "$f" || return 0 tmp=$(mktemp) set +e @@ -89,14 +94,15 @@ apply_to_file() { function code_only(s, t) { t = s; gsub(/"([^"\\]|\\.)*"/, "\"\"", t); gsub(/'"'"'[^'"'"']*'"'"'/, "'"'"''"'"'", t); sub(/(^|[[:space:]])#.*$/, "", t); return t } function brace_delta(s, a, b) { a = code_only(s); b = a; return gsub(/\{/, "", a) - gsub(/\}/, "", b) } function nbrace(s, a) { a = s; return gsub(/\{/, "", a) } - # The inputs a flow line names at the `with:` mapping'"'"'s own level: quoted - # VALUES are blanked (a `"clippy, toolchain: nightly"` is text, a quoted - # key is still a key), comments dropped, and nested mappings removed — + # The inputs a flow line names at the `with:` mapping'"'"'s own level: a + # quoted VALUE becomes the one-letter text `"v"` (a `"clippy, toolchain: + # nightly"` is text, a quoted key is still a key) while an empty one + # (`""`, `'"'"''"'"'`) stays empty, comments are dropped, nested mappings removed — # on the opener the outer `{` is kept, on a continuation line at depth 1 # every balanced group is nested. A line deeper than that names nothing. function inputs_view(s, keep_outer, t) { t = s - gsub(/:[[:space:]]*"([^"\\]|\\.)*"/, ": \"\"", t); gsub(/:[[:space:]]*'"'"'[^'"'"']*'"'"'/, ": '"'"''"'"'", t) + gsub(/:[[:space:]]*"([^"\\]|\\.)+"/, ": \"v\"", t); gsub(/:[[:space:]]*'"'"'[^'"'"']+'"'"'/, ": '"'"'v'"'"'", t) sub(/(^|[[:space:]])#.*$/, "", t) while (match(t, /\{[^{}]*\}/)) { if (keep_outer && nbrace(t) <= 1) break @@ -108,11 +114,12 @@ apply_to_file() { # stripped. `@stable` and `@1.85.0` are tags: the ref carries the # channel and the step is not ours. # The 40 hex characters must END the ref: `@-patched` is a tag - # that merely begins with one, and carries its own channel. + # that merely begins with one, and carries its own channel. Either + # case: git resolves a commit ID written in upper-case hex too. function sha_ref(s, t, m, nxt) { if (!plain(s)) return 0 t = s; sub(/[[:space:]]#.*$/, "", t) - if (!match(t, /dtolnay\/rust-toolchain@[0-9a-f]+/)) return 0 + if (!match(t, /dtolnay\/rust-toolchain@[0-9a-fA-F]+/)) return 0 m = substr(t, RSTART + 23, RLENGTH - 23) nxt = substr(t, RSTART + RLENGTH, 1) return (length(m) == 40 && (nxt == "" || nxt ~ /["'"'"'[:space:],}\]]/)) @@ -131,9 +138,16 @@ apply_to_file() { for (k in keyat) if (k + 0 <= ind && k + 0 > best) best = k + 0 return (best >= 0 && keyat[best] == "steps") } + # Return true if the nearest recorded key strictly above ind is steps: + # a key line inside a flow-style step spread over several lines. + function under_steps(ind, k, best) { + best = -1 + for (k in keyat) if (k + 0 < ind && k + 0 > best) best = k + 0 + return (best >= 0 && keyat[best] == "steps") + } # Emit the buffered step, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_nested, m, pre, post, delim, comment, v) { + function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_empty_eol, tc_nested, tc_await, tc_flow_await, m, pre, post, delim, comment, v, w) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` the indentation of the first plain line under it. @@ -156,7 +170,7 @@ apply_to_file() { } if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } has_with = 0; with_bad = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; in_flow = 0; child_indent = -1; has_tc = 0 - tc_empty_i = 0; tc_empty_flow = 0; tc_nested = 0 + tc_empty_i = 0; tc_empty_flow = 0; tc_empty_eol = 0; tc_nested = 0; tc_await = 0; tc_flow_await = 0 for (i = 1; i <= n; i++) { line = buf[i]; ind = indent_of(line) if (in_flow) { @@ -167,7 +181,13 @@ apply_to_file() { # at the mapping'"'"'s own depth names inputs, and only outside # quoted values and nested mappings. v = (flow_depth == 1) ? inputs_view(line, 0) : "" + # A bare `toolchain:` that ended the previous line is empty + # only when the next token closes the mapping or moves on + # (`}`, `,`); anything else is its value, spread over lines, + # and not this fixer'"'"'s to place. + if (tc_flow_await) { w = code_only(line); sub(/^[[:space:]]*/, "", w); if (w != "") { tc_flow_await = 0; if (w !~ /^[,}]/) tc_nested = 1 } } if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_RE || v ~ TC_FLOW_RE) has_tc = 1 flow_depth += brace_delta(line) if (flow_depth <= 0) in_flow = 0 @@ -179,11 +199,13 @@ apply_to_file() { # Only a DIRECT child of `with:` is an input: the contents of # another input'"'"'s block scalar (`components: |`) sit deeper. if (child_indent < 0) child_indent = ind - if (ind == child_indent && line ~ TC_EMPTY_RE) tc_empty_i = i + # An empty `toolchain:` whose next plain line (blank and + # comment lines between them notwithstanding) sits deeper is + # a nested mapping, not an empty value: nothing this fixer + # can repair. + if (tc_await) { tc_await = 0; if (ind > child_indent) tc_nested = 1 } + if (ind == child_indent && line ~ TC_EMPTY_RE) { tc_empty_i = i; tc_await = 1 } else if (ind == child_indent && line ~ TC_RE) has_tc = 1 - # An empty `toolchain:` followed by deeper lines is a nested - # mapping, not an empty value: nothing this fixer can repair. - else if (tc_empty_i && !tc_empty_flow && ind > child_indent && i == tc_empty_i + 1) tc_nested = 1 } } if (!in_with && !has_with && length(key_indent_of(line)) == skey) { @@ -193,6 +215,7 @@ apply_to_file() { has_with = 1; with_i = i; with_flow = 1 v = inputs_view(line, 1) if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_FLOW_RE) has_tc = 1 flow_depth = brace_delta(line) if (flow_depth > 0) in_flow = 1 @@ -204,8 +227,10 @@ apply_to_file() { } } } + if (tc_flow_await) tc_nested = 1 if (tc_nested) { unrec[++nunrec] = lnum[tc_empty_i]; with_bad = 1 } if (tc_empty_i && tc_empty_flow && !match(buf[tc_empty_i], TC_FLOW_EMPTY_RE)) { unrec[++nunrec] = lnum[tc_empty_i]; with_bad = 1 } + if (tc_empty_i && tc_empty_eol && !match(buf[tc_empty_i], TC_FLOW_EOL_RAW_RE)) { unrec[++nunrec] = lnum[tc_empty_i]; with_bad = 1 } if (has_tc || with_bad) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } for (i = 1; i <= n; i++) { line = buf[i] @@ -215,12 +240,14 @@ apply_to_file() { match(line, TC_FLOW_EMPTY_RE) pre = substr(line, 1, RSTART - 1); m = substr(line, RSTART, RLENGTH); post = substr(line, RSTART + RLENGTH) delim = substr(m, length(m)) - sub(/[[:space:]]*(null|~)?[[:space:]]*[,}]$/, "", m) + sub("[[:space:]]*" NULLV "[[:space:]]*[,}]$", "", m) line = pre m " " toolchain (delim == "}" ? " }" : ",") post } else { + # A block entry, or a flow entry whose delimiter is on + # the next line: the value ends the line either way. comment = "" if (match(line, /[[:space:]]+#.*$/)) comment = substr(line, RSTART) - sub(/:[[:space:]]*(null|~)?[[:space:]]*(#.*)?$/, "", line) + sub(":[[:space:]]*" NULLV "[[:space:]]*(#.*)?$", "", line) line = line ": " toolchain comment } print line; fixed++ @@ -257,10 +284,20 @@ apply_to_file() { WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" TC_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:]]" TC_FLOW_RE = "[{,][[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:],}]" - # The key present with no value (`toolchain:`, `toolchain: null`, `~`): - # not an input the action can read, and not a key to write twice. - TC_EMPTY_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*(null|~)?[[:space:]]*(#.*)?$" - TC_FLOW_EMPTY_RE = "(^|[{,])[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*(null|~)?[[:space:]]*[,}]" + # The key present with no value — YAML null in the spellings the core + # schema admits unquoted (`toolchain:`, `null`, `Null`, `NULL`, `~`) + # or the empty string (`""`, `'"'"''"'"'`): not an input the action can + # read, and not a key to write twice. `"null"` quoted is the string + # null, a value. + NULLV = "(null|Null|NULL|~|\"\"|'"'"''"'"')?" + TC_EMPTY_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*" NULLV "[[:space:]]*(#.*)?$" + TC_FLOW_EMPTY_RE = "(^|[{,])[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*" NULLV "[[:space:]]*[,}]" + # The same entry with its delimiter on the next line of a multi-line + # flow mapping (`toolchain: null` then `}`); a BARE key there waits + # for the next token, which must be that delimiter. + TC_FLOW_EOL_RE = "(^|[{,])[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*" NULLV "[[:space:]]*$" + TC_FLOW_EOL_RAW_RE = "(^|[{,])[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*" NULLV "[[:space:]]*(#.*)?$" + TC_FLOW_BARE_EOL_RE = "(^|[{,])[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*$" } { ind = indent_of($0) @@ -287,8 +324,10 @@ apply_to_file() { record_key($0) if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } # A key line that itself carries the pin (`steps: [ { uses: ... } ]` - # on one line) is a step this parser cannot place. - if (sha_ref($0)) unrec[++nunrec] = NR + # on one line, a key of a flow-style step spread over lines) is a + # step this parser cannot place. The pin inside some other value + # (a workflow-level `env:` string naming the action) is data. + if (sha_ref($0) && (parent_is_steps(ind) || under_steps(ind))) unrec[++nunrec] = NR } else if (sha_ref($0) && parent_is_steps(ind)) unrec[++nunrec] = NR print } diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index b17aedf2..e52654b9 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -61,7 +61,9 @@ validate_table() { case "$v_path" in ''|'#'*) continue ;; esac [[ "$v_stale" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad stale SHA '$v_stale'" >&2; bad=1; } [[ "$v_new" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad replacement SHA '$v_new' for ${v_stale:0:7}" >&2; bad=1; } - [[ "$v_path" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.*-]+$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad reusable path '$v_path'" >&2; bad=1; } + # The only wildcard is a terminal `/*` (every reusable of the repository); + # a `*` anywhere else would be matched literally, never as a glob. + [[ "$v_path" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/([A-Za-z0-9_.-]+|\*)$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad reusable path '$v_path' (the only wildcard is a terminal /*)" >&2; bad=1; } owners="$owners${v_path%%/*}"$'\n' done < "$PINS_TABLE" [ "$bad" -eq 0 ] || { echo "fix-stale-reusable-pin: table $PINS_TABLE is invalid; nothing was changed" >&2; exit 2; } @@ -70,6 +72,9 @@ validate_table() { } validate_table [ -d "$REPO_PATH/.github/workflows" ] || { echo "OK (no workflows): $REPO_PATH"; exit 0; } +# One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh). +. "$SCRIPT_DIR/lib/pin-fixer-lock.sh" +pin_fixer_lock "$REPO_PATH" LOCK="$REPO_PATH/.github/workflows/actions.lock" rewrites=0; lock_touched=0 @@ -200,7 +205,9 @@ USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space: # tag that merely STARTS with a dead SHA (`-patched`) is a different ref # the table never named, and must not be rewritten into `-patched`. REF_END="([\"'[:space:]#]|$)" -REUSABLE_REF_RE="(${TABLE_OWNERS_RE})/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}" +# Git resolves a commit ID in either case: upper-case hex is the same pin, +# looked up in lower case and rewritten as written. +REUSABLE_REF_RE="(${TABLE_OWNERS_RE})/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-fA-F]{40}" edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edgerepository lock_stale=0 # a rewritten caller whose lock entry never had the edge: the lock was already out of sync unsupported=0 @@ -214,7 +221,8 @@ while IFS= read -r -d '' wf; do grep -qx "$lineno" <<<"$placed" && continue ref=$(grep -oE "${REUSABLE_REF_RE}${REF_END}" <<<"${numbered#*:}" | head -1 | sed -E "s/[\"'[:space:]#]\$//" || true) [ -n "$ref" ] || continue - if lookup_replacement "${ref%@*}" "${ref##*@}"; then + sha="${ref##*@}" + if lookup_replacement "${ref%@*}" "${sha,,}"; then echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 skip=1 fi @@ -228,12 +236,12 @@ while IFS= read -r -d '' wf; do # `set -e` would turn into a silent exit 1 of the whole run. ref=$(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$line" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]#]\$//" | head -1 || true) [ -n "$ref" ] || continue - path="${ref%@*}"; stale="${ref##*@}" + path="${ref%@*}"; stale_raw="${ref##*@}"; stale="${stale_raw,,}" lookup_replacement "$path" "$stale" || continue [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } # ERE: a `$` inside a BRE group is literal to sed, so the boundary # alternation needs -E (and a delimiter the pattern does not use). - sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path)@$stale([\"'[:space:]#]|\$)~\1@$replacement\3~" "$wf" + sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path)@$stale_raw([\"'[:space:]#]|\$)~\1@$replacement\3~" "$wf" echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason)" rewrites=$((rewrites + 1)) pending+=("$stale"$'\t'"$replacement"$'\t'"${path%%/.github/workflows/*}") @@ -246,7 +254,7 @@ while IFS= read -r -d '' wf; do IFS=$'\t' read -r stale replacement repo_prefix <<<"$entry" keep=0 for other in $klines; do - sed -n "${other}p" "$wf" | grep -qE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" && keep=1 + sed -n "${other}p" "$wf" | grep -qiE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" && keep=1 done printf '%s\t%s\t%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" "$keep" "$repo_prefix" >> "$edits" done @@ -260,10 +268,13 @@ done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml if [ -f "$LOCK" ] && [ -s "$edits" ]; then sort -u "$edits" -o "$edits" while IFS=$'\t' read -r wfkey stale new keep repo; do - if ! awk -v wfkey=" '$wfkey':" -v edge="$repo@$stale" ' + # The workflow key in either quote style (`gh actions-lock` writes + # single quotes; a hand edit may not): compared unquoted. + if ! awk -v wfkey="$wfkey" -v edge="$repo@$stale" ' + function unq(s) { gsub(/["'"'"']/, "", s); return s } /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && /^ ["'"'"']/ { inwf = ($0 == wfkey) } + sec == "w" && /^ ["'"'"']/ { inwf = (unq($0) == " " wfkey ":") } sec == "w" && inwf && /^ - / && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"")) { found = 1 } END { exit !found }' "$LOCK"; then echo "WARN: actions.lock has no edge '$repo@${stale:0:7}' under '$wfkey': the lock was already out of sync with this caller — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 @@ -271,10 +282,22 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then continue fi tmp=$(mktemp) - awk -v wfkey=" '$wfkey':" -v stale="$stale" -v new="$new" -v keep="$keep" -v repo="$repo" ' + # The lock is read twice: first for the edges this workflow entry + # already lists, then to move the stale one — an entry that already + # holds the replacement edge (a job already at the new revision) is + # never given it a second time. + awk -v wfkey="$wfkey" -v stale="$stale" -v new="$new" -v keep="$keep" -v repo="$repo" ' + function unq(s) { gsub(/["'"'"']/, "", s); return s } + FNR == NR { + if ($0 ~ /^workflows:[[:space:]]*$/) s1 = "w" + if ($0 ~ /^dependencies:[[:space:]]*$/) s1 = "d" + if (s1 == "w" && $0 ~ /^ ["'"'"']/) w1 = (unq($0) == " " wfkey ":") + if (s1 == "w" && w1 && $0 ~ /^ - /) have[unq($0)] = 1 + next + } /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && /^ ["'"'"']/ { inwf = ($0 == wfkey) } + sec == "w" && /^ ["'"'"']/ { inwf = (unq($0) == " " wfkey ":") } sec == "w" && inwf && /^ - / { # Either quote style; the edge keeps the one it has. q = "'"'"'"; i = index($0, q repo "@" stale q) @@ -283,11 +306,13 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then moved = substr($0, 1, i + length(repo)) "@" new q # keep: an uncovered caller in this workflow still pins the stale # SHA, so the edge is added beside the old one, not replaced. - if (keep == "1") { if (!seen[moved]++) { print; $0 = moved } else next } else $0 = moved + if (keep == "1") print + if (unq(moved) in have) next + have[unq(moved)] = 1; $0 = moved } } { print } - ' "$LOCK" > "$tmp" + ' "$LOCK" "$LOCK" > "$tmp" if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi done < "$edits" # Then the records: rename a stale record nothing references any more; diff --git a/scripts/lib/pin-fixer-lock.sh b/scripts/lib/pin-fixer-lock.sh new file mode 100644 index 00000000..43673ef0 --- /dev/null +++ b/scripts/lib/pin-fixer-lock.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# pin-fixer-lock.sh — one workflow-pin fixer per repository at a time. +# +# The three fixers registered for WF026 (fix-rust-toolchain-input.sh), WF027 / +# PIN-003 (fix-stale-reusable-pin.sh) and PI006 (fix-lock-self-sha-refs.sh) +# all rewrite .github/workflows/*.yml and actions.lock of the same checkout. +# Under `dispatch-runner.sh --parallel N` two manifest entries for one +# repository can start together, so each fixer takes this lock on the +# repository before it reads anything: their edits never interleave (one's +# `sed -i` landing between another's read and write). +# +# Sourced, then `pin_fixer_lock `. The lock file lives outside the +# checkout (PIN_FIXER_LOCK_DIR, else XDG_RUNTIME_DIR, else TMPDIR), named by +# the checkout's real path, so every fixer run on the same checkout — by any +# path that reaches it — contends for the same file. It is held until the +# fixer exits. A holder that does not go away within PIN_FIXER_LOCK_WAIT +# seconds (default 600) is reported and the fixer exits 2 having changed +# nothing: a dispatcher must not record that run as a clean repair. +pin_fixer_lock() { + local repo="$1" dir key wait + dir="${PIN_FIXER_LOCK_DIR:-${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}}" + wait="${PIN_FIXER_LOCK_WAIT:-600}" + key=$(cd "$repo" 2>/dev/null && pwd -P || printf '%s' "$repo") + key=$(printf '%s' "$key" | sha256sum | cut -c1-16) + if ! command -v flock >/dev/null 2>&1; then + echo "WARN: flock not found; workflow-pin fixers on $repo are not serialised" >&2 + return 0 + fi + exec 9>"$dir/pin-fixer-$key.lock" + flock -w "$wait" 9 || { + echo "fix-workflow-pins: another workflow-pin fixer holds $repo (waited ${wait}s); nothing was changed" >&2 + exit 2 + } +} diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 64dcc3fe..ba80bdbf 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -1623,5 +1623,344 @@ by_recipe PI006 fix-lock-self-sha-refs.sh by_recipe pin_integrity/self_sha_lock_record fix-lock-self-sha-refs.sh TSV + +# ── Review cases (gitbot-fleet#615, rounds 10–11) ─────────────────────────── +# tc15: the empty string (`""`, `''`), the other null spellings YAML admits +# unquoted (`Null`, `NULL`) and an upper-case SHA are all the key with no +# value: filled in place, never duplicated. `"null"` quoted is the string +# null — a value the step names, left alone. +mkdir -p "$fx/tc15/.github/workflows" +cat > "$fx/tc15/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6C977A6CA4077A0CEB28FFBE03F59D46E9AC8772 + with: + toolchain: "" + components: clippy + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: '', components: clippy } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: NULL # chosen later + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: Null } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: "null" } +YML +cat > "$fx/tc15-expected.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6C977A6CA4077A0CEB28FFBE03F59D46E9AC8772 + with: + toolchain: stable + components: clippy + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: stable, components: clippy } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: stable # chosen later + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: stable } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: "null" } +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc15" +check "quoted-empty, Null/NULL and an upper-case SHA are filled in place; a quoted \"null\" string is a value" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc15-expected.yml' '$fx/tc15/.github/workflows/x.yml'" +cp "$fx/tc15/.github/workflows/x.yml" "$fx/tc15-after1.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc15" +check "tc15 second run is a no-op" "[ \$rc -eq 0 ] && cmp -s '$fx/tc15-after1.yml' '$fx/tc15/.github/workflows/x.yml'" + +# tc16: an empty `toolchain:` whose next PLAIN line sits deeper is a nested +# mapping even when a comment or blank line sits between them: unsupported, +# the file untouched, exit 2 — never a second `toolchain:` key. +mkdir -p "$fx/tc16/.github/workflows" +cat > "$fx/tc16/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: + # chosen below + + channel: stable +YML +cp "$fx/tc16/.github/workflows/x.yml" "$fx/tc16-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc16" +check "a nested toolchain mapping behind a comment line is unsupported: exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/tc16-before.yml' '$fx/tc16/.github/workflows/x.yml'" + +# tc17: a multi-line flow `with:` whose empty entry has its delimiter on the +# next line — `toolchain: null` then `}`, or a bare `toolchain:` followed by +# the closing brace — is filled where it stands; a bare key whose next token +# is a value (spread over lines) is not this fixer's to place. +mkdir -p "$fx/tc17/.github/workflows" "$fx/tc17b/.github/workflows" +cat > "$fx/tc17/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + toolchain: null + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + components: clippy, + toolchain: + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: ~ + } +YML +cat > "$fx/tc17-expected.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + toolchain: stable + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + components: clippy, + toolchain: stable + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: stable + } +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc17" +check "an empty flow entry whose delimiter is on the next line is filled in place, never duplicated" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc17-expected.yml' '$fx/tc17/.github/workflows/x.yml'" +cat > "$fx/tc17b/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + toolchain: + nightly } +YML +cp "$fx/tc17b/.github/workflows/x.yml" "$fx/tc17b-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc17b" +check "a bare flow toolchain: whose value starts on the next line is unsupported: exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/tc17b-before.yml' '$fx/tc17b/.github/workflows/x.yml'" + +# tc18: only a pin the parser cannot place AS A STEP is unsupported. A +# workflow-level `env:` string naming the action is data: the step below it +# is still repaired and the run is clean. A key of a flow-style step spread +# over several lines IS a step the parser cannot place. +mkdir -p "$fx/tc18/.github/workflows" "$fx/tc18b/.github/workflows" +cat > "$fx/tc18/.github/workflows/x.yml" <<'YML' +env: + TOOLCHAIN_ACTION: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc18" +check "a workflow-level env string naming the pin is data: the step is repaired and the run is clean" \ + "[ \$rc -eq 0 ] && ! grep -q UNSUPPORTED <<<\"\$out\" && grep -q '^ TOOLCHAIN_ACTION: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772$' '$fx/tc18/.github/workflows/x.yml' && [ \$(grep -c 'toolchain: stable' '$fx/tc18/.github/workflows/x.yml') -eq 1 ]" +cat > "$fx/tc18b/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: [ + { name: toolchain, + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 } + ] +YML +cp "$fx/tc18b/.github/workflows/x.yml" "$fx/tc18b-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc18b" +check "a key of a flow-style step spread over lines is still unsupported: exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/tc18b-before.yml' '$fx/tc18b/.github/workflows/x.yml'" + +# ls12: only a DIRECT key of a step is its `uses:`; the same key nested under +# `with:` or `env:` is an input and names no action. An estate record such +# an input names is then unreferenced and keeps its SHA ref (the reusable +# fallback) — as an action site it would have been given the tag. A +# bare-dash item learns its key column from its first key line. +mkdir -p "$fx/ls12/.github/workflows" +cat > "$fx/ls12/.github/workflows/ci.yml" < "$fx/ls12/.github/workflows/actions.lock" < "$fx/ls12-tags.tsv" +TAG_MAP="$fx/ls12-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls12" --offline +check "a step's direct uses: (under - name: and under a bare dash) is an action site; a uses: nested under with: is not" \ + "grep -q \"^ ref: 'v1.0.0'\" '$fx/ls12/.github/workflows/actions.lock' && grep -q \"^ ref: 'v3.0.0'\" '$fx/ls12/.github/workflows/actions.lock' && grep -q \"^ ref: '$SHA_B'\" '$fx/ls12/.github/workflows/actions.lock'" + +# ls13: the whole `jobs:` mapping written on one line is still a job map: +# its `uses:` entries are reusable calls, whose records keep their SHA ref. +# Two steps on one `steps: [ ... ]` line are each an action site. +mkdir -p "$fx/ls13/.github/workflows" +cat > "$fx/ls13/.github/workflows/ci.yml" < "$fx/ls13/.github/workflows/two.yml" < "$fx/ls13/.github/workflows/actions.lock" < "$fx/ls13-tags.tsv" +TAG_MAP="$fx/ls13-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls13" --offline +check "an inline jobs: { ... } mapping is a reusable call: its record keeps the SHA ref; two inline steps are both actions" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: '$SHA_A'\" '$fx/ls13/.github/workflows/actions.lock' && grep -q \"^ ref: 'v2.0.0'\" '$fx/ls13/.github/workflows/actions.lock' && grep -q \"^ ref: 'v3.0.0'\" '$fx/ls13/.github/workflows/actions.lock'" + +# sp17: an upper-case SHA is the same dead pin: looked up in lower case, the +# caller rewritten as written, the (lower-case) lock edge moved. +mkdir -p "$fx/sp17/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "${STALE_A^^}" > "$fx/sp17/.github/workflows/ci.yml" +cat > "$fx/sp17/.github/workflows/actions.lock" < "$fx/sp18/.github/workflows/ci.yml" +cp "$fx/sp18/.github/workflows/ci.yml" "$fx/sp18-before.yml" +printf 'hyperpolymath/standards/.github/workflows/rust-*.yml\t%s\t%s\tglob in the file name\n' "$STALE_A" "$GOOD" > "$fx/sp18-table.tsv" +PINS_TABLE="$fx/sp18-table.tsv" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp18" +check "a wildcard anywhere but a terminal /* is a bad row: exit 2, nothing changed" \ + "[ \$rc -eq 2 ] && grep -q 'terminal /\\*' <<<\"\$out\" && cmp -s '$fx/sp18-before.yml' '$fx/sp18/.github/workflows/ci.yml'" + +# sp19: a hand-edited lock whose workflow key is double-quoted is the same +# key: the edge is found and moved, not reported as missing. +mkdir -p "$fx/sp19/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp19/.github/workflows/ci.yml" +cat > "$fx/sp19/.github/workflows/actions.lock" < "$fx/sp20/.github/workflows/ci.yml" +cat > "$fx/sp20/.github/workflows/actions.lock" < "$fx/pl1/.github/workflows/ci.yml" +cp "$fx/pl1/.github/workflows/ci.yml" "$fx/pl1-before.yml" +pl1_key=$(cd "$fx/pl1" && pwd -P | tr -d '\n' | sha256sum | cut -c1-16) +flock -x "$fx/pin-fixer-$pl1_key.lock" sleep 3 & +pl1_holder=$! +sleep 0.3 +PIN_FIXER_LOCK_DIR="$fx" PIN_FIXER_LOCK_WAIT=1 run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl1" +check "a fixer that cannot take the checkout's lock in time reports it, exits 2 and changes nothing" \ + "[ \$rc -eq 2 ] && grep -q 'another workflow-pin fixer holds' <<<\"\$out\" && cmp -s '$fx/pl1-before.yml' '$fx/pl1/.github/workflows/ci.yml'" +wait "$pl1_holder" || true +PIN_FIXER_LOCK_DIR="$fx" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl1" +check "once the holder is gone the same run takes the lock and repairs" \ + "[ \$rc -eq 0 ] && grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/pl1/.github/workflows/ci.yml'" + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] From 4669f5cf1b3c0ae20d283bd5085799decf553f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:11:55 +0000 Subject: [PATCH 20/44] fix(fix-scripts): upper-case uses: keys, checkout lock for relative file targets, with: { uses } is data Review round 12 on #615 (CodeRabbit): - fix-rust-toolchain-input.sh: USES_RE accepts the same hex as sha_ref, so an upper-case SHA beginning with a letter is the step's own `uses:` key and is repaired rather than reported as unplaceable (tc19). - fix-rust-toolchain-input.sh: a single workflow file is locked by its checkout, found from the file's directory (`/.github/ workflows/`, relative or absolute), so every fixer run on that checkout contends for the same lock file (pl2). - fix-lock-self-sha-refs.sh: a flow mapping on one of a step's own keys (`with: { uses: ... }`) is that input's data, never the step's action; only the `steps: [ ... ]` list form and the scalar `uses:` key name an action site (ls14). 177 contracts, gawk and mawk. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 6 ++- scripts/fix-rust-toolchain-input.sh | 16 ++++++-- scripts/tests/fix-workflow-pins.sh | 62 +++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index a1eacbb7..54dc8280 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -143,13 +143,15 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 0 && keyat[p] == "jobs")) print "R\t" fu[j] - else if (key == "steps" || (item_steps && ind == item_key)) print "A\t" fu[j] + else if (key == "steps") print "A\t" fu[j] } } } diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 12afb1f4..6b61fc58 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -63,9 +63,19 @@ TOOLCHAIN="${TOOLCHAIN:-stable}" TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh): -# a single workflow file is locked by the checkout it belongs to. +# a single workflow file is locked by the checkout it belongs to — found from +# the file'"'"'s directory (`/.github/workflows/`, relative or +# absolute), so every fixer on that checkout contends for the same lock. . "$SCRIPT_DIR/lib/pin-fixer-lock.sh" -if [ -d "$TARGET" ]; then pin_fixer_lock "$TARGET"; else pin_fixer_lock "${TARGET%/.github/workflows/*}"; fi +if [ -d "$TARGET" ]; then + pin_fixer_lock "$TARGET" +else + target_dir=$(cd "$(dirname "$TARGET")" 2>/dev/null && pwd -P || dirname "$TARGET") + case "$target_dir" in + */.github/workflows) pin_fixer_lock "${target_dir%/.github/workflows}" ;; + *) pin_fixer_lock "$target_dir" ;; + esac +fi # Add missing TOOLCHAIN inputs to SHA-pinned Rust toolchain steps in file $1. # Return 0 for a repair or no-op; return 2 and leave the file untouched if @@ -278,7 +288,7 @@ apply_to_file() { LIST_RE = "^[[:space:]]*-([[:space:]]|$)" KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" - USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-f]+" + USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-fA-F]+" WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index ba80bdbf..a5c5e66a 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -1962,5 +1962,67 @@ PIN_FIXER_LOCK_DIR="$fx" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" check "once the holder is gone the same run takes the lock and repairs" \ "[ \$rc -eq 0 ] && grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/pl1/.github/workflows/ci.yml'" + +# ── Review cases (gitbot-fleet#615, round 12) ─────────────────────────────── +# tc19: an upper-case SHA that begins with a letter is still the step's own +# `uses:` key (USES_RE accepts the same hex as sha_ref), so it is repaired, +# not reported as a pin the parser cannot place. +mkdir -p "$fx/tc19/.github/workflows" +cat > "$fx/tc19/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@AC977A6CA4077A0CEB28FFBE03F59D46E9AC8772 + with: + components: clippy +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc19" +check "an upper-case SHA beginning with a letter is the step's uses: key: repaired, not unsupported" \ + "[ \$rc -eq 0 ] && ! grep -q UNSUPPORTED <<<\"\$out\" && grep -A2 'AC977A6CA4077A0CEB28FFBE03F59D46E9AC8772' '$fx/tc19/.github/workflows/x.yml' | grep -q '^ toolchain: stable$'" + +# pl2: a single workflow file named relative to the checkout root locks the +# checkout, not the file: the lock file is the one every fixer run on that +# checkout computes. +mkdir -p "$fx/pl2/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/pl2/.github/workflows/x.yml" +pl2_key=$(cd "$fx/pl2" && pwd -P | tr -d '\n' | sha256sum | cut -c1-16) +(cd "$fx/pl2" && PIN_FIXER_LOCK_DIR="$fx/pl2-locks" bash -c 'mkdir -p "$PIN_FIXER_LOCK_DIR"; bash "$1" .github/workflows/x.yml' _ "$fleet/scripts/fix-rust-toolchain-input.sh") > "$fx/pl2.out" 2>&1; rc_pl2=$? +check "a relative workflow-file target is locked by its checkout (same lock file as a checkout-path run)" \ + "[ \$rc_pl2 -eq 0 ] && [ -f '$fx/pl2-locks/pin-fixer-$pl2_key.lock' ] && grep -q 'toolchain: stable' '$fx/pl2/.github/workflows/x.yml'" + +# ls14: a flow mapping on one of a step's own keys (`with: { uses: ... }`) is +# that input's data, not the step's action: the estate record it names stays +# unreferenced and keeps its SHA ref. +mkdir -p "$fx/ls14/.github/workflows" +cat > "$fx/ls14/.github/workflows/ci.yml" < "$fx/ls14/.github/workflows/actions.lock" < "$fx/ls14-tags.tsv" +TAG_MAP="$fx/ls14-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls14" --offline +check "a with: { uses: ... } flow input on a step key is data: the step's action is repaired, the estate record keeps its SHA ref" \ + "grep -q \"^ ref: 'v1.0.0'\" '$fx/ls14/.github/workflows/actions.lock' && grep -q \"^ ref: '$SHA_B'\" '$fx/ls14/.github/workflows/actions.lock'" + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] From 48cdecca35a337b0095564062bc3184a91b7b7af Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:30:03 +0000 Subject: [PATCH 21/44] fix(security): secure temporary paths and cleanup, baseline Hypatia false positives, and use bunx in the E2E template --- .hypatia-baseline.json | 59 ++++++++++++++++++- fleet-coordinator.sh | 10 ++-- .../templates/skeleton/Justfile | 2 +- scripts/fix-actions-policy.sh | 12 +++- scripts/fix-workflow-timeout-minutes.sh | 15 ++--- scripts/maintenance-hard-pass.sh | 4 +- scripts/submit-finding.sh | 2 +- 7 files changed, 85 insertions(+), 19 deletions(-) diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json index 49342b92..f85be45f 100644 --- a/.hypatia-baseline.json +++ b/.hypatia-baseline.json @@ -1 +1,58 @@ -[{"file":"bots/gsbot/scripts/backup_db.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/export_data.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/load_fixtures.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/setup.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/bot.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/admin.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/brands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/materials.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/user_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/main.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/settings.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/base.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/brand.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/garment.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/material.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/user.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/database.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/cache.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/logger.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/conftest.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/test_bot_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_models.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_services.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/accessibilitybot/src/analyzers/alt_text.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/aria.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/forms.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/keyboard.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/language.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/media.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/semantic.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/dns.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/hashing.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/password.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/rng.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/signatures.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/symmetric.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/dispatcher/echidna_client.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/scheduler/limiter.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/finishingbot/src/analyzers/claims.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/glambot/src/analyzers/accessibility.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/gsbot/src/services.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"robot-repo-automaton/src/hypatia.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"shared-context/benches/fleet_benchmarks.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":".","rule_module":"scorecard","severity":"medium","type":"DependencyPinning"},{"file":".claude/worktrees/actions-policy","rule_module":"structural_drift","severity":"critical","type":"SD005"},{"file":"boj-build.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"casket-pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"codeql.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"e2e.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"hypatia-dispatch-intake.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"inbox-steward.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"instant-sync.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"learning-loop.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"push-email-notify.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"repo-integrity-guard.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"rust.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"supervised-fleet-scan.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"expires_at":"2027-01-05","file":"bots/echidnabot/fuzz/fuzz_targets/fuzz_hmac.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/adapters/mod.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/api/rate_limit.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/scheduler/job_queue.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"}] +[{"file":"bots/gsbot/scripts/backup_db.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/export_data.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/load_fixtures.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/setup.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/bot.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/admin.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/brands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/materials.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/user_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/main.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/settings.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/base.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/brand.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/garment.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/material.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/user.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/database.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/cache.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/logger.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/conftest.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/test_bot_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_models.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_services.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/accessibilitybot/src/analyzers/alt_text.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/aria.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/forms.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/keyboard.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/language.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/media.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/semantic.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/dns.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/hashing.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/password.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/rng.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/signatures.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/symmetric.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/dispatcher/echidna_client.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/scheduler/limiter.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/finishingbot/src/analyzers/claims.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/glambot/src/analyzers/accessibility.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/gsbot/src/services.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"robot-repo-automaton/src/hypatia.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"shared-context/benches/fleet_benchmarks.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":".","rule_module":"scorecard","severity":"medium","type":"DependencyPinning"},{"file":".claude/worktrees/actions-policy","rule_module":"structural_drift","severity":"critical","type":"SD005"},{"file":"boj-build.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"casket-pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"codeql.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"e2e.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"hypatia-dispatch-intake.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"inbox-steward.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"instant-sync.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"learning-loop.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"push-email-notify.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"repo-integrity-guard.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"rust.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"supervised-fleet-scan.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"expires_at":"2027-01-05","file":"bots/echidnabot/fuzz/fuzz_targets/fuzz_hmac.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/adapters/mod.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/api/rate_limit.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/scheduler/job_queue.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"}, +{ + "file": "run-fleet.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "eval_in_shell", + "note": "False positive: the case arm maps the literal eval_in_shell finding name to DynamicCodeExecution; it does not execute eval." +}, +{ + "file": "scripts/fix-command-injection.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "eval_in_shell", + "note": "Intentional detector and annotation strings for eval usage in target files; this remediation script does not execute eval." +}, +{ + "file": "scripts/fix-dynamic-code-exec.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "eval_in_shell", + "note": "Intentional grep/awk patterns and warning strings for JavaScript/Python eval in target files; this remediation script does not execute shell eval." +}, +{ + "file": "scripts/fix-eval-to-safe.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "eval_in_shell", + "note": "Intentional grep/sed patterns and security warning strings for eval in target files; this remediation script does not execute eval." +}, +{ + "file": "scripts/fix-heredoc-install.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "eval_in_shell", + "note": "Intentional grep patterns and diagnostic strings describing eval in target files; this remediation script does not execute eval." +}, +{ + "file": "scripts/fix-heredoc-install.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "download_then_run_shell", + "note": "False positive: the curl|bash text is a diagnostic for a matched unsafe target pattern; this remediation script does not download or execute an installer." +}, +{ + "file": "scripts/fix-tmp-paths.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "hardcoded_tmp", + "note": "Intentional grep/sed patterns and diagnostic strings identifying static temporary paths in target files; this remediation script does not create files at those paths." +}, +{ + "file": "scripts/fix-chapel-runs-on-pin.sh", + "rule_module": "content_patterns", + "severity": "medium", + "type": "hardcoded_tmp", + "note": "Intentional literal match and replacement of the Chapel installation command in target workflows; this remediation script does not download or install a package at that path." +} +] diff --git a/fleet-coordinator.sh b/fleet-coordinator.sh index fbe2fd4a..e186faf3 100755 --- a/fleet-coordinator.sh +++ b/fleet-coordinator.sh @@ -632,7 +632,7 @@ EOF fi } -create_rule_approval_pr() { +create_rule_approval_pr() ( local rule_file="$1" local rule_type="$2" local observations="$3" @@ -640,7 +640,9 @@ create_rule_approval_pr() { log_bot "hypatia" "Creating rule approval PR for $rule_type..." # Clone hypatia repo to temp directory - local hypatia_dir="/tmp/hypatia-rule-pr-$$" + local hypatia_dir + hypatia_dir="$(mktemp -d)" + trap 'rm -rf "$hypatia_dir"' EXIT git clone https://github.com/hyperpolymath/hypatia.git "$hypatia_dir" 2>/dev/null cd "$hypatia_dir" @@ -680,7 +682,6 @@ COMMIT_EOF git push origin "$branch_name" 2>/dev/null || { log_warn "Failed to push rule PR branch" cd - > /dev/null - rm -rf "$hypatia_dir" return 1 } @@ -734,8 +735,7 @@ PR_BODY fi cd - > /dev/null - rm -rf "$hypatia_dir" -} +) deploy_bots() { log_error "BLOCKED: deployment readiness is not verified; deployment is quarantined. See docs/AUTOMATION-QUARANTINE.adoc." diff --git a/robot-repo-automaton/templates/skeleton/Justfile b/robot-repo-automaton/templates/skeleton/Justfile index d39fe7d4..75272b90 100644 --- a/robot-repo-automaton/templates/skeleton/Justfile +++ b/robot-repo-automaton/templates/skeleton/Justfile @@ -150,7 +150,7 @@ e2e: @echo "Running E2E tests..." # TODO: Replace with your E2E test command. Examples: # bash tests/e2e.sh # Shell-based E2E - # npx playwright test # Browser E2E + # bunx playwright test # Browser E2E # mix test test/integration/e2e_test.exs # Elixir E2E # cargo test --test end_to_end # Rust E2E @echo "E2E tests passed!" diff --git a/scripts/fix-actions-policy.sh b/scripts/fix-actions-policy.sh index b788f06f..860eb18f 100755 --- a/scripts/fix-actions-policy.sh +++ b/scripts/fix-actions-policy.sh @@ -69,6 +69,8 @@ JSONL="" ONE_REPO="" FINDING_FILE="" # dispatch-runner contract: $2 is the finding JSON STD_REPO="hyperpolymath/standards" +allowlist_tmp="" +trap 'if [ -n "$allowlist_tmp" ]; then rm -f "$allowlist_tmp"; fi' EXIT die() { echo "fix-actions-policy: $*" >&2; exit "${2:-2}"; } @@ -222,9 +224,10 @@ for R in $REPOS; do # a healthy `selected` repo. SRC="$ALLOWLIST" if [ -z "$SRC" ]; then - gh api "repos/${STD_REPO}/actions/permissions/selected-actions" > "/tmp/allow.$$.json" 2>/dev/null \ + allowlist_tmp="$(mktemp)" + gh api "repos/${STD_REPO}/actions/permissions/selected-actions" > "$allowlist_tmp" 2>/dev/null \ || die "cannot read the standards allowlist; pass --allowlist FILE instead" - SRC="/tmp/allow.$$.json" + SRC="$allowlist_tmp" fi [ -s "$SRC" ] || die "allowlist '$SRC' is missing or empty" jq -e '(.patterns_allowed|length) > 0' "$SRC" >/dev/null 2>&1 \ @@ -232,7 +235,10 @@ for R in $REPOS; do gh api --method PUT "repos/${FULL}/actions/permissions/selected-actions" --input "$SRC" >/dev/null # `&& rm` alone would return 1 when the test is false and, under `set -e`, # abort the sweep on the --allowlist path. Keep the guard total. - if [ "$SRC" = "/tmp/allow.$$.json" ]; then rm -f "/tmp/allow.$$.json"; fi + if [ -n "$allowlist_tmp" ]; then + rm -f "$allowlist_tmp" + allowlist_tmp="" + fi fi # GET-verify. Trading sha_pinning away for permissiveness is never acceptable, diff --git a/scripts/fix-workflow-timeout-minutes.sh b/scripts/fix-workflow-timeout-minutes.sh index c41197ce..6075f63f 100755 --- a/scripts/fix-workflow-timeout-minutes.sh +++ b/scripts/fix-workflow-timeout-minutes.sh @@ -21,9 +21,12 @@ set -euo pipefail DEFAULT_TIMEOUT="${DEFAULT_TIMEOUT:-10}" TARGET="${1:?Usage: fix-workflow-timeout-minutes.sh }" -apply_to_file() { +apply_to_file() ( local f="$1" [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } + local missing_timeouts tmp="" + missing_timeouts=$(mktemp) + trap 'rm -f "$missing_timeouts"; if [ -n "$tmp" ]; then rm -f "$tmp"; fi' EXIT # Get list of (line_number, job_name) for jobs that lack timeout-minutes # within their block. We scan the file and find each top-level `^ :$` @@ -38,10 +41,9 @@ apply_to_file() { } in_jobs && /^ timeout-minutes:/ { hadto = 1 } END { if (curjob && !hadto) print curjob_line "\t" curjob } - ' "$f" > /tmp/missing_timeouts.$$ + ' "$f" > "$missing_timeouts" - if [ ! -s /tmp/missing_timeouts.$$ ]; then - rm -f /tmp/missing_timeouts.$$ + if [ ! -s "$missing_timeouts" ]; then echo "OK (no missing timeouts): $f" return 0 fi @@ -51,7 +53,7 @@ apply_to_file() { tmp=$(mktemp) cp "$f" "$tmp" - tac /tmp/missing_timeouts.$$ | while IFS=$'\t' read -r job_start_line job_name; do + tac "$missing_timeouts" | while IFS=$'\t' read -r job_start_line job_name; do # Find the runs-on line within the next 20 lines from the job header runs_on_line=$(awk -v start="$job_start_line" -v end="$((job_start_line+20))" ' NR > start && NR < end && /^ runs-on:/ { print NR; exit } @@ -70,8 +72,7 @@ apply_to_file() { else rm -f "$tmp" fi - rm -f /tmp/missing_timeouts.$$ -} +) if [ -d "$TARGET" ]; then for f in "$TARGET"/.github/workflows/*.yml "$TARGET"/.github/workflows/*.yaml; do diff --git a/scripts/maintenance-hard-pass.sh b/scripts/maintenance-hard-pass.sh index 60fc577e..4c056297 100755 --- a/scripts/maintenance-hard-pass.sh +++ b/scripts/maintenance-hard-pass.sh @@ -59,7 +59,9 @@ repo_name="$(basename "$repo")" ts="$(date -u +%Y%m%dT%H%M%SZ)" if [[ -z "$output" ]]; then - output="/tmp/gitbot-fleet-maintenance/${repo_name}-${ts}.json" + # Reports persist between runs; keep them in the user's state directory. + umask 077 + output="${XDG_STATE_HOME:-$HOME/.local/state}/gitbot-fleet/maintenance/${repo_name}-${ts}.json" fi mkdir -p "$(dirname "$output")" diff --git a/scripts/submit-finding.sh b/scripts/submit-finding.sh index 658f51f9..fd816729 100755 --- a/scripts/submit-finding.sh +++ b/scripts/submit-finding.sh @@ -40,7 +40,7 @@ REPO_SLUG=$(echo "$REPO_NAME" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') TARGET_FILE="shared-context/findings/${REPO_SLUG}/${TIMESTAMP}.json" # Clone or update fleet repo -FLEET_DIR="/tmp/gitbot-fleet-$$" +FLEET_DIR="$(mktemp -d)" trap 'rm -rf "$FLEET_DIR"' EXIT if [ -n "$PUSH_TOKEN" ]; then From 24c4fdfef371f810f7f704b1b5283e86c9592ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:00:03 +0000 Subject: [PATCH 22/44] fix(fix-scripts): nested-flow suffix, unterminated table row, masked flow uses, upper-case/plain lock keys, literal repo match Codex round on 5fa7192 (gitbot-fleet#615), six findings: * fix-rust-toolchain-input.sh: a multi-line flow `with:` is now read by one quote/comment-aware walk (level_view) that also counts the braces, so text after a nested mapping closes mid-line (`channel: beta }, toolchain: nightly`) is read at the `with:` level. Before, the whole line was discarded and a second toolchain key was written. (tc20) * fix-stale-reusable-pin.sh: both table loops process a final row the file ends on without a newline. (sp21) * fix-stale-reusable-pin.sh: the lock merger, the edge mover and the record mover recognise plain unquoted keys and edges; an unquoted record before the quoted ones was silently dropped from dependencies: by any repair. (sp22) * fix-lock-self-sha-refs.sh: flow `uses` entries are scanned on a masked line (comment dropped, quoted scalars blanked unless key or `uses` value), so a `{ uses: ... }` inside a name: string or a comment is no longer a reusable site. (ls15) * fix-lock-self-sha-refs.sh: record keys and refs in upper-case hex, and plain unquoted keys, are self-SHA records; looked up in lower case, the ref rewritten as written. (ls16) * fix-lock-self-sha-refs.sh: usage_of compares repository and revision as fields (awk, case-folded) instead of interpolating the name into an ERE, so `foo/action.test` no longer matches `foo/actionXtest`. (ls17) Contracts: 186 passed under gawk and under mawk; each new contract fails against the previous scripts. shellcheck -S warning clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 95 ++++++++---- scripts/fix-rust-toolchain-input.sh | 71 +++++---- scripts/fix-stale-reusable-pin.sh | 56 ++++--- scripts/tests/fix-workflow-pins.sh | 221 ++++++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 75 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 54dc8280..ef2808b3 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -87,9 +87,33 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A p) p = k + 0; return p } + # The line with its trailing comment dropped and every quoted + # scalar that is neither a key nor the value of a `uses` key + # blanked to `""`, so a `uses:` written inside a `name:` string + # or after a `#` names no site. Quotes are walked, not matched by + # pattern: a `#` or a brace inside a string is text. + function masked(s, out, i, n, c, j, body) { + out = ""; n = length(s); i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break + if (c == "\"" || c == "'"'"'") { + j = i + 1 + if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } + else { while (j <= n && substr(s, j, 1) != "'"'"'") j++ } + body = substr(s, i + 1, j - i - 1) + if (substr(s, j + 1) ~ /^[[:space:]]*:/ || out ~ /(^|[{,[:space:]])["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*$/) out = out c body c + else out = out c c + i = j + 1; continue + } + out = out c; i++ + } + return out + } # Every `uses` entry of the flow mappings written on the key'"'"'s own # line (`call: { uses: ... }`, `- { uses: ... }`, `jobs: { a: { uses: ... }, - # b: { uses: ... } }`), into out[1..n]; returns n. + # b: { uses: ... } }`), into out[1..n]; returns n. Given the MASKED + # line: the raw one carries its comment and its strings. function flow_uses_all(s, out, n, r, t) { n = 0; split("", out); t = s while (match(t, /(^|[{,])[[:space:]]*["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*["'"'"']?[^"'"'"'[:space:],}]+/)) { @@ -123,7 +147,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 0) ? nearest_below(p) : -1 if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "R\t" ref_of($0) else if (item_steps && ind == item_key) print "A\t" ref_of($0) - } else if ($0 ~ /:[[:space:]]*\{/) { + } else if ((ml = masked($0)) ~ /:[[:space:]]*\{/) { # A flow mapping on the key'"'"'s line: a job written # `call: { uses: ... }`, or the whole `jobs: { ... }` mapping # on one line, is a reusable call; `steps: [ { uses: ... } ]` # is a list of actions. A flow mapping on one of a step'"'"'s # own keys (`with: { uses: ... }`) is that input'"'"'s data: the # step'"'"'s action is its scalar `uses:` key, never a mapping. - m = flow_uses_all($0, fu) + # A `{ uses: ... }` inside a job'"'"'s `name:` string or after + # a `#` is neither: the masked line has no such text. + m = flow_uses_all(ml, fu) if (m) { p = nearest_below(ind) for (j = 1; j <= m; j++) { @@ -166,15 +192,21 @@ SITES=$(mktemp) uses_sites > "$SITES" # For owner/repo $1 at SHA $2, print reusable, action, or unknown; reusable -# takes precedence when both kinds of use exist. +# takes precedence when both kinds of use exist. Repository and revision are +# compared as fields, never as a pattern — a `.` in a repository name is a +# dot, so `foo/action.test` is not `foo/actionXtest` — and in lower case: +# GitHub reads a repository name, and git a commit ID, in either case. usage_of() { # -> reusable | action | unknown - if grep -qE "^R $1/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@$2$" "$SITES"; then - echo reusable - elif grep -qE "^A $1(/[A-Za-z0-9_./-]+)?@$2$" "$SITES"; then - echo action - else - echo unknown - fi + awk -F'\t' -v r="$1" -v s="$2" ' + BEGIN { r = tolower(r); s = tolower(s); n = length(r) } + { + i = index($2, "@"); if (!i) next + path = tolower(substr($2, 1, i - 1)); rev = tolower(substr($2, i + 1)) + if (rev != s) next + if ($1 == "R" && substr(path, 1, n + 19) == r "/.github/workflows/" && substr(path, n + 20) ~ /^[a-z0-9_.-]+\.ya?ml$/) reusable = 1 + if ($1 == "A" && (path == r || substr(path, 1, n + 1) == r "/")) action = 1 + } + END { print (reusable ? "reusable" : (action ? "action" : "unknown")) }' "$SITES" } # Return 0 if owner/repo $1 at SHA $2 should keep its SHA ref, or 1 otherwise. @@ -193,34 +225,39 @@ tmp=$(mktemp); cp "$LOCK" "$tmp" # would look like "no records" and the script would report OK. POSIX awk only # (Debian/Ubuntu's default awk is mawk: no match() capture array, no interval # expressions), so the SHA is checked for content and length separately. -# A record key and its ref may be single- or double-quoted (gh actions-lock -# writes single quotes; a hand-added record — exactly where this defect -# comes from — may not), and the quote style is kept on the rewrite. -# A record is read to its end (the next record key, or the end of the 8-space -# block): YAML mapping order is not fixed, so `ref:` may follow `commit:` and -# the other fields — exactly as a hand-added record might write it. Each -# finding carries the line of the `ref:` itself. +# A record key and its ref may be single-quoted, double-quoted or plain (gh +# actions-lock writes single quotes; a hand-added record — exactly where this +# defect comes from — may not), and the quote style is kept on the rewrite. +# Either may write the commit ID in upper-case hex: git resolves it the same, +# so it is the same self-SHA record, looked up in lower case and rewritten as +# written. A record is read to its end (the next record key, or the end of +# the 8-space block): YAML mapping order is not fixed, so `ref:` may follow +# `commit:` and the other fields — exactly as a hand-added record might write +# it. Each finding carries the line of the `ref:` itself, and the ref as that +# line spells it. records=$(mktemp) if ! awk ' - /^ ["'"'"'][^"'"'"'@]+@[0-9a-f]+["'"'"']:$/ { - s = $0; sub(/^ ["'"'"']/, "", s); sub(/["'"'"']:$/, "", s) + /^ ["'"'"']?[^"'"'"'@[:space:]#]+@[0-9a-fA-F]+["'"'"']?:$/ { + s = $0; sub(/^ ["'"'"']?/, "", s); sub(/["'"'"']?:$/, "", s) i = index(s, "@"); repo = substr(s, 1, i - 1); sha = substr(s, i + 1) if (length(sha) == 40) { inrec = 1; key_repo = repo; key_sha = sha } else inrec = 0 next } /^[^[:space:]]/ || /^ [^[:space:]]/ { inrec = 0 } inrec && /^ ref: / { - # Quoted either way, or a plain scalar: the same ref to GitHub. + # Quoted either way, or a plain scalar, in either case of hex: the + # same ref to GitHub. r = $0; sub(/^ ref: /, "", r); sub(/[[:space:]]+#.*$/, "", r); gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) - if (r == key_sha) print NR "\t" key_repo "\t" key_sha + if (tolower(r) == tolower(key_sha)) print NR "\t" key_repo "\t" key_sha "\t" r } ' "$LOCK" > "$records"; then echo "fix-lock-self-sha-refs: could not parse $LOCK (awk failed); lock left unchanged" >&2 rm -f "$tmp" "$records" "$SITES"; exit 2 fi -while IFS=$'\t' read -r lineno repo sha; do - if keeps_sha_ref "$repo" "$sha"; then continue; fi - tag=$(lookup_tag "$repo" "$sha") +while IFS=$'\t' read -r lineno repo sha ref_raw; do + sha_lc="${sha,,}" + if keeps_sha_ref "$repo" "$sha_lc"; then continue; fi + tag=$(lookup_tag "$repo" "$sha_lc") if [ -z "$tag" ]; then echo "UNRESOLVED $repo@${sha:0:7}: no tag found for this commit (record left as is; GitHub rejects it)" >&2 unresolved=$((unresolved + 1)); continue @@ -231,8 +268,10 @@ while IFS=$'\t' read -r lineno repo sha; do echo "UNRESOLVED $repo@${sha:0:7}: resolved tag '$tag' carries characters this fixer will not write into the lock (record left as is)" >&2 unresolved=$((unresolved + 1)); continue fi - # lineno is the ref: line itself; its quote style (or none) is kept. - sed -i "${lineno}s|ref: \(['\"]\{0,1\}\)$sha\1|ref: \1$tag\1|" "$tmp" + # lineno is the ref: line itself; its quote style (or none) is kept, and + # the SHA is matched as that line spells it (its case may differ from the + # key's). + sed -i "${lineno}s|ref: \(['\"]\{0,1\}\)$ref_raw\1|ref: \1$tag\1|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 6b61fc58..ee760abd 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -96,29 +96,47 @@ apply_to_file() { function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } # Return a string of n spaces for an inserted YAML line. function spaces(n, t) { t = ""; while (n-- > 0) t = t " "; return t } - # Net brace depth of a line: a flow mapping closes when it returns to - # zero, not at the first `}` — an expression `${{ matrix.x }}` inside it - # opens and closes its own braces. - # ... and only braces OUTSIDE quoted scalars and comments count: a + # Only text OUTSIDE quoted scalars and comments is code: a # `components: "}"` or a `# }` is text, not the end of the mapping. function code_only(s, t) { t = s; gsub(/"([^"\\]|\\.)*"/, "\"\"", t); gsub(/'"'"'[^'"'"']*'"'"'/, "'"'"''"'"'", t); sub(/(^|[[:space:]])#.*$/, "", t); return t } - function brace_delta(s, a, b) { a = code_only(s); b = a; return gsub(/\{/, "", a) - gsub(/\}/, "", b) } - function nbrace(s, a) { a = s; return gsub(/\{/, "", a) } - # The inputs a flow line names at the `with:` mapping'"'"'s own level: a - # quoted VALUE becomes the one-letter text `"v"` (a `"clippy, toolchain: - # nightly"` is text, a quoted key is still a key) while an empty one - # (`""`, `'"'"''"'"'`) stays empty, comments are dropped, nested mappings removed — - # on the opener the outer `{` is kept, on a continuation line at depth 1 - # every balanced group is nested. A line deeper than that names nothing. - function inputs_view(s, keep_outer, t) { - t = s - gsub(/:[[:space:]]*"([^"\\]|\\.)+"/, ": \"v\"", t); gsub(/:[[:space:]]*'"'"'[^'"'"']+'"'"'/, ": '"'"'v'"'"'", t) - sub(/(^|[[:space:]])#.*$/, "", t) - while (match(t, /\{[^{}]*\}/)) { - if (keep_outer && nbrace(t) <= 1) break - t = substr(t, 1, RSTART - 1) substr(t, RSTART + RLENGTH) + # The inputs one line names at the `with:` flow mapping'"'"'s OWN level, + # walked from brace depth `depth` — 0 on the `with: {` line, the depth + # the previous line left on a continuation line. A flow mapping closes + # when the depth returns to zero, not at the first `}`: an expression + # `${{ matrix.x }}` opens and closes its own braces (its `$` stays as + # the value'"'"'s text), and a nested mapping and its contents are dropped + # with its braces. The brace that opens the mapping and the one that + # closes it are kept. A quoted VALUE becomes the one-letter text `"v"` + # (a `"clippy, toolchain: nightly"` is text, a quoted key is still a + # key) while an empty one (`""`, `'"'"''"'"'`) stays empty, and a comment ends + # the line. Text BEFORE a nested group closes is hidden; text AFTER it + # on the same line (`channel: beta }, toolchain: nightly`) is read at + # the mapping'"'"'s level like any other entry. The depth the line leaves + # is left in lv_depth: the walk that reads the entries is the one that + # counts the braces, so the two can never disagree. + function level_view(s, depth, out, i, n, c, j, body) { + out = ""; n = length(s); i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break + if (c == "\"" || c == "'"'"'") { + j = i + 1 + if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } + else { while (j <= n && substr(s, j, 1) != "'"'"'") j++ } + body = substr(s, i + 1, j - i - 1) + if (depth == 1) { + if (substr(s, j + 1) ~ /^[[:space:]]*:/) out = out c body c + else out = out c (body == "" ? "" : "v") c + } + i = j + 1; continue + } + if (c == "{") { depth++; if (depth == 1) out = out c } + else if (c == "}") { if (depth == 1) out = out c; depth-- } + else if (depth == 1) out = out c + i++ } - return t + lv_depth = depth + return out } # A 40-hex pin of the action on a non-comment line, trailing comment # stripped. `@stable` and `@1.85.0` are tags: the ref carries the @@ -187,10 +205,11 @@ apply_to_file() { # A flow `with: {` spread over several lines: every entry up to # the brace that closes it is an input. An EMPTY `toolchain:` # entry (`toolchain: ,` / `toolchain: null`) is the key with no - # value: it is given one in place, never duplicated. Only a line - # at the mapping'"'"'s own depth names inputs, and only outside - # quoted values and nested mappings. - v = (flow_depth == 1) ? inputs_view(line, 0) : "" + # value: it is given one in place, never duplicated. Only text + # at the mapping'"'"'s own depth names inputs — outside quoted + # values and nested mappings, and including the rest of a line + # on which a nested mapping closes. + v = level_view(line, flow_depth); flow_depth = lv_depth # A bare `toolchain:` that ended the previous line is empty # only when the next token closes the mapping or moves on # (`}`, `,`); anything else is its value, spread over lines, @@ -199,7 +218,6 @@ apply_to_file() { if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_RE || v ~ TC_FLOW_RE) has_tc = 1 - flow_depth += brace_delta(line) if (flow_depth <= 0) in_flow = 0 continue } @@ -223,11 +241,10 @@ apply_to_file() { has_with = 1; with_i = i; with_indent = skey; in_with = 1 } else if (line ~ WITH_FLOW_RE) { has_with = 1; with_i = i; with_flow = 1 - v = inputs_view(line, 1) + v = level_view(line, 0); flow_depth = lv_depth if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_FLOW_RE) has_tc = 1 - flow_depth = brace_delta(line) if (flow_depth > 0) in_flow = 1 } else if (line ~ WITH_ANY_RE) { # `with: &anchor`, `with: *alias`, or any value shape this diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index e52654b9..665e5512 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -56,7 +56,9 @@ REPO_PATH="${1:?Usage: fix-stale-reusable-pin.sh }" TABLE_OWNERS_RE="" validate_table() { local n=0 v_path v_stale v_new bad=0 owners="" - while IFS=$'\t' read -r v_path v_stale v_new _; do + # `|| [ -n ... ]`: a final row the file ends on without a newline is a + # row too — `read` returns 1 on it with the fields filled in. + while IFS=$'\t' read -r v_path v_stale v_new _ || [ -n "$v_path" ]; do n=$((n + 1)) case "$v_path" in ''|'#'*) continue ;; esac [[ "$v_stale" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad stale SHA '$v_stale'" >&2; bad=1; } @@ -105,20 +107,26 @@ merge_duplicate_records() { close(cmd) } } - # A record key is the dependency between its quotes; the key is - # compared unquoted so `'a@sha':` and `"a@sha":` are one record, and - # the first spelling seen is the one written back. + # A record key is the dependency between its quotes — or a plain + # scalar, as a hand-added record may write it (`actions/checkout@:`, + # exactly the kind of record this fixer meets); the key is compared + # unquoted so `'a@sha':`, `"a@sha":` and `a@sha:` are one record, and + # the first spelling seen is the one written back. A record this + # merger did not recognise was not kept: every line of it fell to the + # catch-all below with no current record, and was dropped. function unq(s) { gsub(/["'"'"']/, "", s); return s } + function record_key(s) { return (s ~ /^ ["'"'"'][^"'"'"']+["'"'"']:$/ || s ~ /^ [^[:space:]"'"'"'#][^[:space:]]*:$/) } + function uses_entry(s) { return (s ~ /^ - ["'"'"'][^"'"'"']+["'"'"']$/ || s ~ /^ - [^[:space:]"'"'"'#][^[:space:]]*$/) } BEGIN { indep = 0; cur = "" } /^dependencies:[[:space:]]*$/ { indep = 1; print; next } !indep { print; next } - /^ ["'"'"'][^"'"'"']+["'"'"']:$/ { + record_key($0) { key = unq($0) if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } cur = key; next } /^ uses:[[:space:]]*$/ { next } - /^ - ["'"'"'][^"'"'"']+["'"'"']$/ { u = $0; sub(/^ - /, "", u); uses_of[cur SUBSEP unq(u)] = u; next } + uses_entry($0) { u = $0; sub(/^ - /, "", u); uses_of[cur SUBSEP unq(u)] = u; next } /^ [a-z_]+: / { if (first) { head[cur, ++nhead[cur]] = $0 } ; next } { # A line the merger does not understand is not the merger'"'"'s to drop: @@ -142,7 +150,7 @@ merge_duplicate_records() { lookup_replacement() { # local t_path t_stale t_new t_reason replacement=""; reason="" - while IFS=$'\t' read -r t_path t_stale t_new t_reason; do + while IFS=$'\t' read -r t_path t_stale t_new t_reason || [ -n "$t_path" ]; do [[ -z "$t_path" || "$t_path" == \#* ]] && continue [[ "$t_stale" == "$2" ]] || continue if [[ "$t_path" == "$1" ]] || { [[ "$t_path" == */\* ]] && [[ "$1" == "${t_path%\*}"* ]]; }; then @@ -268,14 +276,15 @@ done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml if [ -f "$LOCK" ] && [ -s "$edits" ]; then sort -u "$edits" -o "$edits" while IFS=$'\t' read -r wfkey stale new keep repo; do - # The workflow key in either quote style (`gh actions-lock` writes - # single quotes; a hand edit may not): compared unquoted. + # The workflow key in either quote style, or none (`gh actions-lock` + # writes single quotes; a hand edit may not): compared unquoted. if ! awk -v wfkey="$wfkey" -v edge="$repo@$stale" ' function unq(s) { gsub(/["'"'"']/, "", s); return s } + function bare(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+(#.*)?$/, "", s); return s } /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && /^ ["'"'"']/ { inwf = (unq($0) == " " wfkey ":") } - sec == "w" && inwf && /^ - / && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"")) { found = 1 } + sec == "w" && /^ [^[:space:]#]/ { inwf = (unq($0) == " " wfkey ":") } + sec == "w" && inwf && /^ - / && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"") || bare($0) == edge) { found = 1 } END { exit !found }' "$LOCK"; then echo "WARN: actions.lock has no edge '$repo@${stale:0:7}' under '$wfkey': the lock was already out of sync with this caller — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 lock_stale=1 @@ -291,19 +300,24 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then FNR == NR { if ($0 ~ /^workflows:[[:space:]]*$/) s1 = "w" if ($0 ~ /^dependencies:[[:space:]]*$/) s1 = "d" - if (s1 == "w" && $0 ~ /^ ["'"'"']/) w1 = (unq($0) == " " wfkey ":") + if (s1 == "w" && $0 ~ /^ [^[:space:]#]/) w1 = (unq($0) == " " wfkey ":") if (s1 == "w" && w1 && $0 ~ /^ - /) have[unq($0)] = 1 next } /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && /^ ["'"'"']/ { inwf = (unq($0) == " " wfkey ":") } + sec == "w" && /^ [^[:space:]#]/ { inwf = (unq($0) == " " wfkey ":") } sec == "w" && inwf && /^ - / { - # Either quote style; the edge keeps the one it has. + # Either quote style, or none (a hand-written edge); the edge + # keeps the spelling it has. q = "'"'"'"; i = index($0, q repo "@" stale q) if (!i) { q = "\""; i = index($0, q repo "@" stale q) } + if (i) moved = substr($0, 1, i + length(repo)) "@" new q + else { + t = $0; sub(/^ - /, "", t); sub(/[[:space:]]+(#.*)?$/, "", t) + if (t == repo "@" stale) { i = 1; rest = $0; sub(/^ - [^[:space:]]+/, "", rest); moved = " - " repo "@" new rest } + } if (i) { - moved = substr($0, 1, i + length(repo)) "@" new q # keep: an uncovered caller in this workflow still pins the stale # SHA, so the edge is added beside the old one, not replaced. if (keep == "1") print @@ -322,7 +336,8 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then while IFS=$'\t' read -r stale new repo; do grep -q "$repo@$stale" "$LOCK" || continue still=$(awk -v edge="$repo@$stale" ' - /^ +- ["'"'"']/ && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"")) { n++ } END { print n + 0 }' "$LOCK") + function bare(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+(#.*)?$/, "", s); return s } + /^ +- / && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"") || bare($0) == edge) { n++ } END { print n + 0 }' "$LOCK") tmp=$(mktemp) # Only THIS repository'"'"'s record moves: its key line, and its ref: and # commit: lines. Another repository at the same SHA is a different @@ -330,13 +345,14 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then awk -v stale="$stale" -v new="$new" -v repo="$repo" -v still="$still" ' /^dependencies:[[:space:]]*$/ { sec = "d" } # The record is the one whose unquoted key IS repo@stale: a key that - # merely contains it (`evilhyperpolymath/standards@`) is another record. - sec == "d" && /^ ["'"'"']/ { k = $0; sub(/^ /, "", k); sub(/:$/, "", k); gsub(/["'"'"']/, "", k); inrec = (k == repo "@" stale) } - sec == "d" && inrec && (/^ ["'"'"']/ || /^ (ref|commit):/) { + # merely contains it (`evilhyperpolymath/standards@`) is another + # record, and so is a plain unquoted key (a hand-written record). + sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/:$/, "", k); gsub(/["'"'"']/, "", k); inrec = (k == repo "@" stale) } + sec == "d" && inrec && (/^ [^[:space:]#]/ || /^ (ref|commit):/) { if (still == 0) gsub(stale, new) else { c = $0; gsub(stale, new, c); copy[++nc] = c } } - sec == "d" && inrec && still != 0 && !(/^ ["'"'"']/ || /^ (ref|commit):/) { copy[++nc] = $0 } + sec == "d" && inrec && still != 0 && !(/^ [^[:space:]#]/ || /^ (ref|commit):/) { copy[++nc] = $0 } { print } END { for (i = 1; i <= nc; i++) print copy[i] }' "$LOCK" > "$tmp" [ "$still" -eq 0 ] || echo "NOTE: actions.lock keeps the record for ${stale:0:7}: a workflow the table does not cover still pins it; the replacement got its own record." >&2 diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index a5c5e66a..c916a9cd 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2024,5 +2024,226 @@ TAG_MAP="$fx/ls14-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "a with: { uses: ... } flow input on a step key is data: the step's action is repaired, the estate record keeps its SHA ref" \ "grep -q \"^ ref: 'v1.0.0'\" '$fx/ls14/.github/workflows/actions.lock' && grep -q \"^ ref: '$SHA_B'\" '$fx/ls14/.github/workflows/actions.lock'" +# Review cases (Codex round on 5fa7192, gitbot-fleet#615). + +# tc20: a continuation line of a multi-line flow `with:` is read at the +# mapping's own level even when it STARTS inside a nested mapping: the text +# after the brace that closes the nested group (`channel: beta }, toolchain: +# nightly`) is an entry of `with:` — a toolchain that must count, or an +# empty one that is filled where it stands — while `toolchain:` inside the +# nested group is not. +mkdir -p "$fx/tc20/.github/workflows" +cat > "$fx/tc20/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { + channel: beta }, toolchain: nightly + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { + channel: beta }, toolchain: null } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { + toolchain: nightly }, components: clippy + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { channel: "}" }, # toolchain: nightly + toolchain: ${{ matrix.rust }} + } +YML +cat > "$fx/tc20-expected.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { + channel: beta }, toolchain: nightly + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { + channel: beta }, toolchain: stable } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: stable, + env: { + toolchain: nightly }, components: clippy + } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + env: { channel: "}" }, # toolchain: nightly + toolchain: ${{ matrix.rust }} + } +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc20" +check "tc20 an entry after a nested mapping closes mid-line counts (kept, or filled in place); one inside it does not" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc20-expected.yml' '$fx/tc20/.github/workflows/x.yml'" +cp "$fx/tc20/.github/workflows/x.yml" "$fx/tc20-after1.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc20" +check "tc20 second run is a no-op" "[ \$rc -eq 0 ] && cmp -s '$fx/tc20-after1.yml' '$fx/tc20/.github/workflows/x.yml'" + +# sp21: a table whose last row has no trailing newline is read whole — the +# row is matched and, when it is malformed, rejected — never skipped. +mkdir -p "$fx/sp21/.github/workflows" +printf 'jobs:\n rust:\n uses: octo/example/.github/workflows/ci.yml@%s\n' "$STALE_A" > "$fx/sp21/.github/workflows/ci.yml" +printf '# a private table\nocto/example/.github/workflows/*\t%s\t%s\tdead window' "$STALE_A" "$GOOD" > "$fx/sp21-table.tsv" +PINS_TABLE="$fx/sp21-table.tsv" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp21" +check "sp21 a final table row without a newline is a row: its owner is scanned and its dead pin moved" \ + "[ \$rc -eq 0 ] && grep -q \"ci.yml@$GOOD\$\" '$fx/sp21/.github/workflows/ci.yml' && grep -q '^FIXED' <<<\"\$out\"" +printf 'jobs:\n rust:\n uses: octo/example/.github/workflows/ci.yml@%s\n' "$STALE_A" > "$fx/sp21/.github/workflows/ci.yml" +printf 'octo/example/.github/workflows/*\t%s\tnot-a-sha\tbad row' "$STALE_A" > "$fx/sp21-bad.tsv" +PINS_TABLE="$fx/sp21-bad.tsv" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp21" +check "sp21 a malformed final row without a newline is still validated: exit 2, nothing changed" \ + "[ \$rc -eq 2 ] && grep -q 'bad replacement SHA' <<<\"\$out\" && grep -q \"ci.yml@$STALE_A\$\" '$fx/sp21/.github/workflows/ci.yml'" + +# sp22: a hand-written lock may leave its keys and edges unquoted. A plain +# record (`actions/checkout@:`) written before the quoted ones used to +# fall through the merger with no current record and vanish from +# `dependencies:` on any repair; it is a record like the others, and a plain +# edge or record of the dead pin itself moves like a quoted one. +mkdir -p "$fx/sp22/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp22/.github/workflows/ci.yml" +cat > "$fx/sp22/.github/workflows/actions.lock" < "$fx/ls15/.github/workflows/ci.yml" < "$fx/ls15/.github/workflows/two.yml" < "$fx/ls15/.github/workflows/actions.lock" < "$fx/ls15-tags.tsv" +TAG_MAP="$fx/ls15-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls15" --offline +check "ls15 a uses: inside a quoted string or a comment names no site: the two action records are repaired, the real reusable call keeps its SHA" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\" '$fx/ls15/.github/workflows/actions.lock' && grep -q \"^ ref: 'v3.0.0'\" '$fx/ls15/.github/workflows/actions.lock' && grep -q \"^ ref: '$SHA_B'\" '$fx/ls15/.github/workflows/actions.lock' && [ \$(grep -c '^FIXED' <<<\"\$out\") -eq 2 ]" + +# ls16: a hand-added record may spell its commit ID in upper-case hex, in +# its key, its ref, or the workflow's pin — git resolves it the same. The +# record is found, looked up in lower case, and its ref rewritten as the +# ref line spells it; a plain (unquoted) key is a record too. +mkdir -p "$fx/ls16/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: foo/bar@%s\n - uses: foo/baz@%s\n' "${SHA_A^^}" "$SHA_B" > "$fx/ls16/.github/workflows/ci.yml" +cat > "$fx/ls16/.github/workflows/actions.lock" < "$fx/ls16-tags.tsv" +TAG_MAP="$fx/ls16-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls16" --offline +check "ls16 an upper-case self-SHA record (quoted) and a plain-keyed one whose key and ref differ in case are both found and repaired" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls16/.github/workflows/actions.lock' && grep -q '^ ref: v2.0.0\$' '$fx/ls16/.github/workflows/actions.lock' && ! grep -q 'no self-SHA' <<<\"\$out\" && [ \$(grep -c '^FIXED' <<<\"\$out\") -eq 2 ]" + +# ls17: a repository name is compared as a name, not as a pattern: the `.` +# of `foo/action.test` is a dot, so a reusable call of `foo/actionXtest` at +# the same SHA is not a reusable use of the action, whose self-SHA record is +# repaired. +mkdir -p "$fx/ls17/.github/workflows" +cat > "$fx/ls17/.github/workflows/ci.yml" < "$fx/ls17/.github/workflows/actions.lock" < "$fx/ls17-tags.tsv" +TAG_MAP="$fx/ls17-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls17" --offline +check "ls17 a dot in a repository name is a dot: the action's record is repaired, the reusable at the same SHA keeps its SHA ref" \ + "[ \$rc -eq 0 ] && grep -A1 \"^ 'foo/action.test@$SHA_A':\$\" '$fx/ls17/.github/workflows/actions.lock' | grep -q \"ref: 'v1.0.0'\" && grep -A1 \"^ 'foo/actionXtest@$SHA_A':\$\" '$fx/ls17/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_A'\"" + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] From 709c153d12b64ddde9bd02ea5866643b08f49502 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:05:17 +0000 Subject: [PATCH 23/44] fix(baseline): move the 8 detector false positives to inline allow directives The previous commit on this branch suppressed eight content-pattern false positives (fix scripts whose grep/sed/awk patterns and messages NAME eval, a pipe-to-shell download or a fixed temp path in target files) by adding eight entries to .hypatia-baseline.json. That grew the ledger from 78 to 86 and failed `governance / Exemption ratchet`. The estate suppression ladder puts an inline directive ahead of a baseline entry, and the pinned scanner (hypatia 51ab649) honours `hypatia: allow content_patterns/` on the matching line for content-pattern rules. Each of the 35 lines the CI scanner flagged now carries that directive with its reason (one on the line above, where the flagged line opens a multi-line string), and the baseline is restored to its 78 entries. The suppressions and their reasons are kept; the ledger does not grow. The directives are trailing shell or awk comments: each of the six edited fixers produces byte-identical output and fixture results before and after on the same planted fixture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .hypatia-baseline.json | 59 +------------------------------ run-fleet.sh | 2 +- scripts/fix-chapel-runs-on-pin.sh | 4 +-- scripts/fix-command-injection.sh | 8 ++--- scripts/fix-dynamic-code-exec.sh | 12 +++---- scripts/fix-eval-to-safe.sh | 18 +++++----- scripts/fix-heredoc-install.sh | 14 ++++---- scripts/fix-tmp-paths.sh | 11 +++--- 8 files changed, 36 insertions(+), 92 deletions(-) diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json index f85be45f..49342b92 100644 --- a/.hypatia-baseline.json +++ b/.hypatia-baseline.json @@ -1,58 +1 @@ -[{"file":"bots/gsbot/scripts/backup_db.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/export_data.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/load_fixtures.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/setup.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/bot.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/admin.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/brands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/materials.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/user_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/main.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/settings.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/base.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/brand.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/garment.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/material.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/user.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/database.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/cache.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/logger.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/conftest.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/test_bot_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_models.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_services.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/accessibilitybot/src/analyzers/alt_text.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/aria.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/forms.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/keyboard.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/language.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/media.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/semantic.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/dns.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/hashing.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/password.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/rng.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/signatures.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/symmetric.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/dispatcher/echidna_client.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/scheduler/limiter.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/finishingbot/src/analyzers/claims.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/glambot/src/analyzers/accessibility.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/gsbot/src/services.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"robot-repo-automaton/src/hypatia.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"shared-context/benches/fleet_benchmarks.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":".","rule_module":"scorecard","severity":"medium","type":"DependencyPinning"},{"file":".claude/worktrees/actions-policy","rule_module":"structural_drift","severity":"critical","type":"SD005"},{"file":"boj-build.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"casket-pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"codeql.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"e2e.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"hypatia-dispatch-intake.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"inbox-steward.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"instant-sync.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"learning-loop.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"push-email-notify.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"repo-integrity-guard.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"rust.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"supervised-fleet-scan.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"expires_at":"2027-01-05","file":"bots/echidnabot/fuzz/fuzz_targets/fuzz_hmac.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/adapters/mod.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/api/rate_limit.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/scheduler/job_queue.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"}, -{ - "file": "run-fleet.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "eval_in_shell", - "note": "False positive: the case arm maps the literal eval_in_shell finding name to DynamicCodeExecution; it does not execute eval." -}, -{ - "file": "scripts/fix-command-injection.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "eval_in_shell", - "note": "Intentional detector and annotation strings for eval usage in target files; this remediation script does not execute eval." -}, -{ - "file": "scripts/fix-dynamic-code-exec.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "eval_in_shell", - "note": "Intentional grep/awk patterns and warning strings for JavaScript/Python eval in target files; this remediation script does not execute shell eval." -}, -{ - "file": "scripts/fix-eval-to-safe.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "eval_in_shell", - "note": "Intentional grep/sed patterns and security warning strings for eval in target files; this remediation script does not execute eval." -}, -{ - "file": "scripts/fix-heredoc-install.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "eval_in_shell", - "note": "Intentional grep patterns and diagnostic strings describing eval in target files; this remediation script does not execute eval." -}, -{ - "file": "scripts/fix-heredoc-install.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "download_then_run_shell", - "note": "False positive: the curl|bash text is a diagnostic for a matched unsafe target pattern; this remediation script does not download or execute an installer." -}, -{ - "file": "scripts/fix-tmp-paths.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "hardcoded_tmp", - "note": "Intentional grep/sed patterns and diagnostic strings identifying static temporary paths in target files; this remediation script does not create files at those paths." -}, -{ - "file": "scripts/fix-chapel-runs-on-pin.sh", - "rule_module": "content_patterns", - "severity": "medium", - "type": "hardcoded_tmp", - "note": "Intentional literal match and replacement of the Chapel installation command in target workflows; this remediation script does not download or install a package at that path." -} -] +[{"file":"bots/gsbot/scripts/backup_db.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/export_data.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/scripts/load_fixtures.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/setup.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/bot.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/admin.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/brands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/materials.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/cogs/user_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/bot/main.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/config/settings.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/base.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/brand.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/garment.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/material.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/models/user.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/database.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/services/sustainability.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/cache.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/src/utils/logger.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/conftest.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/integration/test_bot_commands.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/__init__.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_models.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/gsbot/tests/unit/test_services.py","rule_module":"cicd_rules","severity":"critical","type":"banned_language_file"},{"file":"bots/accessibilitybot/src/analyzers/alt_text.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/aria.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/forms.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/keyboard.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/language.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/media.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/accessibilitybot/src/analyzers/semantic.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/dns.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/hashing.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/password.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/rng.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/signatures.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/cipherbot/src/analyzers/symmetric.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/dispatcher/echidna_client.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/echidnabot/src/scheduler/limiter.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/finishingbot/src/analyzers/claims.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/glambot/src/analyzers/accessibility.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"bots/gsbot/src/services.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"robot-repo-automaton/src/hypatia.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":"shared-context/benches/fleet_benchmarks.rs","rule_module":"code_safety","severity":"medium","type":"expect_in_hot_path"},{"file":".","rule_module":"scorecard","severity":"medium","type":"DependencyPinning"},{"file":".claude/worktrees/actions-policy","rule_module":"structural_drift","severity":"critical","type":"SD005"},{"file":"boj-build.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"casket-pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"codeql.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"dogfood-gate.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"e2e.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"hypatia-dispatch-intake.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"inbox-steward.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"instant-sync.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"learning-loop.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"pages.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"panicbot-sweep.yml","rule_module":"workflow_audit","severity":"high","type":"unpinned_action"},{"file":"push-email-notify.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"repo-integrity-guard.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"rust.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"file":"supervised-fleet-scan.yml","rule_module":"workflow_audit","severity":"medium","type":"unpinned_action"},{"expires_at":"2027-01-05","file":"bots/echidnabot/fuzz/fuzz_targets/fuzz_hmac.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/adapters/mod.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"high","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"unwrap_without_check"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/api/rate_limit.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"},{"expires_at":"2027-01-05","file":"bots/echidnabot/src/scheduler/job_queue.rs","note":"Vendored from hyperpolymath/echidnabot (pinned sync); fix upstream","rule_module":"code_safety","severity":"medium","tracking_issue":"hyperpolymath/gitbot-fleet#588","type":"expect_in_hot_path"}] diff --git a/run-fleet.sh b/run-fleet.sh index 468146f9..a5972854 100755 --- a/run-fleet.sh +++ b/run-fleet.sh @@ -379,7 +379,7 @@ cmd_fix() { innerhtml_assignment|*innerHTML*) registry_key="XSS" ;; rust_unwrap|unwrap_without_check|*unwrap*) registry_key="PanicPath" ;; wildcard_cors|*cors*) registry_key="CORSWildcard" ;; - eval_in_shell|*eval*) registry_key="DynamicCodeExecution" ;; + eval_in_shell|*eval*) registry_key="DynamicCodeExecution" ;; # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval hardcoded_tmp|*tmp_path*) registry_key="UncheckedAllocation" ;; *sql_inject*|*parameteriz*) registry_key="SQLInjection" ;; *shell_inject*|*shell_quot*) registry_key="ShellInjection" ;; diff --git a/scripts/fix-chapel-runs-on-pin.sh b/scripts/fix-chapel-runs-on-pin.sh index 4c7515fe..2655e9e7 100755 --- a/scripts/fix-chapel-runs-on-pin.sh +++ b/scripts/fix-chapel-runs-on-pin.sh @@ -31,8 +31,8 @@ for f in $(find .github/workflows -name '*.yml' -o -name '*.yaml' 2>/dev/null); fi # Also normalise the apt install pattern (declarative resolution beats dpkg+fix loop) - if grep -qF 'sudo dpkg -i /tmp/chapel.deb || sudo apt-get install -f -y' "$f"; then - sed -i 's|sudo dpkg -i /tmp/chapel.deb || sudo apt-get install -f -y|sudo apt-get install -y /tmp/chapel.deb|' "$f" + if grep -qF 'sudo dpkg -i /tmp/chapel.deb || sudo apt-get install -f -y' "$f"; then # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path + sed -i 's|sudo dpkg -i /tmp/chapel.deb || sudo apt-get install -f -y|sudo apt-get install -y /tmp/chapel.deb|' "$f" # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path echo " apt-get install replaces dpkg fallback: $f" CHANGED=$((CHANGED + 1)) fi diff --git a/scripts/fix-command-injection.sh b/scripts/fix-command-injection.sh index b6d6a6e5..03e7d909 100755 --- a/scripts/fix-command-injection.sh +++ b/scripts/fix-command-injection.sh @@ -75,13 +75,13 @@ for file in "${SHELL_FILES[@]}"; do # --- Fix 2: Annotate eval usage with warning --- # Insert a warning comment above eval lines that use variable expansion - if grep -qP '^\s*eval\s+["\$]' "$file" 2>/dev/null; then + if grep -qP '^\s*eval\s+["\$]' "$file" 2>/dev/null; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval # Check if warning comment already exists (idempotent) - if ! grep -q "SECURITY: eval with variable expansion is a command injection risk" "$file" 2>/dev/null; then - sed -i.bak '/^\s*eval\s\+["\$]/i\# SECURITY: eval with variable expansion is a command injection risk — refactor to avoid eval' "$file" + if ! grep -q "SECURITY: eval with variable expansion is a command injection risk" "$file" 2>/dev/null; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval + sed -i.bak '/^\s*eval\s\+["\$]/i\# SECURITY: eval with variable expansion is a command injection risk — refactor to avoid eval' "$file" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval if [[ -f "$file.bak" ]] && ! diff -q "$file" "$file.bak" >/dev/null 2>&1; then ((CHANGES++)) || true - echo " [shell] Annotated eval usage in $rel_path" + echo " [shell] Annotated eval usage in $rel_path" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval fi rm -f "$file.bak" fi diff --git a/scripts/fix-dynamic-code-exec.sh b/scripts/fix-dynamic-code-exec.sh index 100827d8..356ca362 100755 --- a/scripts/fix-dynamic-code-exec.sh +++ b/scripts/fix-dynamic-code-exec.sh @@ -65,7 +65,7 @@ for file in "${JS_FILES[@]}"; do # Preserve indentation match($0, /^[[:space:]]*/); indent = substr($0, RSTART, RLENGTH); - print indent "// SECURITY WARNING: dynamic code execution — consider eliminating eval"; + print indent "// SECURITY WARNING: dynamic code execution — consider eliminating eval"; # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval } # Replace simple eval(varName) with Function form # Matches eval(identifier) but not eval("string") or eval(complex.expr()) @@ -95,7 +95,7 @@ for file in "${JS_FILES[@]}"; do if (prev !~ /SECURITY WARNING: dynamic code execution/) { match($0, /^[[:space:]]*/); indent = substr($0, RSTART, RLENGTH); - print indent "// SECURITY WARNING: dynamic code execution — new Function() is equivalent to eval"; + print indent "// SECURITY WARNING: dynamic code execution — new Function() is equivalent to eval"; # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval } print prev = $0 @@ -170,16 +170,16 @@ for file in "${PY_FILES[@]}"; do continue fi - if grep -qP '^\s*[^#]*\b(exec|eval)\(' "$file" 2>/dev/null; then + if grep -qP '^\s*[^#]*\b(exec|eval)\(' "$file" 2>/dev/null; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval tmpfile=$(mktemp) awk ' /# SECURITY WARNING: dynamic code execution/ { print; next } /^\s*#/ { print; next } - /\b(exec|eval)\(/ { + /\b(exec|eval)\(/ { # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval if (prev !~ /SECURITY WARNING: dynamic code execution/) { match($0, /^[[:space:]]*/); indent = substr($0, RSTART, RLENGTH); - print indent "# SECURITY WARNING: dynamic code execution — avoid exec()/eval() where possible"; + print indent "# SECURITY WARNING: dynamic code execution — avoid exec()/eval() where possible"; # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval } print prev = $0 @@ -191,7 +191,7 @@ for file in "${PY_FILES[@]}"; do if ! diff -q "$file" "$tmpfile" >/dev/null 2>&1; then cp "$tmpfile" "$file" rel_path="${file#"$REPO_PATH"/}" - echo " Flagged exec/eval in $rel_path" + echo " Flagged exec/eval in $rel_path" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval ((FIXED_COUNT++)) || true fi rm -f "$tmpfile" diff --git a/scripts/fix-eval-to-safe.sh b/scripts/fix-eval-to-safe.sh index 4948ea16..92a49069 100755 --- a/scripts/fix-eval-to-safe.sh +++ b/scripts/fix-eval-to-safe.sh @@ -35,9 +35,9 @@ while IFS= read -r -d '' file; do # --- Fix 1: eval statements --- # Annotate eval calls that are not already marked - if grep -v '^\s*#' "$file" 2>/dev/null | grep -qw 'eval'; then - if ! grep -q 'SECURITY.*eval' "$file" 2>/dev/null; then - sed -i '/^\s*#/!{/\beval\b/{/SECURITY/!s/\(.*\beval\b\)/# SECURITY: eval is unsafe with untrusted input — refactor to avoid dynamic code execution\n\1/}}' "$file" 2>/dev/null || true + if grep -v '^\s*#' "$file" 2>/dev/null | grep -qw 'eval'; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval + if ! grep -q 'SECURITY.*eval' "$file" 2>/dev/null; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval + sed -i '/^\s*#/!{/\beval\b/{/SECURITY/!s/\(.*\beval\b\)/# SECURITY: eval is unsafe with untrusted input — refactor to avoid dynamic code execution\n\1/}}' "$file" 2>/dev/null || true # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval changed=true fi fi @@ -54,7 +54,7 @@ while IFS= read -r -d '' file; do if [[ "$changed" == true ]]; then rel_path="${file#"$REPO_PATH"/}" - echo " FIXED $rel_path (shell eval/pipe-to-shell)" + echo " FIXED $rel_path (shell eval/pipe-to-shell)" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval ((FIXED_COUNT++)) || true fi done < <(find "$REPO_PATH" -type f \( -name "*.sh" -o -name "*.bash" \) "${FIND_EXCLUDES[@]}" -print0 2>/dev/null) @@ -70,9 +70,9 @@ while IFS= read -r -d '' file; do changed=false # eval annotation - if grep -v '^\s*#' "$file" 2>/dev/null | grep -qw 'eval'; then - if ! grep -q 'SECURITY.*eval' "$file" 2>/dev/null; then - sed -i '/^\s*#/!{/\beval\b/{/SECURITY/!s/\(.*\beval\b\)/# SECURITY: eval is unsafe with untrusted input — refactor to avoid dynamic code execution\n\1/}}' "$file" 2>/dev/null || true + if grep -v '^\s*#' "$file" 2>/dev/null | grep -qw 'eval'; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval + if ! grep -q 'SECURITY.*eval' "$file" 2>/dev/null; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval + sed -i '/^\s*#/!{/\beval\b/{/SECURITY/!s/\(.*\beval\b\)/# SECURITY: eval is unsafe with untrusted input — refactor to avoid dynamic code execution\n\1/}}' "$file" 2>/dev/null || true # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval changed=true fi fi @@ -87,7 +87,7 @@ while IFS= read -r -d '' file; do if [[ "$changed" == true ]]; then rel_path="${file#"$REPO_PATH"/}" - echo " FIXED $rel_path (shell eval/pipe-to-shell)" + echo " FIXED $rel_path (shell eval/pipe-to-shell)" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval ((FIXED_COUNT++)) || true fi done < <(find "$REPO_PATH" -type f -not -name "*.*" "${FIND_EXCLUDES[@]}" -print0 2>/dev/null) @@ -96,5 +96,5 @@ echo "" if [[ "$FIXED_COUNT" -gt 0 ]]; then echo "Annotated dynamic code execution patterns in $FIXED_COUNT file(s)" else - echo "No eval/pipe-to-shell patterns found" + echo "No eval/pipe-to-shell patterns found" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval fi diff --git a/scripts/fix-heredoc-install.sh b/scripts/fix-heredoc-install.sh index 24a8d394..18de8c50 100755 --- a/scripts/fix-heredoc-install.sh +++ b/scripts/fix-heredoc-install.sh @@ -29,7 +29,7 @@ while IFS= read -r -d '' file; do # Pattern 1: curl | bash / curl | sh (pipe-to-shell) if grep -qP 'curl\s.*\|\s*(ba)?sh' "$file" 2>/dev/null; then - echo " FOUND curl|bash in $rel_path" + echo " FOUND curl|bash in $rel_path" # hypatia: allow content_patterns/download_then_run_shell -- diagnostic message text; nothing is downloaded or executed # Add warning comment (idempotent — skip if already annotated) if ! grep -q "WARNING: Pipe-to-shell is unsafe" "$file" 2>/dev/null; then sed -i 's/\(.*curl.*|.*sh\)/# WARNING: Pipe-to-shell is unsafe — download and verify first\n\1/' "$file" 2>/dev/null || true @@ -38,14 +38,14 @@ while IFS= read -r -d '' file; do fi # Pattern 2: eval "$(cat ..." or eval "$(heredoc..." - if grep -qP 'eval\s+["\x27]?\$\(' "$file" 2>/dev/null; then - echo " FOUND eval \$() in $rel_path" + if grep -qP 'eval\s+["\x27]?\$\(' "$file" 2>/dev/null; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval + echo " FOUND eval \$() in $rel_path" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval ((changes++)) || true fi # Pattern 3: eval $VAR - if grep -qP 'eval\s+\$[A-Z_]' "$file" 2>/dev/null; then - echo " FOUND eval \$VAR in $rel_path" + if grep -qP 'eval\s+\$[A-Z_]' "$file" 2>/dev/null; then # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval + echo " FOUND eval \$VAR in $rel_path" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval ((changes++)) || true fi @@ -56,7 +56,7 @@ done < <(find "$REPO_PATH" -type f \( -name "*.sh" -o -name "*.bash" \) -not -pa echo "" if [[ "$FIXED_COUNT" -gt 0 ]]; then - echo "Found eval/heredoc patterns in $FIXED_COUNT file(s)" + echo "Found eval/heredoc patterns in $FIXED_COUNT file(s)" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval else - echo "No eval/heredoc patterns found" + echo "No eval/heredoc patterns found" # hypatia: allow content_patterns/eval_in_shell -- detector or message text naming eval in target files; this line does not run eval fi diff --git a/scripts/fix-tmp-paths.sh b/scripts/fix-tmp-paths.sh index 4cf849af..b8234c49 100755 --- a/scripts/fix-tmp-paths.sh +++ b/scripts/fix-tmp-paths.sh @@ -29,7 +29,7 @@ while IFS= read -r -d '' file; do fi # Only match static /tmp/name patterns (not /tmp/$VAR or /tmp/${VAR}) - if grep -qP '/tmp/[a-zA-Z][\w.-]+' "$file" 2>/dev/null; then + if grep -qP '/tmp/[a-zA-Z][\w.-]+' "$file" 2>/dev/null; then # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path rel_path="${file#$REPO_PATH/}" # Skip files that already use mktemp properly @@ -38,7 +38,7 @@ while IFS= read -r -d '' file; do continue fi - count=$(grep -cP '/tmp/[a-zA-Z][\w.-]+' "$file" 2>/dev/null || echo 0) + count=$(grep -cP '/tmp/[a-zA-Z][\w.-]+' "$file" 2>/dev/null || echo 0) # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path # Check if file already has our TMPDIR block (idempotent) if grep -q 'HYPATIA_TMPDIR' "$file" 2>/dev/null; then @@ -48,13 +48,14 @@ while IFS= read -r -d '' file; do # Strategy: Add a mktemp block after set -e (or shebang) and replace /tmp/X with $HYPATIA_TMPDIR/X # Step 1: Extract all static /tmp/filename patterns - tmp_names=$(grep -oP '/tmp/\K[a-zA-Z][\w.-]+' "$file" 2>/dev/null | sort -u) + tmp_names=$(grep -oP '/tmp/\K[a-zA-Z][\w.-]+' "$file" 2>/dev/null | sort -u) # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path if [[ -z "$tmp_names" ]]; then continue fi # Step 2: Insert mktemp block after the first 'set -' line or after shebang + # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path MKTEMP_BLOCK='# Auto-generated temp dir (replaces hardcoded /tmp/) HYPATIA_TMPDIR="$(mktemp -d)" trap '\''rm -rf "$HYPATIA_TMPDIR"'\'' EXIT' @@ -73,7 +74,7 @@ ${MKTEMP_BLOCK}" "$file" 2>/dev/null || true # Step 3: Replace /tmp/name with $HYPATIA_TMPDIR/name for each static name while IFS= read -r name; do - sed -i "s|/tmp/${name}|\"\$HYPATIA_TMPDIR/${name}\"|g" "$file" 2>/dev/null || true + sed -i "s|/tmp/${name}|\"\$HYPATIA_TMPDIR/${name}\"|g" "$file" 2>/dev/null || true # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path done <<< "$tmp_names" echo " FIXED $rel_path — replaced $count hardcoded /tmp/ path(s) with mktemp" @@ -85,5 +86,5 @@ echo "" if [[ "$FIXED_COUNT" -gt 0 ]]; then echo "Fixed $FIXED_COUNT file(s) — hardcoded /tmp/ replaced with mktemp" else - echo "No fixable /tmp/ patterns found" + echo "No fixable /tmp/ patterns found" # hypatia: allow content_patterns/hardcoded_tmp -- literal path this fixer matches in target files; nothing is created at that path fi From c7392ee80817be2bd6d6e772a5b99aaab750ac2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:29:43 +0000 Subject: [PATCH 24/44] fix(fix-scripts): private lock dir, masked stale-pin fallback, case-folded repos, commented nested uses, flow-nested steps Codex round on 709c153 (gitbot-fleet#615), five findings: * lib/pin-fixer-lock.sh: the lock file is opened only inside a directory private to this user. The default is a `pin-fixer-` directory created 0700 under XDG_RUNTIME_DIR/TMPDIR//tmp; any lock directory that is a symlink, not owned by this user, or group/world-writable is refused (exit 2, nothing changed). A symlink planted at a predictable name in a shared /tmp can no longer redirect the `>` open. (pl3) * fix-stale-reusable-pin.sh: the fallback scan for pins the parser cannot place reads a masked line (comment dropped, non-key quoted scalars emptied) and needs a `uses` key, so a dead pin named in a trailing comment or a quoted value is text, not UNSUPPORTED; a flow-style job with the dead pin as its `uses:` is still refused. (sp23) * fix-stale-reusable-pin.sh: owner/repository names are matched case-insensitively (candidate scan, table lookup, lock edges and records), the caller's spelling kept on rewrite; the path is matched literally in the sed rewrite. (sp24) * fix-stale-reusable-pin.sh: the lock merger accepts nested `uses:` entries with trailing comments and keeps any other line of the list under its `uses:` header; before, the header was dropped and the entry left as a child with no parent key. (sp25) * fix-lock-self-sha-refs.sh: each flow `uses` entry is classified by the keys that open the brackets around it: under `steps: [` an action, directly in a job mapping (inside `jobs: {` on the line, or on a job line under a block `jobs:`) a reusable call, anywhere else data. A step inside a one-line `jobs:` map now gets its tag. (ls18) Contracts: 194 passed under gawk and under mawk; the new ones fail against the previous scripts (the UNSUPPORTED regression guard passes on both). shellcheck -S warning clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 52 ++++++++-- scripts/fix-stale-reusable-pin.sh | 110 ++++++++++++++++----- scripts/lib/pin-fixer-lock.sh | 32 ++++-- scripts/tests/fix-workflow-pins.sh | 150 +++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 39 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index ef2808b3..3eacbad3 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -114,14 +114,46 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A 0) d-- + } + if (item) return (d == 1 && kind[1] == "{") ? "A" : "" + for (k = 1; k <= d; k++) if (kind[k] == "[" && kname[k] == "steps") return "A" + if (d >= 1 && kind[d] == "{" && kname[d] != "") { + if (d >= 2 && kind[d - 1] == "{" && kname[d - 1] == "jobs") return "R" + if (d == 1 && parent == "jobs") return "R" + } + return "" + } BEGIN { Q = "[\"'"'"']?" KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" @@ -147,7 +179,10 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 0 && keyat[p] == "jobs")) print "R\t" fu[j] - else if (key == "steps") print "A\t" fu[j] + fk = flow_kind(ml, out_at[j], (p >= 0 ? keyat[p] : ""), 0) + if (fk != "") print fk "\t" fu[j] } } } diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 665e5512..0216411f 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -99,12 +99,13 @@ merge_duplicate_records() { for (i = 1; i <= nhead[cur]; i++) print head[cur, i] n = 0 for (u in uses_of) if (index(u, cur SUBSEP) == 1) n++ - if (n > 0) { + if (n > 0 || nextra[cur] > 0) { print " uses:" fflush() cmd = "sort" for (u in uses_of) if (index(u, cur SUBSEP) == 1) print " - " uses_of[u] | cmd close(cmd) + for (i = 1; i <= nextra[cur]; i++) print uses_extra[cur, i] } } # A record key is the dependency between its quotes — or a plain @@ -116,17 +117,27 @@ merge_duplicate_records() { # catch-all below with no current record, and was dropped. function unq(s) { gsub(/["'"'"']/, "", s); return s } function record_key(s) { return (s ~ /^ ["'"'"'][^"'"'"']+["'"'"']:$/ || s ~ /^ [^[:space:]"'"'"'#][^[:space:]]*:$/) } - function uses_entry(s) { return (s ~ /^ - ["'"'"'][^"'"'"']+["'"'"']$/ || s ~ /^ - [^[:space:]"'"'"'#][^[:space:]]*$/) } - BEGIN { indep = 0; cur = "" } + # A nested `uses:` entry, quoted or plain, with or without a trailing + # comment; it is deduplicated on its value, the comment kept with + # the first spelling seen. + function uses_entry(s) { return (s ~ /^ - ["'"'"'][^"'"'"']+["'"'"']([[:space:]]+#.*)?$/ || s ~ /^ - [^[:space:]"'"'"'#][^[:space:]]*([[:space:]]+#.*)?$/) } + function entry_value(s) { sub(/^ - /, "", s); sub(/[[:space:]]+#.*$/, "", s); return unq(s) } + BEGIN { indep = 0; cur = ""; in_uses = 0 } /^dependencies:[[:space:]]*$/ { indep = 1; print; next } !indep { print; next } + # A record key or one of its own fields ends a nested `uses:` list. + /^ [^[:space:]]/ || /^ [^[:space:]]/ { in_uses = 0 } record_key($0) { key = unq($0) if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } cur = key; next } - /^ uses:[[:space:]]*$/ { next } - uses_entry($0) { u = $0; sub(/^ - /, "", u); uses_of[cur SUBSEP unq(u)] = u; next } + /^ uses:[[:space:]]*$/ { in_uses = 1; next } + uses_entry($0) { u = $0; sub(/^ - /, "", u); k = cur SUBSEP entry_value($0); if (!(k in uses_of)) uses_of[k] = u; next } + # Any other line inside the list (a comment, a shape this merger has + # no rule for) stays under the `uses:` header it came from: moved + # above it, it would be a child with no parent key. + in_uses { uses_extra[cur, ++nextra[cur]] = $0; next } /^ [a-z_]+: / { if (first) { head[cur, ++nhead[cur]] = $0 } ; next } { # A line the merger does not understand is not the merger'"'"'s to drop: @@ -148,12 +159,15 @@ merge_duplicate_records() { # wildcard paths. Set replacement/reason and return 0 on a match; otherwise # clear both variables and return 1. lookup_replacement() { # - local t_path t_stale t_new t_reason + local t_path t_stale t_new t_reason t_path_lc replacement=""; reason="" while IFS=$'\t' read -r t_path t_stale t_new t_reason || [ -n "$t_path" ]; do [[ -z "$t_path" || "$t_path" == \#* ]] && continue + t_path_lc="${t_path,,}" [[ "$t_stale" == "$2" ]] || continue - if [[ "$t_path" == "$1" ]] || { [[ "$t_path" == */\* ]] && [[ "$1" == "${t_path%\*}"* ]]; }; then + # GitHub resolves an owner or repository name in either case: the + # table and the caller are compared case-folded. + if [[ "${t_path,,}" == "${1,,}" ]] || { [[ "$t_path" == */\* ]] && [[ "${1,,}" == "${t_path_lc%\*}"* ]]; }; then replacement="$t_new"; reason="$t_reason"; return 0 fi done < "$PINS_TABLE" @@ -213,9 +227,36 @@ USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space: # tag that merely STARTS with a dead SHA (`-patched`) is a different ref # the table never named, and must not be rewritten into `-patched`. REF_END="([\"'[:space:]#]|$)" -# Git resolves a commit ID in either case: upper-case hex is the same pin, +# Git resolves a commit ID in either case, and GitHub an owner or repository +# name: an upper-case spelling is the same pin, matched case-insensitively, # looked up in lower case and rewritten as written. REUSABLE_REF_RE="(${TABLE_OWNERS_RE})/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-fA-F]{40}" +# A line with its comment dropped and every quoted scalar that is neither a +# key nor the value of a `uses` key emptied, quote-aware. The fallback scan +# for pins the parser cannot place reads this, so a dead pin named in a +# trailing comment or inside a `name:` string is data, not an UNSUPPORTED +# caller. +mask_line() { + printf '%s\n' "$1" | awk -v sq="'" '{ + s = $0; out = ""; n = length(s); i = 1 + tail = "(^|[-{,[:space:]])[\"" sq "]?uses[\"" sq "]?[[:space:]]*:[[:space:]]*$" + while (i <= n) { + c = substr(s, i, 1) + if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break + if (c == "\"" || c == sq) { + j = i + 1 + if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } + else { while (j <= n && substr(s, j, 1) != sq) j++ } + if (substr(s, j + 1) ~ /^[[:space:]]*:/ || tolower(out) ~ tail) out = out substr(s, i, j - i + 1) + else out = out c c + i = j + 1; continue + } + out = out c; i++ + } + print out + }' +} +FALLBACK_USES_RE="(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?${REUSABLE_REF_RE}${REF_END}" edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edgerepository lock_stale=0 # a rewritten caller whose lock entry never had the edge: the lock was already out of sync unsupported=0 @@ -227,14 +268,18 @@ while IFS= read -r -d '' wf; do while IFS= read -r numbered; do lineno="${numbered%%:*}" grep -qx "$lineno" <<<"$placed" && continue - ref=$(grep -oE "${REUSABLE_REF_RE}${REF_END}" <<<"${numbered#*:}" | head -1 | sed -E "s/[\"'[:space:]#]\$//" || true) + # Only a `uses:` key outside comments and other scalars can be a + # caller the parser failed to place (a flow-style job); a dead pin + # named in a comment or a `name:` string is text. + mask_line "${numbered#*:}" | grep -qiE "$FALLBACK_USES_RE" || continue + ref=$(grep -oiE "${REUSABLE_REF_RE}${REF_END}" <<<"${numbered#*:}" | head -1 | sed -E "s/[\"'[:space:]#]\$//" || true) [ -n "$ref" ] || continue sha="${ref##*@}" if lookup_replacement "${ref%@*}" "${sha,,}"; then echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 skip=1 fi - done < <(grep -nE "${REUSABLE_REF_RE}${REF_END}" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) + done < <(grep -niE "${REUSABLE_REF_RE}${REF_END}" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() for lineno in $klines; do @@ -242,14 +287,17 @@ while IFS= read -r -d '' wf; do # A job-level call pinned by tag (`@v1`) carries no SHA: nothing for a # SHA table to move, so it is a no-op here — not a grep exit 1 that # `set -e` would turn into a silent exit 1 of the whole run. - ref=$(grep -oE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$line" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]#]\$//" | head -1 || true) + ref=$(grep -oiE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$line" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]#]\$//" | head -1 || true) [ -n "$ref" ] || continue path="${ref%@*}"; stale_raw="${ref##*@}"; stale="${stale_raw,,}" lookup_replacement "$path" "$stale" || continue [[ "$replacement" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: bad replacement SHA in table for $stale" >&2; exit 2; } # ERE: a `$` inside a BRE group is literal to sed, so the boundary # alternation needs -E (and a delimiter the pattern does not use). - sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path)@$stale_raw([\"'[:space:]#]|\$)~\1@$replacement\3~" "$wf" + # The path as the caller spells it, matched literally (a `.` in it + # is a dot). + path_re=$(printf '%s' "$path" | sed -E 's/[][\.^$*+?(){}|~\\]/\\&/g') + sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path_re)@$stale_raw([\"'[:space:]#]|\$)~\1@$replacement\3~" "$wf" echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason)" rewrites=$((rewrites + 1)) pending+=("$stale"$'\t'"$replacement"$'\t'"${path%%/.github/workflows/*}") @@ -278,13 +326,16 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then while IFS=$'\t' read -r wfkey stale new keep repo; do # The workflow key in either quote style, or none (`gh actions-lock` # writes single quotes; a hand edit may not): compared unquoted. + # Repository and SHA are compared case-folded (GitHub and git read + # either case); every line keeps the spelling it has. if ! awk -v wfkey="$wfkey" -v edge="$repo@$stale" ' function unq(s) { gsub(/["'"'"']/, "", s); return s } function bare(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+(#.*)?$/, "", s); return s } + BEGIN { edge = tolower(edge) } /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } sec == "w" && /^ [^[:space:]#]/ { inwf = (unq($0) == " " wfkey ":") } - sec == "w" && inwf && /^ - / && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"") || bare($0) == edge) { found = 1 } + sec == "w" && inwf && /^ - / { l = tolower($0); if (index(l, "'"'"'" edge "'"'"'") || index(l, "\"" edge "\"") || bare(l) == edge) found = 1 } END { exit !found }' "$LOCK"; then echo "WARN: actions.lock has no edge '$repo@${stale:0:7}' under '$wfkey': the lock was already out of sync with this caller — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 lock_stale=1 @@ -301,7 +352,7 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then if ($0 ~ /^workflows:[[:space:]]*$/) s1 = "w" if ($0 ~ /^dependencies:[[:space:]]*$/) s1 = "d" if (s1 == "w" && $0 ~ /^ [^[:space:]#]/) w1 = (unq($0) == " " wfkey ":") - if (s1 == "w" && w1 && $0 ~ /^ - /) have[unq($0)] = 1 + if (s1 == "w" && w1 && $0 ~ /^ - /) have[tolower(unq($0))] = 1 next } /^workflows:[[:space:]]*$/ { sec = "w" } @@ -310,19 +361,20 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then sec == "w" && inwf && /^ - / { # Either quote style, or none (a hand-written edge); the edge # keeps the spelling it has. - q = "'"'"'"; i = index($0, q repo "@" stale q) - if (!i) { q = "\""; i = index($0, q repo "@" stale q) } + l = tolower($0); e = tolower(repo "@" stale) + q = "'"'"'"; i = index(l, q e q) + if (!i) { q = "\""; i = index(l, q e q) } if (i) moved = substr($0, 1, i + length(repo)) "@" new q else { - t = $0; sub(/^ - /, "", t); sub(/[[:space:]]+(#.*)?$/, "", t) - if (t == repo "@" stale) { i = 1; rest = $0; sub(/^ - [^[:space:]]+/, "", rest); moved = " - " repo "@" new rest } + t = l; sub(/^ - /, "", t); sub(/[[:space:]]+(#.*)?$/, "", t) + if (t == e) { i = 1; rest = $0; sub(/^ - [^[:space:]]+/, "", rest); spell = substr($0, 11, length(repo)); moved = " - " spell "@" new rest } } if (i) { # keep: an uncovered caller in this workflow still pins the stale # SHA, so the edge is added beside the old one, not replaced. if (keep == "1") print - if (unq(moved) in have) next - have[unq(moved)] = 1; $0 = moved + if (tolower(unq(moved)) in have) next + have[tolower(unq(moved))] = 1; $0 = moved } } { print } @@ -334,10 +386,11 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # stale SHA — a workflow's own edge, or another record's nested `uses:` # (a reusable that itself calls the stale revision keeps needing it). while IFS=$'\t' read -r stale new repo; do - grep -q "$repo@$stale" "$LOCK" || continue + grep -qiF "$repo@$stale" "$LOCK" || continue still=$(awk -v edge="$repo@$stale" ' function bare(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+(#.*)?$/, "", s); return s } - /^ +- / && (index($0, "'"'"'" edge "'"'"'") || index($0, "\"" edge "\"") || bare($0) == edge) { n++ } END { print n + 0 }' "$LOCK") + BEGIN { edge = tolower(edge) } + /^ +- / { l = tolower($0); if (index(l, "'"'"'" edge "'"'"'") || index(l, "\"" edge "\"") || bare(l) == edge) n++ } END { print n + 0 }' "$LOCK") tmp=$(mktemp) # Only THIS repository'"'"'s record moves: its key line, and its ref: and # commit: lines. Another repository at the same SHA is a different @@ -347,10 +400,17 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # The record is the one whose unquoted key IS repo@stale: a key that # merely contains it (`evilhyperpolymath/standards@`) is another # record, and so is a plain unquoted key (a hand-written record). - sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/:$/, "", k); gsub(/["'"'"']/, "", k); inrec = (k == repo "@" stale) } + # The SHA is replaced wherever it stands on the line, in either + # case; the rest of the line keeps its spelling. + function isub(s, old, new, l, o, i, out) { + out = ""; l = tolower(s); o = tolower(old) + while ((i = index(l, o)) > 0) { out = out substr(s, 1, i - 1) new; s = substr(s, i + length(old)); l = substr(l, i + length(old)) } + return out s + } + sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/:$/, "", k); gsub(/["'"'"']/, "", k); inrec = (tolower(k) == tolower(repo "@" stale)) } sec == "d" && inrec && (/^ [^[:space:]#]/ || /^ (ref|commit):/) { - if (still == 0) gsub(stale, new) - else { c = $0; gsub(stale, new, c); copy[++nc] = c } + if (still == 0) $0 = isub($0, stale, new) + else copy[++nc] = isub($0, stale, new) } sec == "d" && inrec && still != 0 && !(/^ [^[:space:]#]/ || /^ (ref|commit):/) { copy[++nc] = $0 } { print } diff --git a/scripts/lib/pin-fixer-lock.sh b/scripts/lib/pin-fixer-lock.sh index 43673ef0..fde871c0 100644 --- a/scripts/lib/pin-fixer-lock.sh +++ b/scripts/lib/pin-fixer-lock.sh @@ -11,15 +11,33 @@ # `sed -i` landing between another's read and write). # # Sourced, then `pin_fixer_lock `. The lock file lives outside the -# checkout (PIN_FIXER_LOCK_DIR, else XDG_RUNTIME_DIR, else TMPDIR), named by -# the checkout's real path, so every fixer run on the same checkout — by any -# path that reaches it — contends for the same file. It is held until the -# fixer exits. A holder that does not go away within PIN_FIXER_LOCK_WAIT -# seconds (default 600) is reported and the fixer exits 2 having changed -# nothing: a dispatcher must not record that run as a clean repair. +# checkout, named by the checkout's real path, so every fixer run on the same +# checkout — by any path that reaches it — contends for the same file. It is +# held until the fixer exits. A holder that does not go away within +# PIN_FIXER_LOCK_WAIT seconds (default 600) is reported and the fixer exits 2 +# having changed nothing: a dispatcher must not record that run as a clean +# repair. +# +# The directory is PIN_FIXER_LOCK_DIR when set, else a per-user +# `pin-fixer-` directory (created mode 0700) under XDG_RUNTIME_DIR, else +# TMPDIR, else /tmp. Opening `>file` follows a symlink, so the file must sit +# where no other user can plant one: the directory is refused — exit 2, +# nothing changed — unless it is a real directory (not a symlink) owned by +# this user and writable by neither group nor others. A predictable name in a +# shared, world-writable /tmp is exactly what this rules out. pin_fixer_lock() { local repo="$1" dir key wait - dir="${PIN_FIXER_LOCK_DIR:-${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}}" + if [ -n "${PIN_FIXER_LOCK_DIR:-}" ]; then + dir="$PIN_FIXER_LOCK_DIR" + else + dir="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/pin-fixer-$(id -u)" + mkdir -m 700 "$dir" 2>/dev/null || true + fi + if [ -L "$dir" ] || [ ! -d "$dir" ] || [ ! -O "$dir" ] \ + || [ -n "$(find "$dir" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then + echo "fix-workflow-pins: lock directory $dir is not a private directory of this user (a symlink, missing, owned by another user, or group/world-writable); nothing was changed" >&2 + exit 2 + fi wait="${PIN_FIXER_LOCK_WAIT:-600}" key=$(cd "$repo" 2>/dev/null && pwd -P || printf '%s' "$repo") key=$(printf '%s' "$key" | sha256sum | cut -c1-16) diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index c916a9cd..185c46ba 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2245,5 +2245,155 @@ TAG_MAP="$fx/ls17-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls17 a dot in a repository name is a dot: the action's record is repaired, the reusable at the same SHA keeps its SHA ref" \ "[ \$rc -eq 0 ] && grep -A1 \"^ 'foo/action.test@$SHA_A':\$\" '$fx/ls17/.github/workflows/actions.lock' | grep -q \"ref: 'v1.0.0'\" && grep -A1 \"^ 'foo/actionXtest@$SHA_A':\$\" '$fx/ls17/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_A'\"" +# Review cases (Codex round on 709c153, gitbot-fleet#615). + +# pl3: the lock file is opened only inside a directory private to this user. +# By default that is a `pin-fixer-` directory created 0700 under the +# runtime/temp base, so a symlink planted at the old predictable name in a +# shared, world-writable directory is never followed. A lock directory that +# is group/world-writable or a symlink is refused: exit 2, nothing changed. +mkdir -p "$fx/pl3/.github/workflows" "$fx/pl3-shared" "$fx/pl3-private" +chmod 1777 "$fx/pl3-shared"; chmod 700 "$fx/pl3-private" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/pl3/.github/workflows/ci.yml" +cp "$fx/pl3/.github/workflows/ci.yml" "$fx/pl3-before.yml" +pl3_key=$(cd "$fx/pl3" && pwd -P | tr -d '\n' | sha256sum | cut -c1-16) +printf 'keep\n' > "$fx/pl3-victim" +ln -s "$fx/pl3-victim" "$fx/pl3-shared/pin-fixer-$pl3_key.lock" +PIN_FIXER_LOCK_DIR="$fx/pl3-shared" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl3" +check "pl3 a world-writable lock directory is refused: exit 2, nothing changed, the planted link not followed" \ + "[ \$rc -eq 2 ] && grep -q 'not a private directory' <<<\"\$out\" && cmp -s '$fx/pl3-before.yml' '$fx/pl3/.github/workflows/ci.yml' && [ \"\$(cat '$fx/pl3-victim')\" = keep ]" +ln -s "$fx/pl3-private" "$fx/pl3-link" +PIN_FIXER_LOCK_DIR="$fx/pl3-link" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl3" +check "pl3 a symlinked lock directory is refused: exit 2, nothing changed" \ + "[ \$rc -eq 2 ] && grep -q 'not a private directory' <<<\"\$out\" && cmp -s '$fx/pl3-before.yml' '$fx/pl3/.github/workflows/ci.yml'" +rc=0; out=$(env -u PIN_FIXER_LOCK_DIR -u XDG_RUNTIME_DIR TMPDIR="$fx/pl3-shared" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl3" 2>&1) || rc=$? +pl3_dir="$fx/pl3-shared/pin-fixer-$(id -u)" +check "pl3 by default the lock sits in a 0700 per-user directory; the shared name is never opened" \ + "[ \$rc -eq 0 ] && [ -f '$pl3_dir/pin-fixer-$pl3_key.lock' ] && [ -z \"\$(find '$pl3_dir' -maxdepth 0 \\( -perm -0020 -o -perm -0002 -o -perm -0040 -o -perm -0004 \\) -print)\" ] && [ \"\$(cat '$fx/pl3-victim')\" = keep ] && grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/pl3/.github/workflows/ci.yml'" + +# sp23: a dead pin named only in a trailing comment or a quoted scalar is +# data: no caller to repair and no UNSUPPORTED exit. The real caller beside +# it is moved; a flow-style job carrying the dead pin as a `uses:` key is +# still a caller the parser cannot place. +mkdir -p "$fx/sp23/.github/workflows" "$fx/sp23b/.github/workflows" +cat > "$fx/sp23/.github/workflows/ci.yml" < "$fx/sp23b/.github/workflows/ci.yml" +cp "$fx/sp23b/.github/workflows/ci.yml" "$fx/sp23b-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp23b" +check "sp23 a flow-style job whose uses: key carries the dead pin is still UNSUPPORTED: exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/sp23b-before.yml' '$fx/sp23b/.github/workflows/ci.yml'" + +# sp24: GitHub reads an owner or repository name in either case. A caller +# spelled `Hyperpolymath/Standards/...` meets the table's row, is rewritten +# with its own spelling kept, and finds its (lower-case) lock edge and record. +mkdir -p "$fx/sp24/.github/workflows" +printf 'jobs:\n rust:\n uses: Hyperpolymath/Standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp24/.github/workflows/ci.yml" +cat > "$fx/sp24/.github/workflows/actions.lock" < "$fx/sp25/.github/workflows/ci.yml" +cat > "$fx/sp25/.github/workflows/actions.lock" < "$fx/sp25-expected.lock" < } ] } }` +# used to classify every uses: on the line as R, so the estate action record +# kept its self-SHA ref; the job-level reusable call beside it still keeps +# its SHA. +mkdir -p "$fx/ls18/.github/workflows" +cat > "$fx/ls18/.github/workflows/ci.yml" < "$fx/ls18/.github/workflows/actions.lock" < "$fx/ls18-tags.tsv" +TAG_MAP="$fx/ls18-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls18" --offline +check "ls18 a step in a one-line jobs map is an action (tag written); the job-level reusable call keeps its SHA" \ + "[ \$rc -eq 0 ] && grep -A1 \"^ 'hyperpolymath/standards@$SHA_A':\$\" '$fx/ls18/.github/workflows/actions.lock' | grep -q \"ref: 'v1.0.0'\" && grep -A1 \"^ 'hyperpolymath/cicd-suite@$SHA_B':\$\" '$fx/ls18/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_B'\"" + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] From c041211f37af920342ea466af8c0b91f687a227f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:42:49 +0000 Subject: [PATCH 25/44] fix(fix-scripts): compare lock edges by value, read the tag map case-folded Codex round on c7392ee (gitbot-fleet#615), two findings: * fix-stale-reusable-pin.sh: the edge mover deduplicates on the edge's value (list dash, trailing comment, quotes and case dropped). An entry that already listed the replacement edge with a trailing comment got it a second time when the stale edge moved. (sp26) * fix-lock-self-sha-refs.sh: lookup_tag reads TAG_MAP case-folded on repository and SHA, so an action spelled `Actions/Checkout` meets the shipped lower-case `actions/checkout` row offline instead of ending UNRESOLVED. (ls19) Contracts: 196 passed under gawk and under mawk; sp26 and ls19 fail on c7392ee. shellcheck -S warning clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 4 ++- scripts/fix-stale-reusable-pin.sh | 9 ++++-- scripts/tests/fix-workflow-pins.sh | 49 ++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 3eacbad3..ceef9bf3 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -54,7 +54,9 @@ best_tag() { awk '{ n = gsub(/\./, ".", $0); print n "\t" length($0) "\t" $0 }' # remote tags unless OFFLINE is set. Print nothing when no tag is found. lookup_tag() { # local repo="$1" sha="$2" tag - tag=$(awk -F'\t' -v r="$repo" -v s="$sha" '$1 == r && $2 == s { print $3; exit }' "$TAG_MAP" 2>/dev/null || true) + # GitHub resolves an owner/repository in either case and git a commit ID: + # the map is read case-folded, so `Actions/Checkout` meets `actions/checkout`. + tag=$(awk -F'\t' -v r="$repo" -v s="$sha" 'tolower($1) == tolower(r) && tolower($2) == tolower(s) { print $3; exit }' "$TAG_MAP" 2>/dev/null || true) if [ -z "$tag" ] && [ "$OFFLINE" -eq 0 ]; then tag=$(git ls-remote --tags "https://github.com/$repo" 2>/dev/null \ | awk -v s="$sha" '$1 == s { sub(/^refs\/tags\//, "", $2); sub(/\^\{\}$/, "", $2); print $2 }' \ diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 0216411f..3dcd6e74 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -348,11 +348,14 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # never given it a second time. awk -v wfkey="$wfkey" -v stale="$stale" -v new="$new" -v keep="$keep" -v repo="$repo" ' function unq(s) { gsub(/["'"'"']/, "", s); return s } + # An edge compared by its value: the list dash, a trailing + # comment, quotes and case are not part of it. + function edge_val(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return tolower(unq(s)) } FNR == NR { if ($0 ~ /^workflows:[[:space:]]*$/) s1 = "w" if ($0 ~ /^dependencies:[[:space:]]*$/) s1 = "d" if (s1 == "w" && $0 ~ /^ [^[:space:]#]/) w1 = (unq($0) == " " wfkey ":") - if (s1 == "w" && w1 && $0 ~ /^ - /) have[tolower(unq($0))] = 1 + if (s1 == "w" && w1 && $0 ~ /^ - /) have[edge_val($0)] = 1 next } /^workflows:[[:space:]]*$/ { sec = "w" } @@ -373,8 +376,8 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # keep: an uncovered caller in this workflow still pins the stale # SHA, so the edge is added beside the old one, not replaced. if (keep == "1") print - if (tolower(unq(moved)) in have) next - have[tolower(unq(moved))] = 1; $0 = moved + if (edge_val(moved) in have) next + have[edge_val(moved)] = 1; $0 = moved } } { print } diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 185c46ba..e785da07 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2395,5 +2395,54 @@ TAG_MAP="$fx/ls18-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls18 a step in a one-line jobs map is an action (tag written); the job-level reusable call keeps its SHA" \ "[ \$rc -eq 0 ] && grep -A1 \"^ 'hyperpolymath/standards@$SHA_A':\$\" '$fx/ls18/.github/workflows/actions.lock' | grep -q \"ref: 'v1.0.0'\" && grep -A1 \"^ 'hyperpolymath/cicd-suite@$SHA_B':\$\" '$fx/ls18/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_B'\"" +# Review cases (Codex round on c7392ee, gitbot-fleet#615). + +# sp26: an entry that already lists the replacement edge WITH a trailing +# comment is the same edge: moving the stale one must not add it again. +mkdir -p "$fx/sp26/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n done:\n uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@%s\n' "$STALE_A" "$GOOD" > "$fx/sp26/.github/workflows/ci.yml" +cat > "$fx/sp26/.github/workflows/actions.lock" < "$fx/ls19/.github/workflows/ci.yml" +cat > "$fx/ls19/.github/workflows/actions.lock" < "$fx/ls19-tags.tsv" +TAG_MAP="$fx/ls19-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls19" --offline +check "ls19 a mixed-case action meets its lower-case tag-map row offline: repaired, exit 0" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v7.0.1'\$\" '$fx/ls19/.github/workflows/actions.lock' && ! grep -q UNRESOLVED <<<\"\$out\"" + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] From 8caa5e829f9907b1bcf95b1782c88489c3904e94 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:36:24 +0000 Subject: [PATCH 26/44] test(fix-scripts): add regression coverage for lock isolation, temporary file cleanup, and private maintenance reports --- scripts/tests/fix-workflow-pins.sh | 2 + scripts/tests/fix-workflow-resources.sh | 320 ++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100755 scripts/tests/fix-workflow-resources.sh diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index e785da07..1f212931 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2444,5 +2444,7 @@ TAG_MAP="$fx/ls19-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls19 a mixed-case action meets its lower-case tag-map row offline: repaired, exit 0" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v7.0.1'\$\" '$fx/ls19/.github/workflows/actions.lock' && ! grep -q UNRESOLVED <<<\"\$out\"" +bash "$fleet/scripts/tests/fix-workflow-resources.sh" + echo "fix-workflow-pins: $pass passed, $fail failed" [ "$fail" -eq 0 ] diff --git a/scripts/tests/fix-workflow-resources.sh b/scripts/tests/fix-workflow-resources.sh new file mode 100755 index 00000000..0e903ca8 --- /dev/null +++ b/scripts/tests/fix-workflow-resources.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Resource boundaries for the workflow fixers changed in PR #615. Run alone +# or through fix-workflow-pins.sh. All mutations and command stubs stay in +# disposable fixtures; no GitHub requests or background processes are needed. +set -euo pipefail +# Child shells must use fixture commands rather than a user's startup file. +unset BASH_ENV +fleet="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fx="$(mktemp -d)" +trap 'rm -rf "$fx"' EXIT +mkdir -m 700 "$fx/tmp" "$fx/locks" +export TMPDIR="$fx/tmp" PIN_FIXER_LOCK_DIR="$fx/locks" PIN_FIXER_LOCK_WAIT=0 +export TOOLCHAIN=stable +pass=0; fail=0 + +check() { + local label="$1"; shift + if "$@"; then + pass=$((pass + 1)); echo " ok $label" + else + fail=$((fail + 1)); echo " FAIL $label" >&2 + fi +} +run_fix() { rc=0; out=$("$@" 2>&1) || rc=$?; } +empty_dir() { [ -z "$(find "$1" -mindepth 1 -print -quit)" ]; } +same_inputs() { + cmp -s "$1/.github/workflows/ci.yml" "$2/.github/workflows/ci.yml" && + cmp -s "$1/.github/workflows/actions.lock" "$2/.github/workflows/actions.lock" +} + +stale=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +good=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +rust_sha=cccccccccccccccccccccccccccccccccccccccc +action_sha=dddddddddddddddddddddddddddddddddddddddd +printf 'fixture/reusables/.github/workflows/*\t%s\t%s\tfixture repair\n' "$stale" "$good" > "$fx/pins.tsv" +printf 'fixture/action\t%s\tv1.2.3\n' "$action_sha" > "$fx/tags.tsv" +export PINS_TABLE="$fx/pins.tsv" TAG_MAP="$fx/tags.tsv" + +make_repo() { + mkdir -p "$1/.github/workflows" + cat > "$1/.github/workflows/ci.yml" < "$1/.github/workflows/actions.lock" < "$fx/regular-file" +cp "$fx/regular-file" "$fx/regular-file.before" +for invalid_dir in "$fx/missing" "$fx/regular-file" "$fx/group-writable"; do + run_fix env PIN_FIXER_LOCK_DIR="$invalid_dir" bash -c \ + '. "$1"; pin_fixer_lock "$2"; echo acquired' _ \ + "$fleet/scripts/lib/pin-fixer-lock.sh" "$repo" + check "lock helper rejects $(basename "$invalid_dir")" test "$rc" -eq 2 + check "lock helper diagnoses $(basename "$invalid_dir")" grep -q 'not a private directory' <<<"$out" +done +check "a file passed as the lock directory is preserved" cmp -s "$fx/regular-file.before" "$fx/regular-file" +check "a missing configured lock directory is not silently created" test ! -e "$fx/missing" + +# XDG_RUNTIME_DIR takes precedence over TMPDIR. The default child directory +# is private even when the base is shared; an explicit directory wins over +# both environment defaults. +mkdir -m 1777 "$fx/runtime" "$fx/fallback" +run_fix env -u PIN_FIXER_LOCK_DIR XDG_RUNTIME_DIR="$fx/runtime" TMPDIR="$fx/fallback" \ + bash -c '. "$1"; pin_fixer_lock "$2"' _ "$fleet/scripts/lib/pin-fixer-lock.sh" "$repo" +check "the helper can use XDG_RUNTIME_DIR" test "$rc" -eq 0 +check "the runtime lock directory has mode 0700" test "$(stat -c %a "$fx/runtime/pin-fixer-$(id -u)")" = 700 +check "TMPDIR stays untouched when XDG_RUNTIME_DIR is available" empty_dir "$fx/fallback" +make_repo "$fx/explicit-lock-repo" +runtime_locks_before=$(find "$fx/runtime" -type f | wc -l) +explicit_locks_before=$(find "$PIN_FIXER_LOCK_DIR" -type f | wc -l) +run_fix env XDG_RUNTIME_DIR="$fx/runtime" TMPDIR="$fx/fallback" \ + bash -c '. "$1"; pin_fixer_lock "$2"' _ "$fleet/scripts/lib/pin-fixer-lock.sh" "$fx/explicit-lock-repo" +check "an explicit private lock directory overrides both defaults" test "$rc" -eq 0 +check "an explicit lock directory receives the new lock" \ + test "$(find "$PIN_FIXER_LOCK_DIR" -type f | wc -l)" -eq "$((explicit_locks_before + 1))" +check "an explicit lock directory leaves runtime locks unchanged" \ + test "$(find "$fx/runtime" -type f | wc -l)" -eq "$runtime_locks_before" +check "an explicit lock directory does not use the TMPDIR fallback" empty_dir "$fx/fallback" + +# The timeout fixer changed to mktemp plus an EXIT trap in a per-file +# subshell. Cover mutation, no-op and failure after both temporaries exist. +timeouts="$fx/timeouts" +mkdir -p "$timeouts/.github/workflows" "$timeouts/tmp" "$timeouts/bin" +cat > "$timeouts/before.yml" <<'YML' +jobs: + first: + runs-on: ubuntu-latest + steps: [] + existing: + runs-on: ubuntu-latest + timeout-minutes: 42 + steps: [] + last: + runs-on: ubuntu-latest + steps: [] +YML +cat > "$timeouts/expected.yml" <<'YML' +jobs: + first: + runs-on: ubuntu-latest + timeout-minutes: 17 + steps: [] + existing: + runs-on: ubuntu-latest + timeout-minutes: 42 + steps: [] + last: + runs-on: ubuntu-latest + timeout-minutes: 17 + steps: [] +YML +cp "$timeouts/before.yml" "$timeouts/.github/workflows/a.yml" +cp "$timeouts/before.yml" "$timeouts/.github/workflows/b.yaml" +run_fix env TMPDIR="$timeouts/tmp" DEFAULT_TIMEOUT=17 bash "$fleet/scripts/fix-workflow-timeout-minutes.sh" "$timeouts" +check "timeout fixer repairs both workflow extensions" test "$rc" -eq 0 +for workflow in a.yml b.yaml; do + check "timeout fixer preserves existing limits and repairs multiple jobs in $workflow" \ + cmp -s "$timeouts/expected.yml" "$timeouts/.github/workflows/$workflow" +done +check "timeout fixer removes temporary files after repairs" empty_dir "$timeouts/tmp" +run_fix env TMPDIR="$timeouts/tmp" DEFAULT_TIMEOUT=99 bash "$fleet/scripts/fix-workflow-timeout-minutes.sh" "$timeouts" +check "timeout no-op succeeds with a different default" test "$rc" -eq 0 +check "timeout no-op preserves the repaired workflow" cmp -s "$timeouts/expected.yml" "$timeouts/.github/workflows/a.yml" +check "timeout no-op removes its scan temporary" empty_dir "$timeouts/tmp" +cat > "$timeouts/bin/sed" <<'SH' +#!/usr/bin/env bash +echo 'fixture: insertion failed' >&2 +exit 47 +SH +chmod +x "$timeouts/bin/sed" +cp "$timeouts/before.yml" "$timeouts/failure.yml" +run_fix env TMPDIR="$timeouts/tmp" PATH="$timeouts/bin:$PATH" \ + bash "$fleet/scripts/fix-workflow-timeout-minutes.sh" "$timeouts/failure.yml" +check "timeout insertion errors remain failures" test "$rc" -eq 47 +check "timeout insertion failure preserves the source workflow" cmp -s "$timeouts/before.yml" "$timeouts/failure.yml" +check "timeout insertion failure removes both temporary files" empty_dir "$timeouts/tmp" + +# Exercise the changed allowlist temporary's lifecycle with an inert gh. +# Unknown commands fail, so a new API call cannot escape to the real CLI. +policy="$fx/policy" +mkdir -p "$policy/bin" +cat > "$policy/bin/gh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$POLICY_CASE/calls" +case "$*" in + 'api repos/hyperpolymath/standards/contents/.machine_readable/bot_exclusion_registry.a2ml --jq .content') + printf 'excluded = []\n' | base64 ;; + 'api users/fixture-owner --jq .type') printf 'User\n' ;; + 'api repos/fixture-owner/demo/actions/permissions') + printf '%s\n' '{"allowed_actions":"selected","sha_pinning_required":true}' ;; + 'api repos/fixture-owner/demo/actions/permissions/selected-actions --jq .patterns_allowed|length') + printf '0\n' ;; + 'api repos/hyperpolymath/standards/actions/permissions/selected-actions') + case "$POLICY_SCENARIO" in + fetch-failure) exit 48 ;; + empty) printf '%s\n' '{"patterns_allowed":[]}' ;; + invalid) printf 'not json\n' ;; + *) printf '%s\n' '{"patterns_allowed":["fixture/*"]}' ;; + esac ;; + 'api --method PUT repos/fixture-owner/demo/actions/permissions/selected-actions --input '*) + [ "$POLICY_SCENARIO" != put-failure ] || exit 47 + cp "${@: -1}" "$POLICY_CASE/applied.json" ;; + *) echo "unexpected fixture gh call: $*" >&2; exit 99 ;; +esac +SH +chmod +x "$policy/bin/gh" +for scenario in success fetch-failure empty invalid put-failure; do + policy_case="$policy/$scenario" + mkdir -p "$policy_case/tmp" + run_fix env TMPDIR="$policy_case/tmp" PATH="$policy/bin:$PATH" \ + POLICY_CASE="$policy_case" POLICY_SCENARIO="$scenario" HYPATIA_AUTOMATION=on \ + bash "$fleet/scripts/fix-actions-policy.sh" fixture-owner --repo demo --apply + case "$scenario" in success) expected_rc=0 ;; put-failure) expected_rc=47 ;; *) expected_rc=2 ;; esac + check "allowlist $scenario reports the expected status" test "$rc" -eq "$expected_rc" + check "allowlist $scenario reaches the download path" \ + grep -qx 'api repos/hyperpolymath/standards/actions/permissions/selected-actions' "$policy_case/calls" + check "allowlist $scenario leaves no temporary files" empty_dir "$policy_case/tmp" + if [ "$scenario" = success ]; then + check "the downloaded allowlist reaches the mocked PUT intact" \ + jq -e '.patterns_allowed == ["fixture/*"]' "$policy_case/applied.json" + else + check "allowlist $scenario never applies a payload" test ! -e "$policy_case/applied.json" + fi +done + +# A user-supplied allowlist is not owned by the fixer, including on failure. +printf '%s\n' '{"patterns_allowed":["fixture/*"]}' > "$policy/curated.json" +cp "$policy/curated.json" "$policy/curated.before" +for scenario in success put-failure; do + policy_case="$policy/curated-$scenario" + mkdir -p "$policy_case/tmp" + run_fix env TMPDIR="$policy_case/tmp" PATH="$policy/bin:$PATH" \ + POLICY_CASE="$policy_case" POLICY_SCENARIO="$scenario" HYPATIA_AUTOMATION=on \ + bash "$fleet/scripts/fix-actions-policy.sh" fixture-owner --repo demo --apply --allowlist "$policy/curated.json" + expected_rc=0; [ "$scenario" != put-failure ] || expected_rc=47 + check "curated allowlist $scenario reports the expected status" test "$rc" -eq "$expected_rc" + check "curated allowlist $scenario preserves the caller's file" cmp -s "$policy/curated.before" "$policy/curated.json" + check "curated allowlist $scenario needs no temporary file" empty_dir "$policy_case/tmp" + check "curated allowlist $scenario never downloads a replacement" \ + test "$(grep -c '^api repos/hyperpolymath/standards/actions/permissions/selected-actions$' "$policy_case/calls" || true)" -eq 0 +done + +# The maintenance report now persists under XDG_STATE_HOME with a private +# umask. A fixture runner exercises the real wrapper without running scans. +maintenance="$fx/maintenance" +mkdir -p "$maintenance/repo/scripts/maintenance" "$maintenance/case" +cat > "$maintenance/repo/scripts/maintenance/run-maintenance.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$MAINTENANCE_CASE/args" +while [ "$#" -gt 0 ]; do + case "$1" in + --repo) shift 2 ;; + --output) output="$2"; shift 2 ;; + --strict|--fail-on-warn) shift ;; + *) exit 99 ;; + esac +done +printf '%s\n' "$output" > "$MAINTENANCE_CASE/output-path" +printf '%s\n' '{"summary":{"fail":0,"warn":0}}' > "$output" +SH +chmod +x "$maintenance/repo/scripts/maintenance/run-maintenance.sh" +run_fix env XDG_STATE_HOME="$maintenance/state with spaces" REPOS_BASE="$maintenance/no-repos" \ + PANIC_ATTACK_BIN= MAINTENANCE_CASE="$maintenance/case" \ + bash -c 'umask 022; exec bash "$@"' _ "$fleet/scripts/maintenance-hard-pass.sh" --repo "$maintenance/repo" +check "maintenance writes its default report successfully" test "$rc" -eq 0 +report=$(cat "$maintenance/case/output-path") +check "maintenance default report lives under XDG_STATE_HOME" \ + test "$(dirname "$report")" = "$maintenance/state with spaces/gitbot-fleet/maintenance" +check "maintenance default report is private despite the caller's umask" test "$(stat -c %a "$report")" = 600 +check "maintenance passes the strict gate to its runner" grep -qx -- --strict "$maintenance/case/args" +check "maintenance passes the warning gate to its runner" grep -qx -- --fail-on-warn "$maintenance/case/args" +run_fix env XDG_STATE_HOME="$maintenance/unused-state" REPOS_BASE="$maintenance/no-repos" \ + PANIC_ATTACK_BIN= MAINTENANCE_CASE="$maintenance/case" \ + bash "$fleet/scripts/maintenance-hard-pass.sh" --repo "$maintenance/repo" --output "$maintenance/explicit/report.json" +check "maintenance accepts an explicit report path" test "$rc" -eq 0 +check "maintenance preserves the explicit output path" \ + test "$(cat "$maintenance/case/output-path")" = "$maintenance/explicit/report.json" +check "an explicit maintenance report does not create the default state directory" test ! -e "$maintenance/unused-state" + +echo "fix-workflow-resources: $pass passed, $fail failed" +[ "$fail" -eq 0 ] From 3f7f773a8d9c80fa31c406e9b4eb8ab065d3379f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:52:51 +0000 Subject: [PATCH 27/44] fix(fix-scripts): read flow collections whole, mask before extracting, unspaced # is ref text Codex round on c041211. fix-lock-self-sha-refs.sh - A flow collection that is a key's value (`steps: [`, `jobs: {`), a `- {` step, or a whole line (a KYAML root, or `[ ... ]` under `steps:`) is read to its closing bracket and its `uses` entries classified there. `key: [` never opened the flow branch, so even a one-line `steps: [ { uses: hyperpolymath/standards@ } ]` under a block job was unread; an estate action there fell back to "reusable source" and kept its defective SHA ref while the run said OK. - A flow collection that never closes is refused (exit 2, lock untouched). - The estate-source fallback is case-folded (`Hyperpolymath/Standards`). - A record's `ref` key may be quoted (`"ref": ...`) and its key line may carry a trailing comment; both are read and rewritten in place. fix-rust-toolchain-input.sh - Only a pin that is the value of a `uses` key (outside quoted text and comments) is reported UNSUPPORTED; the same text in `name:` is data. fix-stale-reusable-pin.sh - The fallback reads every `uses:` site from the masked line it guards on, so a dead ref in `name:` is text and a live first site no longer hides a dead second one; it also ends a ref at `,` `}` `]`. - `#` ends a ref only after whitespace: `#patched` is another ref. - A lock record key or workflow key with a trailing comment is a key: the merger no longer drops such a record, the rename finds it, and the edge under a commented workflow key moves. Tests: 14 new contract cases (ls20-ls26, tc21/tc21b, sp27a-c, sp28, sp29); each fails on 8caa5e8. Suite: 210 + 91 passed under gawk and mawk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 82 +++++++++---- scripts/fix-rust-toolchain-input.sh | 30 ++++- scripts/fix-stale-reusable-pin.sh | 56 +++++---- scripts/tests/fix-workflow-pins.sh | 180 ++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 46 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index ceef9bf3..eef76947 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -156,9 +156,31 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A 0) { facc = s; fparent = parent; fitem = item; fsteps = steps } + else { fdepth = 0; flow_emit(s, parent, item, steps) } + } BEGIN { Q = "[\"'"'"']?" KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" + KEY_FLOW_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:[[:space:]]*[[{]" ITEM_RE = "^[[:space:]]*-([[:space:]]|$)" SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" USES_RE = "^" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q @@ -168,6 +190,19 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A 0) { + ml = masked($0); facc = facc " " ml; fdepth += depth_of(ml) + if (fdepth <= 0) { fdepth = 0; flow_emit(facc, fparent, fitem, fsteps) } + next + } + if ((ml = masked($0)) ~ /^[[:space:]]*[[{]/) { + # A flow collection that is a whole line: the root of a + # KYAML document, or the value of the key above it + # (`steps:` then `[ ... ]`), read as if written after it. + p = nearest_below(ind); g = (p >= 0) ? nearest_below(p) : -1 + flow_open(((p >= 0) ? keyat[p] ": " : "") ml, (g >= 0 ? keyat[g] : ""), 0, 0) + next + } if ($0 ~ ITEM_RE) { # A list item: its parent is the nearest key at its indentation or # shallower (`steps:\n- uses:` puts the dash under the key). @@ -181,13 +216,13 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A 0) flow_open(mt, "", 0, 0) } next } @@ -200,8 +235,8 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 0) ? nearest_below(p) : -1 if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "R\t" ref_of($0) else if (item_steps && ind == item_key) print "A\t" ref_of($0) - } else if ((ml = masked($0)) ~ /:[[:space:]]*\{/) { - # A flow mapping on the key'"'"'s line: a job written + } else if ((ml = masked($0)) ~ KEY_FLOW_RE) { + # A flow collection as the key'"'"'s value: a job written # `call: { uses: ... }`, or the whole `jobs: { ... }` mapping # on one line, is a reusable call; `steps: [ { uses: ... } ]` # is a list of actions. A flow mapping on one of a step'"'"'s @@ -211,25 +246,27 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 0 ? keyat[p] : ""), 0) - if (fk != "") print fk "\t" fu[j] - } - } + # is an action, not a reusable call. A collection that + # spans lines (`steps: [` ... `]`) is read whole. + p = nearest_below(ind) + flow_open(ml, (p >= 0 ? keyat[p] : ""), 0, 0) } keyat[ind] = key if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } } } + # A flow collection that never closes cannot be classified: the + # caller refuses rather than guess the kind of what it hides. + END { if (fdepth > 0) print "X\t" FILENAME } ' "$wf" done } SITES=$(mktemp) uses_sites > "$SITES" +if grep -q '^X' "$SITES"; then + echo "fix-lock-self-sha-refs: a flow collection in $(grep -m1 '^X' "$SITES" | cut -f2) never closes, so its uses: sites cannot be classified; lock left unchanged" >&2 + rm -f "$SITES"; exit 2 +fi # For owner/repo $1 at SHA $2, print reusable, action, or unknown; reusable # takes precedence when both kinds of use exist. Repository and revision are @@ -255,7 +292,8 @@ keeps_sha_ref() { # case "$(usage_of "$1" "$2")" in reusable) return 0 ;; action) return 1 ;; - *) case "$1" in hyperpolymath/standards|hyperpolymath/cicd-suite) return 0 ;; esac; return 1 ;; + # GitHub reads `Hyperpolymath/Standards` as hyperpolymath/standards. + *) case "${1,,}" in hyperpolymath/standards|hyperpolymath/cicd-suite) return 0 ;; esac; return 1 ;; esac } @@ -277,17 +315,17 @@ tmp=$(mktemp); cp "$LOCK" "$tmp" # line spells it. records=$(mktemp) if ! awk ' - /^ ["'"'"']?[^"'"'"'@[:space:]#]+@[0-9a-fA-F]+["'"'"']?:$/ { - s = $0; sub(/^ ["'"'"']?/, "", s); sub(/["'"'"']?:$/, "", s) + /^ ["'"'"']?[^"'"'"'@[:space:]#]+@[0-9a-fA-F]+["'"'"']?:([[:space:]]+#.*)?$/ { + s = $0; sub(/^ ["'"'"']?/, "", s); sub(/["'"'"']?:([[:space:]]+#.*)?$/, "", s) i = index(s, "@"); repo = substr(s, 1, i - 1); sha = substr(s, i + 1) if (length(sha) == 40) { inrec = 1; key_repo = repo; key_sha = sha } else inrec = 0 next } /^[^[:space:]]/ || /^ [^[:space:]]/ { inrec = 0 } - inrec && /^ ref: / { + inrec && /^ ["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]/ { # Quoted either way, or a plain scalar, in either case of hex: the - # same ref to GitHub. - r = $0; sub(/^ ref: /, "", r); sub(/[[:space:]]+#.*$/, "", r); gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) + # same ref to GitHub. The key may be quoted too (`"ref": ...`). + r = $0; sub(/^ ["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*/, "", r); sub(/[[:space:]]+#.*$/, "", r); gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) if (tolower(r) == tolower(key_sha)) print NR "\t" key_repo "\t" key_sha "\t" r } ' "$LOCK" > "$records"; then @@ -311,7 +349,7 @@ while IFS=$'\t' read -r lineno repo sha ref_raw; do # lineno is the ref: line itself; its quote style (or none) is kept, and # the SHA is matched as that line spells it (its case may differ from the # key's). - sed -i "${lineno}s|ref: \(['\"]\{0,1\}\)$ref_raw\1|ref: \1$tag\1|" "$tmp" + sed -i "${lineno}s|^\( ['\"]\{0,1\}ref['\"]\{0,1\}[[:space:]]*:[[:space:]]*\)\(['\"]\{0,1\}\)$ref_raw\2|\1\2$tag\2|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index ee760abd..08804028 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -152,6 +152,30 @@ apply_to_file() { nxt = substr(t, RSTART + RLENGTH, 1) return (length(m) == 40 && (nxt == "" || nxt ~ /["'"'"'[:space:],}\]]/)) } + # The line with its comment dropped and every quoted scalar that is + # neither a key nor the value of a `uses` key blanked to `""`. + function site_view(s, out, i, n, c, j, body) { + out = ""; n = length(s); i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break + if (c == "\"" || c == "'"'"'") { + j = i + 1 + if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } + else { while (j <= n && substr(s, j, 1) != "'"'"'") j++ } + body = substr(s, i + 1, j - i - 1) + if (substr(s, j + 1) ~ /^[[:space:]]*:/ || out ~ /(^|[{,[:space:]])["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*$/) out = out c body c + else out = out c c + i = j + 1; continue + } + out = out c; i++ + } + return out + } + # A SHA pin that is the value of a `uses` key, block or flow: the + # only pin the parser can be asked to place. The same text inside + # another key'"'"'s string (`name: "fixture dtolnay/...@"`) is data. + function uses_pin(s) { return sha_ref(s) && site_view(s) ~ USES_SITE_RE } # YAML context outside list items: the key at each indentation, so a # list item opens a step only when its parent key is `steps:`. function record_key(s, ind, k, t) { @@ -194,6 +218,7 @@ apply_to_file() { if (!sha_ref(buf[i])) continue if (!flow_item && buf[i] ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } if (!flow_item && i > 1 && indent_of(buf[i]) > skey) continue + if (!uses_pin(buf[i])) continue unrec[++nunrec] = lnum[i] } if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } @@ -306,6 +331,7 @@ apply_to_file() { KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-fA-F]+" + USES_SITE_RE = "(^|[-{,[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-fA-F]+" WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" @@ -354,8 +380,8 @@ apply_to_file() { # on one line, a key of a flow-style step spread over lines) is a # step this parser cannot place. The pin inside some other value # (a workflow-level `env:` string naming the action) is data. - if (sha_ref($0) && (parent_is_steps(ind) || under_steps(ind))) unrec[++nunrec] = NR - } else if (sha_ref($0) && parent_is_steps(ind)) unrec[++nunrec] = NR + if (uses_pin($0) && (parent_is_steps(ind) || under_steps(ind))) unrec[++nunrec] = NR + } else if (uses_pin($0) && parent_is_steps(ind)) unrec[++nunrec] = NR print } END { diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 3dcd6e74..94b93738 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -116,7 +116,7 @@ merge_duplicate_records() { # merger did not recognise was not kept: every line of it fell to the # catch-all below with no current record, and was dropped. function unq(s) { gsub(/["'"'"']/, "", s); return s } - function record_key(s) { return (s ~ /^ ["'"'"'][^"'"'"']+["'"'"']:$/ || s ~ /^ [^[:space:]"'"'"'#][^[:space:]]*:$/) } + function record_key(s) { sub(/[[:space:]]+#.*$/, "", s); return (s ~ /^ ["'"'"'][^"'"'"']+["'"'"']:$/ || s ~ /^ [^[:space:]"'"'"'#][^[:space:]]*:$/) } # A nested `uses:` entry, quoted or plain, with or without a trailing # comment; it is deduplicated on its value, the comment kept with # the first spelling seen. @@ -128,7 +128,7 @@ merge_duplicate_records() { # A record key or one of its own fields ends a nested `uses:` list. /^ [^[:space:]]/ || /^ [^[:space:]]/ { in_uses = 0 } record_key($0) { - key = unq($0) + key = $0; sub(/[[:space:]]+#.*$/, "", key); key = unq(key) if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } cur = key; next } @@ -223,10 +223,16 @@ yaml_context() { # # untouched, named on stderr, exit 2 — never "OK". The lock is edited after # the loop, once every workflow rewrite is known. USES_KEY_RE="^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?" -# A SHA ref ends at a quote, whitespace, a comment or the end of the value: a -# tag that merely STARTS with a dead SHA (`-patched`) is a different ref -# the table never named, and must not be rewritten into `-patched`. -REF_END="([\"'[:space:]#]|$)" +# A SHA ref ends at a quote, whitespace or the end of the value: a tag that +# merely STARTS with a dead SHA (`-patched`) is a different ref the table +# never named, and must not be rewritten into `-patched`. A `#` +# starts a comment only after whitespace, so `#patched` is one plain +# scalar — that other ref — and a comment is reached through the whitespace. +REF_END="([\"'[:space:]]|$)" +# The fallback that looks for a pin the parser could not place also stops at +# flow punctuation: `{ uses: x@}` is a flow-style caller, and missing it +# would let the run report OK. +FALLBACK_REF_END="([\"'[:space:],}]|]|$)" # Git resolves a commit ID in either case, and GitHub an owner or repository # name: an upper-case spelling is the same pin, matched case-insensitively, # looked up in lower case and rewritten as written. @@ -256,7 +262,7 @@ mask_line() { print out }' } -FALLBACK_USES_RE="(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?${REUSABLE_REF_RE}${REF_END}" +FALLBACK_USES_RE="(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?${REUSABLE_REF_RE}${FALLBACK_REF_END}" edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edgerepository lock_stale=0 # a rewritten caller whose lock entry never had the edge: the lock was already out of sync unsupported=0 @@ -271,15 +277,19 @@ while IFS= read -r -d '' wf; do # Only a `uses:` key outside comments and other scalars can be a # caller the parser failed to place (a flow-style job); a dead pin # named in a comment or a `name:` string is text. - mask_line "${numbered#*:}" | grep -qiE "$FALLBACK_USES_RE" || continue - ref=$(grep -oiE "${REUSABLE_REF_RE}${REF_END}" <<<"${numbered#*:}" | head -1 | sed -E "s/[\"'[:space:]#]\$//" || true) - [ -n "$ref" ] || continue - sha="${ref##*@}" - if lookup_replacement "${ref%@*}" "${sha,,}"; then - echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 - skip=1 - fi - done < <(grep -niE "${REUSABLE_REF_RE}${REF_END}" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) + # Every such site on the line is checked, each read from the masked + # line the guard read: a dead ref inside a `name:` string beside a + # live `uses:` is text, and a live first site does not hide a dead + # second one. + while IFS= read -r ref; do + [ -n "$ref" ] || continue + sha="${ref##*@}" + if lookup_replacement "${ref%@*}" "${sha,,}"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 + skip=1 + fi + done < <(mask_line "${numbered#*:}" | grep -oiE "$FALLBACK_USES_RE" | grep -oiE "$REUSABLE_REF_RE" || true) + done < <(grep -niE "${REUSABLE_REF_RE}${FALLBACK_REF_END}" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() for lineno in $klines; do @@ -287,7 +297,7 @@ while IFS= read -r -d '' wf; do # A job-level call pinned by tag (`@v1`) carries no SHA: nothing for a # SHA table to move, so it is a no-op here — not a grep exit 1 that # `set -e` would turn into a silent exit 1 of the whole run. - ref=$(grep -oiE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$line" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]#]\$//" | head -1 || true) + ref=$(grep -oiE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$line" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]]\$//" | head -1 || true) [ -n "$ref" ] || continue path="${ref%@*}"; stale_raw="${ref##*@}"; stale="${stale_raw,,}" lookup_replacement "$path" "$stale" || continue @@ -297,7 +307,7 @@ while IFS= read -r -d '' wf; do # The path as the caller spells it, matched literally (a `.` in it # is a dot). path_re=$(printf '%s' "$path" | sed -E 's/[][\.^$*+?(){}|~\\]/\\&/g') - sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path_re)@$stale_raw([\"'[:space:]#]|\$)~\1@$replacement\3~" "$wf" + sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path_re)@$stale_raw([\"'[:space:]]|\$)~\1@$replacement\3~" "$wf" echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason)" rewrites=$((rewrites + 1)) pending+=("$stale"$'\t'"$replacement"$'\t'"${path%%/.github/workflows/*}") @@ -330,11 +340,12 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # either case); every line keeps the spelling it has. if ! awk -v wfkey="$wfkey" -v edge="$repo@$stale" ' function unq(s) { gsub(/["'"'"']/, "", s); return s } + function keyv(s) { sub(/[[:space:]]+#.*$/, "", s); return unq(s) } function bare(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+(#.*)?$/, "", s); return s } BEGIN { edge = tolower(edge) } /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && /^ [^[:space:]#]/ { inwf = (unq($0) == " " wfkey ":") } + sec == "w" && /^ [^[:space:]#]/ { inwf = (keyv($0) == " " wfkey ":") } sec == "w" && inwf && /^ - / { l = tolower($0); if (index(l, "'"'"'" edge "'"'"'") || index(l, "\"" edge "\"") || bare(l) == edge) found = 1 } END { exit !found }' "$LOCK"; then echo "WARN: actions.lock has no edge '$repo@${stale:0:7}' under '$wfkey': the lock was already out of sync with this caller — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 @@ -348,19 +359,20 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # never given it a second time. awk -v wfkey="$wfkey" -v stale="$stale" -v new="$new" -v keep="$keep" -v repo="$repo" ' function unq(s) { gsub(/["'"'"']/, "", s); return s } + function keyv(s) { sub(/[[:space:]]+#.*$/, "", s); return unq(s) } # An edge compared by its value: the list dash, a trailing # comment, quotes and case are not part of it. function edge_val(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return tolower(unq(s)) } FNR == NR { if ($0 ~ /^workflows:[[:space:]]*$/) s1 = "w" if ($0 ~ /^dependencies:[[:space:]]*$/) s1 = "d" - if (s1 == "w" && $0 ~ /^ [^[:space:]#]/) w1 = (unq($0) == " " wfkey ":") + if (s1 == "w" && $0 ~ /^ [^[:space:]#]/) w1 = (keyv($0) == " " wfkey ":") if (s1 == "w" && w1 && $0 ~ /^ - /) have[edge_val($0)] = 1 next } /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } - sec == "w" && /^ [^[:space:]#]/ { inwf = (unq($0) == " " wfkey ":") } + sec == "w" && /^ [^[:space:]#]/ { inwf = (keyv($0) == " " wfkey ":") } sec == "w" && inwf && /^ - / { # Either quote style, or none (a hand-written edge); the edge # keeps the spelling it has. @@ -410,7 +422,7 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then while ((i = index(l, o)) > 0) { out = out substr(s, 1, i - 1) new; s = substr(s, i + length(old)); l = substr(l, i + length(old)) } return out s } - sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/:$/, "", k); gsub(/["'"'"']/, "", k); inrec = (tolower(k) == tolower(repo "@" stale)) } + sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/[[:space:]]+#.*$/, "", k); sub(/:[[:space:]]*$/, "", k); gsub(/["'"'"']/, "", k); inrec = (tolower(k) == tolower(repo "@" stale)) } sec == "d" && inrec && (/^ [^[:space:]#]/ || /^ (ref|commit):/) { if (still == 0) $0 = isub($0, stale, new) else copy[++nc] = isub($0, stale, new) diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 1f212931..d95f466c 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2444,6 +2444,186 @@ TAG_MAP="$fx/ls19-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls19 a mixed-case action meets its lower-case tag-map row offline: repaired, exit 0" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v7.0.1'\$\" '$fx/ls19/.github/workflows/actions.lock' && ! grep -q UNRESOLVED <<<\"\$out\"" +# ls20: a flow `steps:` sequence spread over lines is read to its closing +# bracket: an estate repository used there is an action, so its self-SHA +# record gets the tag instead of falling back to "estate reusable source" +# and being reported OK. The block reusable call after it keeps its SHA. +lock_records() { # ... : a lock with one record per argument + { printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n" + for r in "${@:2}"; do printf " - '%s'\n" "$r"; done + printf "dependencies:\n" + for r in "${@:2}"; do printf " '%s':\n ref: '%s'\n commit: 'sha1-%s'\n" "$r" "${r##*@}" "${r##*@}"; done + } > "$1/.github/workflows/actions.lock" +} +printf 'hyperpolymath/standards\t%s\tv2.0.0\nfoo/bar\t%s\tv1.0.0\nhyperpolymath/cicd-suite\t%s\tv9.9.9\n' "$SHA_A" "$SHA_B" "$SHA_C" > "$fx/ls20-tags.tsv" +mkdir -p "$fx/ls20/.github/workflows" +cat > "$fx/ls20/.github/workflows/ci.yml" < "$fx/ls21/.github/workflows/ci.yml" +lock_records "$fx/ls21" "hyperpolymath/standards@$SHA_A" +TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls21" --offline +check "ls21 an estate action in a one-line steps: [ ] under a block job is an action: tag written" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v2.0.0'\$\" '$fx/ls21/.github/workflows/actions.lock'" + +# ls22: a KYAML workflow is one flow mapping from its first line: its steps +# are actions and its job-level calls reusable, read through the brackets. +mkdir -p "$fx/ls22/.github/workflows" +cat > "$fx/ls22/.github/workflows/ci.yml" < "$fx/ls23/.github/workflows/ci.yml" +lock_records "$fx/ls23" "hyperpolymath/standards@$SHA_A" +cp "$fx/ls23/.github/workflows/actions.lock" "$fx/ls23-before.lock" +TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls23" --offline +check "ls23 an unclosed flow collection is refused: exit 2, said, lock untouched" \ + "[ \$rc -eq 2 ] && grep -q 'never closes' <<<\"\$out\" && cmp -s '$fx/ls23-before.lock' '$fx/ls23/.github/workflows/actions.lock'" + +# ls24: the estate-source fallback for a record no workflow uses reads the +# repository case-folded: `Hyperpolymath/Standards` is the reusable source +# and keeps its SHA ref, even with a tag-map row that would retag it. +mkdir -p "$fx/ls24/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: foo/bar@v1\n' > "$fx/ls24/.github/workflows/ci.yml" +lock_records "$fx/ls24" "Hyperpolymath/Standards@$SHA_A" +TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls24" --offline +check "ls24 an unreferenced Hyperpolymath/Standards record is the estate source in any case: keeps its SHA ref" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: '$SHA_A'\$\" '$fx/ls24/.github/workflows/actions.lock' && ! grep -q FIXED <<<\"\$out\"" + +# ls25/ls26: a hand-added record may quote its `ref` key, and may carry a +# trailing comment on its own key line; both are the same record. +mkdir -p "$fx/ls25/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: foo/bar@%s\n - uses: foo/baz@%s\n' "$SHA_B" "$SHA_C" > "$fx/ls25/.github/workflows/ci.yml" +cat > "$fx/ls25/.github/workflows/actions.lock" < "$fx/ls25-tags.tsv" +TAG_MAP="$fx/ls25-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls25" --offline +check "ls25 a quoted \"ref\": key is the record's ref: rewritten in place, its quotes kept" \ + "[ \$rc -eq 0 ] && grep -q '^ \"ref\": \"v1.0.0\"\$' '$fx/ls25/.github/workflows/actions.lock'" +check "ls26 a record key with a trailing comment is a record: its ref rewritten, the comment kept" \ + "grep -q \"^ 'foo/baz@$SHA_C': # added by hand\$\" '$fx/ls25/.github/workflows/actions.lock' && grep -q \"^ ref: 'v3.0.0'\$\" '$fx/ls25/.github/workflows/actions.lock'" + +# tc21: the pin text inside an ordinary step field is data, not a step the +# parser cannot place: no UNSUPPORTED, and a real bare step beside it is fixed. +mkdir -p "$fx/tc21/.github/workflows" "$fx/tc21b/.github/workflows" +cat > "$fx/tc21/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - name: "fixture dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772" + uses: actions/checkout@v4 +YML +cp "$fx/tc21/.github/workflows/x.yml" "$fx/tc21-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc21" +check "tc21 the pin named in a step's name: string is data: exit 0, no UNSUPPORTED, file untouched" \ + "[ \$rc -eq 0 ] && ! grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/tc21-before.yml' '$fx/tc21/.github/workflows/x.yml'" +cat > "$fx/tc21b/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - name: "pins dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772" + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc21b" +check "tc21b the same name: beside the real bare step: the step is repaired, the name kept" \ + "[ \$rc -eq 0 ] && ! grep -q UNSUPPORTED <<<\"\$out\" && grep -q 'toolchain: stable' '$fx/tc21b/.github/workflows/x.yml' && grep -q '^ - name: \"pins dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\"\$' '$fx/tc21b/.github/workflows/x.yml'" + +# sp27: the fallback for pins the parser cannot place reads each `uses:` +# site of the masked line. A dead ref inside `name:` beside a live `uses:` +# is text (no UNSUPPORTED); a live first site does not hide a dead second +# one; and a flow brace right after the SHA ends the ref. +RC_REUSABLE=hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml +mkdir -p "$fx/sp27a/.github/workflows" "$fx/sp27b/.github/workflows" "$fx/sp27c/.github/workflows" +printf 'jobs:\n call: { name: "%s@%s", uses: %s@%s }\n' "$RC_REUSABLE" "$STALE_A" "$RC_REUSABLE" "$GOOD" > "$fx/sp27a/.github/workflows/ci.yml" +cp "$fx/sp27a/.github/workflows/ci.yml" "$fx/sp27a-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp27a" +check "sp27a a dead ref inside name: beside a live flow uses: is text: exit 0, no UNSUPPORTED, untouched" \ + "[ \$rc -eq 0 ] && ! grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/sp27a-before.yml' '$fx/sp27a/.github/workflows/ci.yml'" +printf 'jobs: { a: { uses: %s@%s }, b: { uses: %s@%s } }\n' "$RC_REUSABLE" "$GOOD" "$RC_REUSABLE" "$STALE_A" > "$fx/sp27b/.github/workflows/ci.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp27b" +check "sp27b a dead second flow site behind a live first one is UNSUPPORTED: exit 2" \ + "[ \$rc -eq 2 ] && grep -q \"UNSUPPORTED.*@$STALE_A\" <<<\"\$out\"" +printf 'jobs:\n call: { uses: %s@%s}\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp27c/.github/workflows/ci.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp27c" +check "sp27c a dead flow site closed by a brace with no space is UNSUPPORTED, never OK: exit 2" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\"" + +# sp28: `#` with no whitespace before it is part of a plain scalar, so +# `#patched` is another ref the table never named: left alone. +mkdir -p "$fx/sp28/.github/workflows" +printf 'jobs:\n rust:\n uses: %s@%s#patched\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp28/.github/workflows/ci.yml" +cp "$fx/sp28/.github/workflows/ci.yml" "$fx/sp28-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp28" +check "sp28 #patched is a different ref: not rewritten, exit 0" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/sp28-before.yml' '$fx/sp28/.github/workflows/ci.yml'" + +# sp29: a lock record or workflow key with a trailing comment is a key: +# the merger keeps the unrelated commented record (it used to vanish), and +# the commented workflow key still carries the edge that moves. +mkdir -p "$fx/sp29/.github/workflows" +printf 'jobs:\n rust:\n uses: %s@%s\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp29/.github/workflows/ci.yml" +cat > "$fx/sp29/.github/workflows/actions.lock" < Date: Tue, 20 Oct 2026 03:10:59 +0000 Subject: [PATCH 28/44] fix(fix-scripts): refuse split replacements, parse lock edges by value, quoted record fields, anchored uses values Codex round on 3f7f773. fix-stale-reusable-pin.sh - Before any rewrite, every replacement the run would write is collected. Overlapping table rows are applied first match first, so two reusables of one repository at one stale SHA can be sent to two replacements; one lock edge and one record cannot carry both, so the run says CONFLICT, exits 2 and changes nothing. A single caller is still applied in table order (the existing sp-order contract). - A lock edge is matched by its own value (dash, quotes and trailing comment removed), in the existence check, the mover and the "still referenced" count: the stale edge quoted in another edge's comment is not the edge, so that edge is no longer rebuilt from its comment and dropped as a duplicate. - A record's `ref`/`commit` field keys may be quoted; they move with the key. - A job-level `uses:` whose value carries a YAML anchor is reported UNSUPPORTED (exit 2, untouched) instead of skipped as placed and reported OK. fix-rust-toolchain-input.sh - An anchored SHA-pinned toolchain value (`uses: &rt dtolnay/...@`) is UNSUPPORTED, not OK. fix-lock-self-sha-refs.sh - The same class: an anchored `uses:` value is read as the site it is. Tests: 6 new contract cases (sp30-sp33, tc22, ls27), each failing on 3f7f773. Suite: 216 + 91 passed under gawk and mawk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 6 +-- scripts/fix-rust-toolchain-input.sh | 2 +- scripts/fix-stale-reusable-pin.sh | 77 ++++++++++++++++++++++------ scripts/tests/fix-workflow-pins.sh | 79 +++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index eef76947..5b02e55c 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -87,7 +87,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A p) p = k + 0; return p } # The line with its trailing comment dropped and every quoted # scalar that is neither a key nor the value of a `uses` key @@ -120,10 +120,10 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A`). +# The rewrite does not edit anchored values, since every alias of the +# anchor would change with it, so such a value is reported, never skipped. +ANCHOR_RE="&[^[:space:],}]+[[:space:]]+" +FALLBACK_USES_RE="(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*(${ANCHOR_RE})?[\"']?${REUSABLE_REF_RE}${FALLBACK_REF_END}" +ANCHORED_USES_RE="(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*${ANCHOR_RE}[\"']?${REUSABLE_REF_RE}${FALLBACK_REF_END}" +# The dead reference a job-level `uses:` line carries, as written, or "". +k_ref() { grep -oiE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$1" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]]\$//" | head -1 || true; } + +# Every replacement the run would write, before one is written. Overlapping +# table rows are applied first match first, so two reusables of one +# repository at one stale SHA can be sent to two different replacements. +# One lock edge and one dependency record per repository@SHA cannot carry +# both, and which caller gets which cannot be said in the lock: the run +# refuses, and nothing is changed. +declare -A move_to=() +conflict=0 +while IFS= read -r -d '' wf; do + for lineno in $(yaml_context "$wf" | grep -o '^K[0-9]*' | tr -d K | sort -n || true); do + ref=$(k_ref "$(sed -n "${lineno}p" "$wf")") + [ -n "$ref" ] || continue + stale="${ref##*@}"; stale="${stale,,}" + lookup_replacement "${ref%@*}" "$stale" || continue + key="${ref%%/.github/workflows/*}"; key="${key,,}@$stale" + if [ -n "${move_to[$key]:-}" ] && [ "${move_to[$key]}" != "$replacement" ]; then + echo "CONFLICT ${wf#"$REPO_PATH"/}:$lineno: $key would move to ${move_to[$key]:0:7} here and to ${replacement:0:7} elsewhere; one lock edge and one record cannot carry both. Fix the table or the callers by hand; nothing was changed." >&2 + conflict=1 + fi + move_to[$key]="$replacement" + done +done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) +[ "$conflict" -eq 0 ] || exit 2 + edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edgerepository lock_stale=0 # a rewritten caller whose lock entry never had the edge: the lock was already out of sync unsupported=0 @@ -273,7 +305,12 @@ while IFS= read -r -d '' wf; do skip=0 while IFS= read -r numbered; do lineno="${numbered%%:*}" - grep -qx "$lineno" <<<"$placed" && continue + # A placed `uses:` key is the rewrite's, unless its value is anchored: + # the rewrite reads the reference right after the colon, finds none + # there, and would otherwise report the file clean. + if grep -qx "$lineno" <<<"$placed"; then + mask_line "${numbered#*:}" | grep -qiE "$ANCHORED_USES_RE" || continue + fi # Only a `uses:` key outside comments and other scalars can be a # caller the parser failed to place (a flow-style job); a dead pin # named in a comment or a `name:` string is text. @@ -285,7 +322,7 @@ while IFS= read -r -d '' wf; do [ -n "$ref" ] || continue sha="${ref##*@}" if lookup_replacement "${ref%@*}" "${sha,,}"; then - echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (flow-style job mapping?). Fix by hand; the file was not changed." >&2 + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (a flow-style job mapping, or an anchored value). Fix by hand; the file was not changed." >&2 skip=1 fi done < <(mask_line "${numbered#*:}" | grep -oiE "$FALLBACK_USES_RE" | grep -oiE "$REUSABLE_REF_RE" || true) @@ -297,7 +334,7 @@ while IFS= read -r -d '' wf; do # A job-level call pinned by tag (`@v1`) carries no SHA: nothing for a # SHA table to move, so it is a no-op here — not a grep exit 1 that # `set -e` would turn into a silent exit 1 of the whole run. - ref=$(grep -oiE "${USES_KEY_RE}${REUSABLE_REF_RE}${REF_END}" <<<"$line" | sed -E "s/${USES_KEY_RE}//; s/[\"'[:space:]]\$//" | head -1 || true) + ref=$(k_ref "$line") [ -n "$ref" ] || continue path="${ref%@*}"; stale_raw="${ref##*@}"; stale="${stale_raw,,}" lookup_replacement "$path" "$stale" || continue @@ -346,7 +383,9 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then /^workflows:[[:space:]]*$/ { sec = "w" } /^dependencies:[[:space:]]*$/ { sec = "d" } sec == "w" && /^ [^[:space:]#]/ { inwf = (keyv($0) == " " wfkey ":") } - sec == "w" && inwf && /^ - / { l = tolower($0); if (index(l, "'"'"'" edge "'"'"'") || index(l, "\"" edge "\"") || bare(l) == edge) found = 1 } + # The edge is the list item'"'"'s own value: the same text quoted in + # a trailing comment of another edge is not it. + sec == "w" && inwf && /^ - / { if (tolower(unq(bare($0))) == edge) found = 1 } END { exit !found }' "$LOCK"; then echo "WARN: actions.lock has no edge '$repo@${stale:0:7}' under '$wfkey': the lock was already out of sync with this caller — run \`gh actions-lock\` or scripts/check-lock-sync.sh before pushing." >&2 lock_stale=1 @@ -376,13 +415,18 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then sec == "w" && inwf && /^ - / { # Either quote style, or none (a hand-written edge); the edge # keeps the spelling it has. - l = tolower($0); e = tolower(repo "@" stale) - q = "'"'"'"; i = index(l, q e q) - if (!i) { q = "\""; i = index(l, q e q) } - if (i) moved = substr($0, 1, i + length(repo)) "@" new q - else { - t = l; sub(/^ - /, "", t); sub(/[[:space:]]+(#.*)?$/, "", t) - if (t == e) { i = 1; rest = $0; sub(/^ - [^[:space:]]+/, "", rest); spell = substr($0, 11, length(repo)); moved = " - " spell "@" new rest } + l = tolower($0); e = tolower(repo "@" stale); i = 0 + # Only an item whose own value is the stale edge moves; the + # same text in its comment is not the edge. Its first quoted + # occurrence is then the value, before any comment. + if (edge_val($0) == e) { + q = "'"'"'"; i = index(l, q e q) + if (!i) { q = "\""; i = index(l, q e q) } + if (i) moved = substr($0, 1, i + length(repo)) "@" new q + else { + t = l; sub(/^ - /, "", t); sub(/[[:space:]]+(#.*)?$/, "", t) + if (t == e) { i = 1; rest = $0; sub(/^ - [^[:space:]]+/, "", rest); spell = substr($0, 11, length(repo)); moved = " - " spell "@" new rest } + } } if (i) { # keep: an uncovered caller in this workflow still pins the stale @@ -403,9 +447,10 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then while IFS=$'\t' read -r stale new repo; do grep -qiF "$repo@$stale" "$LOCK" || continue still=$(awk -v edge="$repo@$stale" ' + function unq(s) { gsub(/["'"'"']/, "", s); return s } function bare(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+(#.*)?$/, "", s); return s } BEGIN { edge = tolower(edge) } - /^ +- / { l = tolower($0); if (index(l, "'"'"'" edge "'"'"'") || index(l, "\"" edge "\"") || bare(l) == edge) n++ } END { print n + 0 }' "$LOCK") + /^ +- / { if (tolower(unq(bare($0))) == edge) n++ } END { print n + 0 }' "$LOCK") tmp=$(mktemp) # Only THIS repository'"'"'s record moves: its key line, and its ref: and # commit: lines. Another repository at the same SHA is a different @@ -423,11 +468,13 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then return out s } sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/[[:space:]]+#.*$/, "", k); sub(/:[[:space:]]*$/, "", k); gsub(/["'"'"']/, "", k); inrec = (tolower(k) == tolower(repo "@" stale)) } - sec == "d" && inrec && (/^ [^[:space:]#]/ || /^ (ref|commit):/) { + # The field keys may be quoted (`"ref": ...`, `'"'"'commit'"'"': ...`), as + # a hand-written record may write them: the same fields. + sec == "d" && inrec && (/^ [^[:space:]#]/ || /^ ["'"'"']?(ref|commit)["'"'"']?[[:space:]]*:/) { if (still == 0) $0 = isub($0, stale, new) else copy[++nc] = isub($0, stale, new) } - sec == "d" && inrec && still != 0 && !(/^ [^[:space:]#]/ || /^ (ref|commit):/) { copy[++nc] = $0 } + sec == "d" && inrec && still != 0 && !(/^ [^[:space:]#]/ || /^ ["'"'"']?(ref|commit)["'"'"']?[[:space:]]*:/) { copy[++nc] = $0 } { print } END { for (i = 1; i <= nc; i++) print copy[i] }' "$LOCK" > "$tmp" [ "$still" -eq 0 ] || echo "NOTE: actions.lock keeps the record for ${stale:0:7}: a workflow the table does not cover still pins it; the replacement got its own record." >&2 diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index d95f466c..baa8dea0 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2624,6 +2624,85 @@ run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp29" check "sp29 a commented record key survives the merger, and a commented workflow key still moves its edge" \ "[ \$rc -eq 3 ] && grep -q \"^ 'actions/checkout@$SHA_A': # pinned by hand\$\" '$fx/sp29/.github/workflows/actions.lock' && grep -q \"^ ref: 'v4.2.2'\$\" '$fx/sp29/.github/workflows/actions.lock' && grep -q \"^ - 'hyperpolymath/standards@$GOOD'\$\" '$fx/sp29/.github/workflows/actions.lock' && ! grep -q 'already out of sync' <<<\"\$out\"" +# sp30: first-match rows may send two reusables of one repository at one +# stale SHA to two replacements. One lock edge and one record cannot carry +# both, so the run refuses before any rewrite; one caller alone (the +# sp-order cases above) is still applied in table order. +mkdir -p "$fx/sp30/.github/workflows" +printf 'jobs:\n a:\n uses: fixture/reusable/.github/workflows/ci.yml@%s\n b:\n uses: fixture/reusable/.github/workflows/other.yml@%s\n' "$STALE_A" "$STALE_A" > "$fx/sp30/.github/workflows/ci.yml" +cp "$fx/sp30/.github/workflows/ci.yml" "$fx/sp30-before.yml" +printf 'fixture/reusable/.github/workflows/ci.yml\t%s\t%s\texact\nfixture/reusable/.github/workflows/*\t%s\t%s\twildcard\n' "$STALE_A" "$GOOD" "$STALE_A" "$SHA_B" > "$fx/sp30-pins.tsv" +run_fix env PINS_TABLE="$fx/sp30-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp30" +check "sp30 one repository@stale sent to two replacements: CONFLICT, exit 2, nothing rewritten" \ + "[ \$rc -eq 2 ] && grep -q 'CONFLICT' <<<\"\$out\" && ! grep -q '^FIXED' <<<\"\$out\" && cmp -s '$fx/sp30-before.yml' '$fx/sp30/.github/workflows/ci.yml'" + +# sp31: the stale edge quoted in another edge's trailing comment is not the +# edge: the entry that lacks it is reported out of sync, and the other edge +# is kept as written (it used to be moved, then dropped as a duplicate). +mkdir -p "$fx/sp31/.github/workflows" +printf 'jobs:\n rust:\n uses: %s@%s\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp31/.github/workflows/ci.yml" +cat > "$fx/sp31/.github/workflows/actions.lock" < "$fx/sp32/.github/workflows/ci.yml" +cat > "$fx/sp32/.github/workflows/actions.lock" < "$fx/sp33/.github/workflows/ci.yml" +cp "$fx/sp33/.github/workflows/ci.yml" "$fx/sp33-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp33" +check "sp33 an anchored dead reusable value is UNSUPPORTED: exit 2, file untouched, never OK" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*anchored value' <<<\"\$out\" && cmp -s '$fx/sp33-before.yml' '$fx/sp33/.github/workflows/ci.yml'" +printf 'jobs:\n check:\n steps:\n - uses: &rt dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/tc22/.github/workflows/x.yml" +cp "$fx/tc22/.github/workflows/x.yml" "$fx/tc22-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc22" +check "tc22 an anchored SHA-pinned toolchain step is UNSUPPORTED: exit 2, file untouched, never OK" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/tc22-before.yml' '$fx/tc22/.github/workflows/x.yml'" + +# ls27: the self-SHA fixer reads an anchored `uses:` value as the site it +# is: the step is an action (tag written), the job call a reusable (kept). +mkdir -p "$fx/ls27/.github/workflows" +cat > "$fx/ls27/.github/workflows/ci.yml" < Date: Tue, 20 Oct 2026 03:21:09 +0000 Subject: [PATCH 29/44] fix(fix-scripts): keep a repaired file's mode, refuse no-op table rows, unspaced # stays in the ref Codex round on 0125570. - lib/pin-fixer-lock.sh: pin_fixer_replace replaces a file the way `sed -i` does: by a rename, with 's mode and, where this user may set them, its owner and group. The five `mv "$tmp" ...` sites (the toolchain fixer's workflow, the stale-pin fixer's lock writes and merger, the self-SHA fixer's lock) use it; a mktemp file is 0600, and renamed as it was it left a 0644 workflow or lock unreadable to the checkout's other users. - fix-stale-reusable-pin.sh: a table row whose replacement equals its stale SHA is refused at preflight; it repaired nothing yet reported every matching caller FIXED on every run. - fix-lock-self-sha-refs.sh: a `uses:` value ends at a quote or whitespace, not at an unspaced `#`, so `.../x.yml@#patched` is no reusable call at keeping an action's self-SHA record from its tag. Tests: 5 new contract cases (ls28, sp34, three mode checks at 0640), each failing on 0125570. Suite: 221 + 91 passed under gawk and mawk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 4 +-- scripts/fix-rust-toolchain-input.sh | 2 +- scripts/fix-stale-reusable-pin.sh | 9 ++++-- scripts/lib/pin-fixer-lock.sh | 11 +++++++ scripts/tests/fix-workflow-pins.sh | 49 +++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 5b02e55c..2b5b4ac7 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -87,7 +87,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A p) p = k + 0; return p } # The line with its trailing comment dropped and every quoted # scalar that is neither a key nor the value of a `uses` key @@ -355,7 +355,7 @@ while IFS=$'\t' read -r lineno repo sha ref_raw; do done < "$records" rm -f "$records" "$SITES" -if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; else rm -f "$tmp"; fi +if ! cmp -s "$LOCK" "$tmp"; then pin_fixer_replace "$tmp" "$LOCK"; else rm -f "$tmp"; fi if [ "$fixed" -eq 0 ] && [ "$unresolved" -eq 0 ]; then echo "OK (no self-SHA action records): $LOCK"; fi [ "$fixed" -gt 0 ] && echo "Rewrote $fixed record(s) in $LOCK" [ "$unresolved" -eq 0 ] diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 2c1ef996..c6bdfa51 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -398,7 +398,7 @@ apply_to_file() { set -e if [ "$rc" -ne 0 ]; then rm -f "$tmp"; return 2; fi - if ! cmp -s "$f" "$tmp"; then mv "$tmp" "$f"; else rm -f "$tmp"; echo "OK (every SHA-pinned dtolnay step names a toolchain): $f"; fi + if ! cmp -s "$f" "$tmp"; then pin_fixer_replace "$tmp" "$f"; else rm -f "$tmp"; echo "OK (every SHA-pinned dtolnay step names a toolchain): $f"; fi } unsupported=0 diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index aff0f130..a0c7c9c5 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -63,6 +63,9 @@ validate_table() { case "$v_path" in ''|'#'*) continue ;; esac [[ "$v_stale" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad stale SHA '$v_stale'" >&2; bad=1; } [[ "$v_new" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad replacement SHA '$v_new' for ${v_stale:0:7}" >&2; bad=1; } + # A row that moves a SHA to itself repairs nothing, yet every caller + # it matches would be reported FIXED on every run. + [ "$v_new" != "$v_stale" ] || { echo "fix-stale-reusable-pin: table row $n: replacement equals the stale SHA ${v_stale:0:7}; a row must move the pin" >&2; bad=1; } # The only wildcard is a terminal `/*` (every reusable of the repository); # a `*` anywhere else would be matched literally, never as a glob. [[ "$v_path" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/([A-Za-z0-9_.-]+|\*)$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad reusable path '$v_path' (the only wildcard is a terminal /*)" >&2; bad=1; } @@ -152,7 +155,7 @@ merge_duplicate_records() { } } ' "$lock" > "$tmp" - if ! cmp -s "$lock" "$tmp"; then mv "$tmp" "$lock"; else rm -f "$tmp"; fi + if ! cmp -s "$lock" "$tmp"; then pin_fixer_replace "$tmp" "$lock"; else rm -f "$tmp"; fi } # Find the first PINS_TABLE row for reusable path $1 at stale SHA $2, including @@ -438,7 +441,7 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then } { print } ' "$LOCK" "$LOCK" > "$tmp" - if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi + if ! cmp -s "$LOCK" "$tmp"; then pin_fixer_replace "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi done < "$edits" # Then the records: rename a stale record nothing references any more; # copy it under the replacement key while an edge still points at the @@ -478,7 +481,7 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then { print } END { for (i = 1; i <= nc; i++) print copy[i] }' "$LOCK" > "$tmp" [ "$still" -eq 0 ] || echo "NOTE: actions.lock keeps the record for ${stale:0:7}: a workflow the table does not cover still pins it; the replacement got its own record." >&2 - if ! cmp -s "$LOCK" "$tmp"; then mv "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi + if ! cmp -s "$LOCK" "$tmp"; then pin_fixer_replace "$tmp" "$LOCK"; lock_touched=1; else rm -f "$tmp"; fi done < <(cut -f2,3,5 "$edits" | sort -u) # A kept stale edge means the new edge was ADDED, so the stale record is # kept too (the branch above copies it); nothing else to do here. diff --git a/scripts/lib/pin-fixer-lock.sh b/scripts/lib/pin-fixer-lock.sh index fde871c0..238e0bcd 100644 --- a/scripts/lib/pin-fixer-lock.sh +++ b/scripts/lib/pin-fixer-lock.sh @@ -51,3 +51,14 @@ pin_fixer_lock() { exit 2 } } + +# Replace with the rewritten the way `sed -i` does: by a rename +# (never a half-written file), keeping 's mode, and its owner and group +# where this user may set them. A `mktemp` file is created 0600; renamed as it +# is over a 0644 workflow or lock, it would leave the repaired file unreadable +# to everyone else who shares the checkout. +pin_fixer_replace() { # + chmod --reference="$2" "$1" 2>/dev/null || chmod "$(stat -c %a "$2")" "$1" + chown --reference="$2" "$1" 2>/dev/null || true + mv -f "$1" "$2" +} diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index baa8dea0..1a341fa3 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2703,6 +2703,55 @@ TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls27 anchored uses: values are read: the anchored step is an action (tag), the anchored job call keeps its SHA" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v2.0.0'\$\" '$fx/ls27/.github/workflows/actions.lock' && grep -q \"^ ref: '$SHA_C'\$\" '$fx/ls27/.github/workflows/actions.lock'" +# ls28: `#` with no whitespace before it is part of the scalar, so +# `.../x.yml@#patched` is another ref, not a reusable call at +# that would keep the action's self-SHA record from its tag. +mkdir -p "$fx/ls28/.github/workflows" +cat > "$fx/ls28/.github/workflows/ci.yml" < "$fx/ls28-tags.tsv" +TAG_MAP="$fx/ls28-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls28" --offline +check "ls28 an unspaced # keeps the ref whole: #patched is no reusable call at , the action gets its tag" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.2.3'\$\" '$fx/ls28/.github/workflows/actions.lock'" + +# sp34: a row that moves a SHA to itself repairs nothing: the table is +# refused rather than every caller reported FIXED on every run. +mkdir -p "$fx/sp34/.github/workflows" +printf 'jobs:\n rust:\n uses: %s@%s\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp34/.github/workflows/ci.yml" +printf 'hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml\t%s\t%s\tself\n' "$STALE_A" "$STALE_A" > "$fx/sp34-pins.tsv" +run_fix env PINS_TABLE="$fx/sp34-pins.tsv" bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp34" +check "sp34 a row whose replacement equals its stale SHA is refused: exit 2, nothing FIXED" \ + "[ \$rc -eq 2 ] && grep -q 'replacement equals the stale SHA' <<<\"\$out\" && ! grep -q '^FIXED' <<<\"\$out\"" + +# mode: a repaired workflow or lock keeps the mode it had (a mktemp file is +# 0600; renamed as it is, the file would be unreadable to the checkout's +# other users). 0640 is no default either path could produce by chance. +mkdir -p "$fx/mode-tc/.github/workflows" "$fx/mode-sp/.github/workflows" "$fx/mode-ls/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/mode-tc/.github/workflows/x.yml" +chmod 0640 "$fx/mode-tc/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/mode-tc" +check "mode: the toolchain fixer's repaired workflow keeps its 0640" \ + "[ \$rc -eq 0 ] && grep -q 'toolchain: stable' '$fx/mode-tc/.github/workflows/x.yml' && [ \$(stat -c %a '$fx/mode-tc/.github/workflows/x.yml') = 640 ]" +printf 'jobs:\n rust:\n uses: %s@%s\n' "$RC_REUSABLE" "$STALE_A" > "$fx/mode-sp/.github/workflows/ci.yml" +lock_records "$fx/mode-sp" "hyperpolymath/standards@$STALE_A" +chmod 0640 "$fx/mode-sp/.github/workflows/ci.yml" "$fx/mode-sp/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/mode-sp" +check "mode: the stale-pin fixer's repaired workflow and lock keep their 0640" \ + "grep -q '@$GOOD' '$fx/mode-sp/.github/workflows/ci.yml' && [ \$(stat -c %a '$fx/mode-sp/.github/workflows/ci.yml') = 640 ] && [ \$(stat -c %a '$fx/mode-sp/.github/workflows/actions.lock') = 640 ]" +printf 'jobs:\n check:\n steps:\n - uses: foo/bar@%s\n' "$SHA_B" > "$fx/mode-ls/.github/workflows/ci.yml" +lock_records "$fx/mode-ls" "foo/bar@$SHA_B" +chmod 0640 "$fx/mode-ls/.github/workflows/actions.lock" +TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/mode-ls" --offline +check "mode: the self-SHA fixer's repaired lock keeps its 0640" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/mode-ls/.github/workflows/actions.lock' && [ \$(stat -c %a '$fx/mode-ls/.github/workflows/actions.lock') = 640 ]" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From 6dda1e075559423f913229737b6240a2ad514fec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 03:59:43 +0000 Subject: [PATCH 30/44] fix(fix-scripts): uses: values continued below the key are refused, toolchain matched in either case, keep-edge check bounded Codex round on f25a572. - fix-rust-toolchain-input.sh: the action is matched in either case (prefilter, sha_ref, the uses-key regexes), as GitHub resolves `Dtolnay/Rust-Toolchain` to the same action; such a step is repaired and keeps its spelling instead of exiting 0 unread. A step's own `uses:` whose value continues below the key (`uses: >-`, or the reference alone on the next line) is UNSUPPORTED (exit 2, untouched) instead of passed over as a step that names its toolchain. - fix-stale-reusable-pin.sh: yaml_context marks the continuation of a job's open `uses:` value `U`; a known-dead pin there is UNSUPPORTED instead of skipped as scalar text and reported OK. The check that keeps the stale lock edge for another caller of the same repository matches the repository literally and the SHA to its end, so `-patched` no longer keeps the stale edge and record beside the replacement. - fix-lock-self-sha-refs.sh: a site-position `uses:` with an open value emits `F`; the run refuses (exit 2, lock untouched) instead of reading the record as `unknown` and retagging a non-estate reusable's SHA ref. Tests: 7 new contract cases (tc23-tc25, sp35, sp36, ls29, ls30), each failing on f25a572; controls tc26 and ls31 (the same text inside a `run: |` script) pass on both. Suite: 230 + 91 passed under gawk and mawk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 19 +++++- scripts/fix-rust-toolchain-input.sh | 38 +++++++++--- scripts/fix-stale-reusable-pin.sh | 34 +++++++++-- scripts/tests/fix-workflow-pins.sh | 94 +++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 17 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 2b5b4ac7..520d4078 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -88,6 +88,15 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A-`), or nothing but a comment — names its site on the + # lines below, which this reader skips as scalar text. Such a site + # cannot be classified; the caller refuses (exit 2) rather than + # read the record it names as `unknown`. + function site(kind, r) { + if (r == "" || r ~ /^#/ || r ~ /^[|>][-+0-9]*$/) print "F\t" FILENAME ":" FNR + else print kind "\t" r + } function nearest_below(ind, k, p) { p = -1; for (k in keyat) if (k + 0 < ind && k + 0 > p) p = k + 0; return p } # The line with its trailing comment dropped and every quoted # scalar that is neither a key nor the value of a `uses` key @@ -215,7 +224,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A item_indent) item_key = ind if (key == "uses") { p = nearest_below(ind); g = (p >= 0) ? nearest_below(p) : -1 - if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "R\t" ref_of($0) - else if (item_steps && ind == item_key) print "A\t" ref_of($0) + if (p >= 0 && g >= 0 && keyat[g] == "jobs") site("R", ref_of($0)) + else if (item_steps && ind == item_key) site("A", ref_of($0)) } else if ((ml = masked($0)) ~ KEY_FLOW_RE) { # A flow collection as the key'"'"'s value: a job written # `call: { uses: ... }`, or the whole `jobs: { ... }` mapping @@ -267,6 +276,10 @@ if grep -q '^X' "$SITES"; then echo "fix-lock-self-sha-refs: a flow collection in $(grep -m1 '^X' "$SITES" | cut -f2) never closes, so its uses: sites cannot be classified; lock left unchanged" >&2 rm -f "$SITES"; exit 2 fi +if grep -q '^F' "$SITES"; then + echo "fix-lock-self-sha-refs: the uses: value at $(grep -m1 '^F' "$SITES" | cut -f2) is a block scalar or continues on the next line, so the site it names cannot be classified; lock left unchanged" >&2 + rm -f "$SITES"; exit 2 +fi # For owner/repo $1 at SHA $2, print reusable, action, or unknown; reusable # takes precedence when both kinds of use exist. Repository and revision are diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index c6bdfa51..6f6a0394 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -35,10 +35,14 @@ # closing brace — without `toolchain` gets it as its first entry; # * a step with no `with:` at all gets one, directly under `uses:`; # * keys may be quoted (`"uses":`, "with":) — the same key to GitHub; +# * the action is matched in either case (`Dtolnay/Rust-Toolchain@` +# is the same action to GitHub) and its spelling is kept; # * a tag-pinned step (`@stable`, `@1.85.0`) and a step that already names a # toolchain are left alone; # * a step written as a flow mapping (`- { name: x, uses: ... }`), a -# one-line `steps: [ { uses: ... } ]`, or a `with:` whose value this +# one-line `steps: [ { uses: ... } ]`, a `uses:` whose value continues +# below its key (`uses: >-`, or the reference alone on the next line), +# or a `with:` whose value this # parser does not read (`with: &anchor`, `with: *alias`) is NOT # rewritten: the script names the line as UNSUPPORTED and exits 2 rather # than report the file as clean. Fix it by hand. @@ -83,7 +87,9 @@ fi apply_to_file() { local f="$1" tmp rc [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } - grep -qE 'dtolnay/rust-toolchain@[0-9a-fA-F]{40}' "$f" || return 0 + # GitHub resolves `Dtolnay/Rust-Toolchain` to the same action: the name is + # matched in either case here and in the parser below. + grep -qiE 'dtolnay/rust-toolchain@[0-9a-fA-F]{40}' "$f" || return 0 tmp=$(mktemp) set +e @@ -143,11 +149,12 @@ apply_to_file() { # channel and the step is not ours. # The 40 hex characters must END the ref: `@-patched` is a tag # that merely begins with one, and carries its own channel. Either - # case: git resolves a commit ID written in upper-case hex too. + # case, for the commit ID (git resolves upper-case hex too) and for the + # action name (GitHub resolves `Dtolnay/Rust-Toolchain` too). function sha_ref(s, t, m, nxt) { if (!plain(s)) return 0 - t = s; sub(/[[:space:]]#.*$/, "", t) - if (!match(t, /dtolnay\/rust-toolchain@[0-9a-fA-F]+/)) return 0 + t = tolower(s); sub(/[[:space:]]#.*$/, "", t) + if (!match(t, /dtolnay\/rust-toolchain@[0-9a-f]+/)) return 0 m = substr(t, RSTART + 23, RLENGTH - 23) nxt = substr(t, RSTART + RLENGTH, 1) return (length(m) == 40 && (nxt == "" || nxt ~ /["'"'"'[:space:],}\]]/)) @@ -175,7 +182,7 @@ apply_to_file() { # A SHA pin that is the value of a `uses` key, block or flow: the # only pin the parser can be asked to place. The same text inside # another key'"'"'s string (`name: "fixture dtolnay/...@"`) is data. - function uses_pin(s) { return sha_ref(s) && site_view(s) ~ USES_SITE_RE } + function uses_pin(s) { return sha_ref(s) && tolower(site_view(s)) ~ USES_SITE_RE } # YAML context outside list items: the key at each indentation, so a # list item opens a step only when its parent key is `steps:`. function record_key(s, ind, k, t) { @@ -215,8 +222,15 @@ apply_to_file() { # parser cannot place (a flow-style step) — that is reported, not guessed. uses_i = 0 for (i = 1; i <= n; i++) { + # The step'"'"'s own `uses:` with its value continued below the key + # (`uses: >-` then `dtolnay/rust-toolchain@`): the key line + # names no action and the value reads as nested text, so a SHA + # pin there is reported, never passed over as a step that is fine. + if (!flow_item && length(key_indent_of(buf[i])) == skey && tolower(site_view(buf[i])) ~ USES_OPEN_RE) + for (j = i + 1; j <= n && !(plain(buf[j]) && indent_of(buf[j]) <= skey); j++) + if (sha_ref(buf[j])) unrec[++nunrec] = lnum[j] if (!sha_ref(buf[i])) continue - if (!flow_item && buf[i] ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } + if (!flow_item && tolower(buf[i]) ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } if (!flow_item && i > 1 && indent_of(buf[i]) > skey) continue if (!uses_pin(buf[i])) continue unrec[++nunrec] = lnum[i] @@ -330,8 +344,12 @@ apply_to_file() { LIST_RE = "^[[:space:]]*-([[:space:]]|$)" KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" - USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-fA-F]+" - USES_SITE_RE = "(^|[-{,[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*(&[^[:space:],}]+[[:space:]]+)?" Q "dtolnay/rust-toolchain@[0-9a-fA-F]+" + # Matched against the line in lower case. + USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-f]+" + USES_SITE_RE = "(^|[-{,[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*(&[^[:space:],}]+[[:space:]]+)?" Q "dtolnay/rust-toolchain@[0-9a-f]+" + # A `uses:` key whose value is not on its line: a block scalar + # (`uses: >-`) or nothing at all, the reference on the lines below. + USES_OPEN_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*(&[^[:space:],}]+[[:space:]]*)?([|>][-+0-9]*)?[[:space:]]*$" WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" @@ -387,7 +405,7 @@ apply_to_file() { END { flush() if (nunrec) { - printf "UNSUPPORTED %s: %d SHA-pinned dtolnay/rust-toolchain reference(s) the parser cannot place as a block-style step key (a flow-style `- { ... }` step?), line(s):", FILENAME, nunrec > "/dev/stderr" + printf "UNSUPPORTED %s: %d SHA-pinned dtolnay/rust-toolchain reference(s) the parser cannot place as a block-style step key (a flow-style `- { ... }` step, or a `uses:` value continued below its key?), line(s):", FILENAME, nunrec > "/dev/stderr" for (j = 1; j <= nunrec; j++) printf " %d", unrec[j] > "/dev/stderr" printf ". Fix by hand; the file was not changed.\n" > "/dev/stderr" exit 2 diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index a0c7c9c5..4e14bb57 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -181,7 +181,10 @@ lookup_replacement() { # # K line n is a `uses:` key of a job under `jobs:` — the one place a # reusable is called; # D line n is a `uses:` key under some other key (`env:`) — data; -# S line n is inside a block scalar (`run: |`, a fixture in `env:`) — text. +# S line n is inside a block scalar (`run: |`, a fixture in `env:`) — text; +# U line n continues the value of a job's `uses:` key that holds none on +# its own line (`uses: >-`, or `uses:` then the reference below): the +# call is there, but not where the rewrite reads it. # A line with none of these that still carries a known-dead pin is one the # parser cannot place (a flow-style job `rust: { uses: ... }`). yaml_context() { # @@ -195,10 +198,13 @@ yaml_context() { # KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" ITEM_KEY_RE = "^[[:space:]]*-[[:space:]]+" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" SCALAR_RE = ":[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$" + # A `uses:` key with no value on its line (a block scalar, or nothing). + OPEN_RE = "^[[:space:]]*" Q "uses" Q "[[:space:]]*:[[:space:]]*(&[^[:space:]]+[[:space:]]*)?([|>][-+0-9]*)?[[:space:]]*(#.*)?$" } { ind = indent_of($0) - if (in_scalar) { if (plain($0) && ind <= scalar_indent) in_scalar = 0; else { print "S" NR; next } } + if (in_scalar) { if (plain($0) && ind <= scalar_indent) in_scalar = 0; else { print (in_uses ? "U" : "S") NR; next } } + in_uses = 0 if (!plain($0)) next if ($0 ~ KEY_RE) { key = $0; sub(/^[[:space:]]*/, "", key); sub(/[[:space:]]*:.*$/, "", key); gsub(/["'"'"']/, "", key) @@ -206,7 +212,11 @@ yaml_context() { # if (key == "uses") { p = -1; for (k in keyat) if (k + 0 < ind && k + 0 > p) p = k + 0 g = -1; for (k in keyat) if (k + 0 < p && k + 0 > g) g = k + 0 - if (p >= 0 && g >= 0 && keyat[g] == "jobs") print "K" NR; else print "D" NR + if (p >= 0 && g >= 0 && keyat[g] == "jobs") { + print "K" NR + # The value continues below the key: its lines are the call. + if ($0 ~ OPEN_RE) { in_scalar = 1; scalar_indent = ind; in_uses = 1; keyat[ind] = key; next } + } else print "D" NR } keyat[ind] = key if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } @@ -330,6 +340,19 @@ while IFS= read -r -d '' wf; do fi done < <(mask_line "${numbered#*:}" | grep -oiE "$FALLBACK_USES_RE" | grep -oiE "$REUSABLE_REF_RE" || true) done < <(grep -niE "${REUSABLE_REF_RE}${FALLBACK_REF_END}" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) + # A job's `uses:` whose value continues below the key (`uses: >-`): the + # rewrite reads the reference on the key line, finds none, and would + # report the file clean. A known-dead pin on such a line is reported. + for lineno in $(grep -o '^U[0-9]*' <<<"$ctx" | tr -d U || true); do + while IFS= read -r ref; do + [ -n "$ref" ] || continue + sha="${ref##*@}" + if lookup_replacement "${ref%@*}" "${sha,,}"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin in a \`uses:\` value continued below its key (a block scalar such as \`uses: >-\`). Fix by hand; the file was not changed." >&2 + skip=1 + fi + done < <(mask_line "$(sed -n "${lineno}p" "$wf")" | grep -oiE "${REUSABLE_REF_RE}${FALLBACK_REF_END}" | grep -oiE "$REUSABLE_REF_RE" || true) + done if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() for lineno in $klines; do @@ -359,8 +382,11 @@ while IFS= read -r -d '' wf; do for entry in ${pending[@]+"${pending[@]}"}; do IFS=$'\t' read -r stale replacement repo_prefix <<<"$entry" keep=0 + # The repository matched literally and the SHA to its end, as the + # caller matcher does: `-patched` is another ref, not this one. + repo_re=$(printf '%s' "$repo_prefix" | sed -E 's/[][\.^$*+?(){}|~\\]/\\&/g') for other in $klines; do - sed -n "${other}p" "$wf" | grep -qiE "${USES_KEY_RE}${repo_prefix}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}" && keep=1 + sed -n "${other}p" "$wf" | grep -qiE "${USES_KEY_RE}${repo_re}/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@${stale}${REF_END}" && keep=1 done printf '%s\t%s\t%s\t%s\t%s\n' ".github/workflows/${wf##*/}" "$stale" "$replacement" "$keep" "$repo_prefix" >> "$edits" done diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 1a341fa3..ce35d429 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2752,6 +2752,100 @@ TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "mode: the self-SHA fixer's repaired lock keeps its 0640" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/mode-ls/.github/workflows/actions.lock' && [ \$(stat -c %a '$fx/mode-ls/.github/workflows/actions.lock') = 640 ]" +# tc23: GitHub resolves `Dtolnay/Rust-Toolchain` to the same action, so the +# step lacks its input all the same: it is repaired, its spelling kept. +mkdir -p "$fx/tc23/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: Dtolnay/Rust-Toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/tc23/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc23" +check "tc23 a SHA-pinned toolchain step spelled in another case is repaired, the spelling kept" \ + "[ \$rc -eq 0 ] && grep -q '^ - uses: Dtolnay/Rust-Toolchain@6c977a6' '$fx/tc23/.github/workflows/x.yml' && grep -q '^ toolchain: stable\$' '$fx/tc23/.github/workflows/x.yml'" + +# tc24 / tc25: a step's `uses:` whose value continues below the key (a +# folded scalar, or the reference alone on the next line) names its action +# where the parser does not read it: UNSUPPORTED, never "every step is fine". +mkdir -p "$fx/tc24/.github/workflows" "$fx/tc25/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: >-\n dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n - run: cargo check\n' > "$fx/tc24/.github/workflows/x.yml" +cp "$fx/tc24/.github/workflows/x.yml" "$fx/tc24-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc24" +check "tc24 a folded uses: >- toolchain pin is UNSUPPORTED: exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 5\\.' <<<\"\$out\" && cmp -s '$fx/tc24-before.yml' '$fx/tc24/.github/workflows/x.yml'" +printf 'jobs:\n check:\n steps:\n - name: tc\n uses:\n dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/tc25/.github/workflows/x.yml" +cp "$fx/tc25/.github/workflows/x.yml" "$fx/tc25-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc25" +check "tc25 a uses: whose reference sits on the next line is UNSUPPORTED: exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/tc25-before.yml' '$fx/tc25/.github/workflows/x.yml'" + +# tc26 (control): the same text inside a `run: |` script is the script's, +# and a real step after it is still repaired. +mkdir -p "$fx/tc26/.github/workflows" +printf 'jobs:\n check:\n steps:\n - run: |\n cat > fx.yml <-\n dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n EOF\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/tc26/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc26" +check "tc26 a folded uses: inside a run: | script is text: the real step is repaired, the script unchanged" \ + "[ \$rc -eq 0 ] && [ \$(grep -c 'toolchain: stable' '$fx/tc26/.github/workflows/x.yml') -eq 1 ] && grep -q '^ uses: >-\$' '$fx/tc26/.github/workflows/x.yml'" + +# sp35: a job's `uses:` written as a folded scalar holds the call on the +# line below: a dead pin there is UNSUPPORTED, never "OK" and left dead. +mkdir -p "$fx/sp35/.github/workflows" +printf 'jobs:\n rust:\n uses: >-\n %s@%s\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp35/.github/workflows/ci.yml" +cp "$fx/sp35/.github/workflows/ci.yml" "$fx/sp35-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp35" +check "sp35 a folded uses: >- dead reusable pin is UNSUPPORTED: exit 2, file untouched, never OK" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*ci.yml:4:.*continued below its key' <<<\"\$out\" && ! grep -q '^OK' <<<\"\$out\" && cmp -s '$fx/sp35-before.yml' '$fx/sp35/.github/workflows/ci.yml'" + +# sp36: a caller at `-patched` is another ref, not the stale SHA: it +# does not keep the stale lock edge next to the replacement. +mkdir -p "$fx/sp36/.github/workflows" +printf 'jobs:\n rust:\n uses: %s@%s\n other:\n uses: hyperpolymath/standards/.github/workflows/other.yml@%s-patched\n' "$RC_REUSABLE" "$STALE_A" "$STALE_A" > "$fx/sp36/.github/workflows/ci.yml" +lock_records "$fx/sp36" "hyperpolymath/standards@$STALE_A" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp36" +check "sp36 a -patched caller keeps no stale edge: the edge moves to the replacement" \ + "grep -q \"^ - 'hyperpolymath/standards@$GOOD'\$\" '$fx/sp36/.github/workflows/actions.lock' && ! grep -q \"standards@$STALE_A'\" '$fx/sp36/.github/workflows/actions.lock'" + +# ls29 / ls30: a `uses:` site whose value continues below the key cannot be +# classified, so the record it names would read `unknown` and a reusable's +# SHA ref be retagged as if it were an action's: the lock is left alone. +mkdir -p "$fx/ls29/.github/workflows" "$fx/ls30/.github/workflows" +cat > "$fx/ls29/.github/workflows/ci.yml" <- + foo/reus/.github/workflows/x.yml@$SHA_A +YML +lock_records "$fx/ls29" "foo/reus@$SHA_A" +printf 'foo/reus\t%s\tv1.2.3\n' "$SHA_A" > "$fx/ls29-tags.tsv" +cp "$fx/ls29/.github/workflows/actions.lock" "$fx/ls29-before.lock" +TAG_MAP="$fx/ls29-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls29" --offline +check "ls29 a folded job uses: is refused: exit 2, the reusable's SHA ref not retagged" \ + "[ \$rc -eq 2 ] && grep -q 'ci.yml:3 is a block scalar or continues' <<<\"\$out\" && cmp -s '$fx/ls29-before.lock' '$fx/ls29/.github/workflows/actions.lock'" +cat > "$fx/ls30/.github/workflows/ci.yml" < "$fx/ls31/.github/workflows/ci.yml" <- + foo/reus/.github/workflows/x.yml@$SHA_A + - uses: foo/bar@$SHA_B +YML +lock_records "$fx/ls31" "foo/bar@$SHA_B" +TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls31" --offline +check "ls31 a folded uses: inside run: | is text: the action's record still gets its tag" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls31/.github/workflows/actions.lock'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From 8e9d4cd2ecfdf05861872a527f1e29379a3bd1ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:16:19 +0000 Subject: [PATCH 31/44] fix(fix-scripts): uses: aliases resolved or refused, TOOLCHAIN validated before it is written Codex round on 6dda1e0. - fix-rust-toolchain-input.sh: anchors whose value is a SHA-pinned toolchain reference are noted wherever they are defined; a step's own `uses: *name` of one is UNSUPPORTED (exit 2, untouched) instead of emitted unchanged as fine. TOOLCHAIN must be a toolchain name (letters, digits, `.`, `_`, `-`), else exit 2 with nothing changed: a newline or YAML punctuation could add a key or end the value. A number YAML would read as a float (`1.80`) is written quoted. - fix-lock-self-sha-refs.sh: a `uses: *name` site (block or flow) is classified by its anchor's value (`&name owner/repo@ref`, recorded as the file is read), so a reusable called through an alias keeps its SHA ref; an alias of no anchor read is refused (exit 2, lock untouched). - fix-stale-reusable-pin.sh: a job's `uses: *name` whose anchor holds a known-dead pin is UNSUPPORTED instead of skipped and reported OK. Tests: 6 new contract cases (tc27-tc29, sp37, ls32, ls33), each failing on 6dda1e0. Suite: 236 + 91 passed under gawk and mawk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 17 +++++++++- scripts/fix-rust-toolchain-input.sh | 41 +++++++++++++++++++++-- scripts/fix-stale-reusable-pin.sh | 17 ++++++++++ scripts/tests/fix-workflow-pins.sh | 52 +++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 520d4078..55b85de3 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -93,10 +93,24 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A`), which + # YAML requires to come first; an alias of no anchor read here + # cannot be classified either. function site(kind, r) { + if (r ~ /^\*/) r = (substr(r, 2) in anchor) ? anchor[substr(r, 2)] : "" if (r == "" || r ~ /^#/ || r ~ /^[|>][-+0-9]*$/) print "F\t" FILENAME ":" FNR else print kind "\t" r } + # Every anchor on line s whose value is a reference (`&name a/b@ref`). + function note_anchors(s, t, a, v) { + t = s + while (match(t, /&[A-Za-z0-9_.-]+[[:space:]]+["'"'"']?[A-Za-z0-9_.-]+\/[^[:space:]"'"'"',}]*@[^[:space:]"'"'"',}]+/)) { + a = substr(t, RSTART + 1, RLENGTH - 1); t = substr(t, RSTART + RLENGTH) + v = a; sub(/^[^[:space:]]+[[:space:]]+["'"'"']?/, "", v); sub(/[[:space:]].*$/, "", a) + anchor[a] = v + } + } function nearest_below(ind, k, p) { p = -1; for (k in keyat) if (k + 0 < ind && k + 0 > p) p = k + 0; return p } # The line with its trailing comment dropped and every quoted # scalar that is neither a key nor the value of a `uses` key @@ -175,7 +189,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A 0) { ml = masked($0); facc = facc " " ml; fdepth += depth_of(ml) if (fdepth <= 0) { fdepth = 0; flow_emit(facc, fparent, fitem, fsteps) } diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 6f6a0394..ee393d76 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -42,7 +42,8 @@ # * a step written as a flow mapping (`- { name: x, uses: ... }`), a # one-line `steps: [ { uses: ... } ]`, a `uses:` whose value continues # below its key (`uses: >-`, or the reference alone on the next line), -# or a `with:` whose value this +# a `uses: *alias` of an anchored pinned reference, or a `with:` whose +# value this # parser does not read (`with: &anchor`, `with: *alias`) is NOT # rewritten: the script names the line as UNSUPPORTED and exits 2 rather # than report the file as clean. Fix it by hand. @@ -57,6 +58,8 @@ # fix-rust-toolchain-input.sh # fix-rust-toolchain-input.sh (every .github/workflows/*.yml) # TOOLCHAIN=1.85.0 fix-rust-toolchain-input.sh +# (a toolchain name only — letters, digits, `.`, `_`, `-`; anything else +# exits 2 having changed nothing) # # Pairs with Hypatia.Rules.WorkflowAudit.check_sha_pinned_toolchain_without_input/1 # (WF026) and recipe id `WF026` in fix-script-registry.json. @@ -64,6 +67,17 @@ set -euo pipefail TOOLCHAIN="${TOOLCHAIN:-stable}" +# The override is written into the workflow as a YAML value, so only a +# rustup toolchain name is accepted (`stable`, `1.85.0`, `nightly-2026-01-01`, +# `stable-x86_64-unknown-linux-gnu`): never text that could end the value, +# add a key or reach awk'"'"'s replacement syntax. A number YAML would read as a +# float (`1.80` is 1.8) is written quoted. +if [[ ! "$TOOLCHAIN" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + printf 'fix-rust-toolchain-input: TOOLCHAIN=%q is not a toolchain name (letters, digits, ., _, -); nothing was changed\n' "$TOOLCHAIN" >&2 + exit 2 +fi +TOOLCHAIN_YAML="$TOOLCHAIN" +if [[ "$TOOLCHAIN" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then TOOLCHAIN_YAML="'$TOOLCHAIN'"; fi TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh): @@ -93,7 +107,7 @@ apply_to_file() { tmp=$(mktemp) set +e - awk -v toolchain="$TOOLCHAIN" ' + awk -v toolchain="$TOOLCHAIN_YAML" ' # Return the number of leading spaces in source line s. function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } # Column of a key, with a list dash counted as indentation: ` - with:` -> 4. @@ -229,6 +243,14 @@ apply_to_file() { if (!flow_item && length(key_indent_of(buf[i])) == skey && tolower(site_view(buf[i])) ~ USES_OPEN_RE) for (j = i + 1; j <= n && !(plain(buf[j]) && indent_of(buf[j]) <= skey); j++) if (sha_ref(buf[j])) unrec[++nunrec] = lnum[j] + # The step'"'"'s own `uses:` given as an alias (`uses: *rust`) of an + # anchor that holds a SHA-pinned toolchain reference: the step + # runs that action, but the reference is not where the parser + # places a step'"'"'s action. Reported, never passed over. + if ((flow_item || length(key_indent_of(buf[i])) == skey) && match(tolower(site_view(buf[i])), USES_ALIAS_RE)) { + m = substr(tolower(site_view(buf[i])), RSTART, RLENGTH); sub(/^.*\*/, "", m) + if (m in tc_anchor) unrec[++nunrec] = lnum[i] + } if (!sha_ref(buf[i])) continue if (!flow_item && tolower(buf[i]) ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } if (!flow_item && i > 1 && indent_of(buf[i]) > skey) continue @@ -347,6 +369,8 @@ apply_to_file() { # Matched against the line in lower case. USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-f]+" USES_SITE_RE = "(^|[-{,[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*(&[^[:space:],}]+[[:space:]]+)?" Q "dtolnay/rust-toolchain@[0-9a-f]+" + # A `uses:` whose value is an alias, `*name` (matched in lower case). + USES_ALIAS_RE = "(^|[-{,[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*\\*[a-z0-9_.-]+" # A `uses:` key whose value is not on its line: a block scalar # (`uses: >-`) or nothing at all, the reference on the lines below. USES_OPEN_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*(&[^[:space:],}]+[[:space:]]*)?([|>][-+0-9]*)?[[:space:]]*$" @@ -372,6 +396,17 @@ apply_to_file() { } { ind = indent_of($0) + # Every anchor whose value is a SHA-pinned toolchain reference + # (`ACTION: &rust dtolnay/rust-toolchain@`), wherever it is + # defined: an alias of it is that reference. + if (plain($0)) { + t = $0 + while (match(t, /&[A-Za-z0-9_.-]+[[:space:]]+["'"'"']?[^[:space:]"'"'"',}]+/)) { + a = substr(t, RSTART + 1, RLENGTH - 1); t = substr(t, RSTART + RLENGTH) + v = a; sub(/^[^[:space:]]+[[:space:]]+["'"'"']?/, "", v); sub(/[[:space:]].*$/, "", a) + if (sha_ref(v)) tc_anchor[tolower(a)] = 1 + } + } # A block scalar outside any step (`FIXTURE: |` under `env:`) is text # until a plain line at its key'"'"'s indentation or shallower. if (!in_step && in_scalar) { @@ -405,7 +440,7 @@ apply_to_file() { END { flush() if (nunrec) { - printf "UNSUPPORTED %s: %d SHA-pinned dtolnay/rust-toolchain reference(s) the parser cannot place as a block-style step key (a flow-style `- { ... }` step, or a `uses:` value continued below its key?), line(s):", FILENAME, nunrec > "/dev/stderr" + printf "UNSUPPORTED %s: %d SHA-pinned dtolnay/rust-toolchain reference(s) the parser cannot place as a block-style step key (a flow-style `- { ... }` step, a `uses:` value continued below its key, or an alias of an anchored reference?), line(s):", FILENAME, nunrec > "/dev/stderr" for (j = 1; j <= nunrec; j++) printf " %d", unrec[j] > "/dev/stderr" printf ". Fix by hand; the file was not changed.\n" > "/dev/stderr" exit 2 diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 4e14bb57..7a7367a6 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -353,6 +353,23 @@ while IFS= read -r -d '' wf; do fi done < <(mask_line "$(sed -n "${lineno}p" "$wf")" | grep -oiE "${REUSABLE_REF_RE}${FALLBACK_REF_END}" | grep -oiE "$REUSABLE_REF_RE" || true) done + # A job's `uses:` given as an alias (`uses: *call`) runs the anchor's + # value, defined elsewhere in the file (`CALL: &call owner/repo/...@`): + # the rewrite reads no reference on the key line. A known-dead pin as + # that value is reported; the anchor is not rewritten from here. + for lineno in $klines; do + alias=$(sed -n "${lineno}p" "$wf" | grep -oE "^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*\*[A-Za-z0-9_.-]+" | sed 's/.*\*//' || true) + [ -n "$alias" ] || continue + alias_re=$(printf '%s' "$alias" | sed 's/\./\\./g') + while IFS= read -r ref; do + [ -n "$ref" ] || continue + sha="${ref##*@}" + if lookup_replacement "${ref%@*}" "${sha,,}"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` runs $ref, a known-dead pin held by the anchor \`&$alias\`. Fix by hand; the file was not changed." >&2 + skip=1 + fi + done < <(grep -oE "&${alias_re}[[:space:]]+[\"']?[^[:space:]\"',}]+" "$wf" | grep -oiE "${REUSABLE_REF_RE}" || true) + done if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() for lineno in $klines; do diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index ce35d429..dedd7656 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2846,6 +2846,58 @@ TAG_MAP="$fx/ls20-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls31 a folded uses: inside run: | is text: the action's record still gets its tag" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls31/.github/workflows/actions.lock'" +# tc27: an alias of an anchor that holds the SHA-pinned toolchain reference +# (`uses: *rust`) runs that action all the same: UNSUPPORTED, never "fine". +mkdir -p "$fx/tc27/.github/workflows" +printf 'env: { ACTION: &rust dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 }\njobs:\n check:\n steps:\n - uses: *rust\n' > "$fx/tc27/.github/workflows/x.yml" +cp "$fx/tc27/.github/workflows/x.yml" "$fx/tc27-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc27" +check "tc27 an aliased SHA-pinned toolchain step is UNSUPPORTED: exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 5\\.' <<<\"\$out\" && cmp -s '$fx/tc27-before.yml' '$fx/tc27/.github/workflows/x.yml'" + +# tc28 / tc29: TOOLCHAIN is written as a YAML value, so it must be a +# toolchain name; a number YAML would read as a float is written quoted. +mkdir -p "$fx/tc28/.github/workflows" "$fx/tc29/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/tc28/.github/workflows/x.yml" +cp "$fx/tc28/.github/workflows/x.yml" "$fx/tc28-before.yml" +cp "$fx/tc28/.github/workflows/x.yml" "$fx/tc29/.github/workflows/x.yml" +run_fix env TOOLCHAIN=$'stable\n timeout-minutes: 1' bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc28" +check "tc28 a TOOLCHAIN that is not a toolchain name is refused: exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'is not a toolchain name' <<<\"\$out\" && cmp -s '$fx/tc28-before.yml' '$fx/tc28/.github/workflows/x.yml'" +run_fix env TOOLCHAIN=1.80 bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc29" +check "tc29 TOOLCHAIN=1.80 is written quoted, never read as the float 1.8" \ + "[ \$rc -eq 0 ] && grep -q \"^ toolchain: '1.80'\$\" '$fx/tc29/.github/workflows/x.yml'" + +# sp37: a job calling `uses: *call`, the anchor holding a known-dead pin: +# UNSUPPORTED, never "OK" with the dead caller left in place. +mkdir -p "$fx/sp37/.github/workflows" +printf 'env: { CALL: &call %s@%s }\njobs:\n rust:\n uses: *call\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp37/.github/workflows/ci.yml" +cp "$fx/sp37/.github/workflows/ci.yml" "$fx/sp37-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp37" +check "sp37 an aliased dead reusable call is UNSUPPORTED: exit 2, file untouched, never OK" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*ci.yml:4:.*uses: \\*call' <<<\"\$out\" && ! grep -q '^OK' <<<\"\$out\" && cmp -s '$fx/sp37-before.yml' '$fx/sp37/.github/workflows/ci.yml'" + +# ls32 / ls33: an aliased job call is classified by its anchor's value, so a +# reusable called through `uses: *call` keeps its SHA ref; an alias with no +# anchor read before it cannot be classified and the lock is left alone. +mkdir -p "$fx/ls32/.github/workflows" "$fx/ls33/.github/workflows" +cat > "$fx/ls32/.github/workflows/ci.yml" < "$fx/ls33/.github/workflows/ci.yml" +lock_records "$fx/ls33" "foo/reus@$SHA_A" +cp "$fx/ls33/.github/workflows/actions.lock" "$fx/ls33-before.lock" +TAG_MAP="$fx/ls29-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls33" --offline +check "ls33 an alias of no anchor is refused: exit 2, lock untouched" \ + "[ \$rc -eq 2 ] && cmp -s '$fx/ls33-before.lock' '$fx/ls33/.github/workflows/actions.lock'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From 64c887cc2890e2bdf3079a06a4217547687184dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:29:04 +0000 Subject: [PATCH 32/44] fix(fix-scripts): one quote-aware anchor reader; TOOLCHAIN names YAML reads as non-strings are quoted Codex round on 8e9d4cd. - lib/yaml-anchors.awk (new): anchor_defs(line, defs), the anchors a line defines, read outside quoted scalars and comments, with quoted and plain values. The three fixers prepend it to their awk programs (POSIX awk, mawk-safe) instead of matching `&name` in raw text, so `&call` inside `NOTE: "fixture &call ..."` is text: - fix-lock-self-sha-refs.sh: such text no longer overwrites the real anchor, so `uses: *call` of a reusable keeps its SHA ref; a later real anchor of the same name still replaces an earlier one; - fix-stale-reusable-pin.sh: the alias resolves to the last anchor above it (block-scalar content skipped), so a quoted fixture naming a dead pin no longer makes a live alias UNSUPPORTED; - fix-rust-toolchain-input.sh: the same reader, block scalars skipped. - fix-rust-toolchain-input.sh: a TOOLCHAIN that YAML would read as anything but a string, a number (`1.80`, `0x1f`) or a null or boolean spelling (`null`, `true`, `no`, ...), is written single-quoted; `toolchain: null` was an empty input and every rerun reported it FIXED. Tests: 4 new contract cases (tc30, tc31, sp38, ls34), each failing on 8e9d4cd. Suite: 240 + 91 passed under gawk and mawk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 20 ++++++----- scripts/fix-rust-toolchain-input.sh | 36 ++++++++++++------- scripts/fix-stale-reusable-pin.sh | 25 +++++++++++-- scripts/lib/yaml-anchors.awk | 56 +++++++++++++++++++++++++++++ scripts/tests/fix-workflow-pins.sh | 36 +++++++++++++++++++ 5 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 scripts/lib/yaml-anchors.awk diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 55b85de3..d8a308e4 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -45,6 +45,8 @@ LOCK="$REPO_PATH/.github/workflows/actions.lock" [ -f "$LOCK" ] || { echo "OK (no actions.lock): $REPO_PATH"; exit 0; } # One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh). . "$SCRIPT_DIR/lib/pin-fixer-lock.sh" +# anchor_defs/2: the anchors a line defines, outside quoted scalars and comments. +ANCHORS_AWK=$(cat "$SCRIPT_DIR/lib/yaml-anchors.awk") pin_fixer_lock "$REPO_PATH" # Read tag names from stdin and print the most specific: more dots, then longer. @@ -83,7 +85,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A][-+0-9]*$/) print "F\t" FILENAME ":" FNR else print kind "\t" r } - # Every anchor on line s whose value is a reference (`&name a/b@ref`). - function note_anchors(s, t, a, v) { - t = s - while (match(t, /&[A-Za-z0-9_.-]+[[:space:]]+["'"'"']?[A-Za-z0-9_.-]+\/[^[:space:]"'"'"',}]*@[^[:space:]"'"'"',}]+/)) { - a = substr(t, RSTART + 1, RLENGTH - 1); t = substr(t, RSTART + RLENGTH) - v = a; sub(/^[^[:space:]]+[[:space:]]+["'"'"']?/, "", v); sub(/[[:space:]].*$/, "", a) - anchor[a] = v + # Every anchor line s defines (lib/yaml-anchors.awk: outside quoted + # scalars and comments), kept when its value is a reference + # (`&name a/b@ref`); a later anchor of the same name replaces it, + # as it does for the aliases after it. + function note_anchors(s, d, k) { + anchor_defs(s, d) + for (k in d) { + if (d[k] ~ /^[A-Za-z0-9_.-]+\/[^@[:space:]]*@[^[:space:]]+$/) anchor[k] = d[k] + else delete anchor[k] } } function nearest_below(ind, k, p) { p = -1; for (k in keyat) if (k + 0 < ind && k + 0 > p) p = k + 0; return p } diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index ee393d76..3b100c5e 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -70,14 +70,20 @@ TOOLCHAIN="${TOOLCHAIN:-stable}" # The override is written into the workflow as a YAML value, so only a # rustup toolchain name is accepted (`stable`, `1.85.0`, `nightly-2026-01-01`, # `stable-x86_64-unknown-linux-gnu`): never text that could end the value, -# add a key or reach awk'"'"'s replacement syntax. A number YAML would read as a -# float (`1.80` is 1.8) is written quoted. +# add a key or reach awk's replacement syntax. A name YAML would read as +# anything but a string is written single-quoted: a number (`1.80` is the +# float 1.8, `0x1f` an integer) or a null or boolean spelling (`null`, `true`, +# `no`, ... as YAML 1.1 and the core schema read them), which would otherwise +# leave the input empty or wrong. if [[ ! "$TOOLCHAIN" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then printf 'fix-rust-toolchain-input: TOOLCHAIN=%q is not a toolchain name (letters, digits, ., _, -); nothing was changed\n' "$TOOLCHAIN" >&2 exit 2 fi TOOLCHAIN_YAML="$TOOLCHAIN" -if [[ "$TOOLCHAIN" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then TOOLCHAIN_YAML="'$TOOLCHAIN'"; fi +if [[ "$TOOLCHAIN" =~ ^([0-9][0-9_]*(\.[0-9_]*)?([eE][-+]?[0-9]+)?|0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+)$ ]] \ + || [[ "${TOOLCHAIN,,}" =~ ^(null|true|false|yes|no|on|off|y|n)$ ]]; then + TOOLCHAIN_YAML="'$TOOLCHAIN'" +fi TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh): @@ -85,6 +91,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # the file'"'"'s directory (`/.github/workflows/`, relative or # absolute), so every fixer on that checkout contends for the same lock. . "$SCRIPT_DIR/lib/pin-fixer-lock.sh" +# anchor_defs/2: the anchors a line defines, outside quoted scalars and comments. +ANCHORS_AWK=$(cat "$SCRIPT_DIR/lib/yaml-anchors.awk") if [ -d "$TARGET" ]; then pin_fixer_lock "$TARGET" else @@ -107,7 +115,7 @@ apply_to_file() { tmp=$(mktemp) set +e - awk -v toolchain="$TOOLCHAIN_YAML" ' + awk -v toolchain="$TOOLCHAIN_YAML" "$ANCHORS_AWK"' # Return the number of leading spaces in source line s. function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } # Column of a key, with a list dash counted as indentation: ` - with:` -> 4. @@ -361,7 +369,7 @@ apply_to_file() { n = 0 } BEGIN { - n = 0; in_step = 0; in_scalar = 0; fixed = 0; nunrec = 0 + n = 0; in_step = 0; in_scalar = 0; fixed = 0; nunrec = 0; anc_scalar = -1 Q = "[\"'"'"']?" LIST_RE = "^[[:space:]]*-([[:space:]]|$)" KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" @@ -398,13 +406,17 @@ apply_to_file() { ind = indent_of($0) # Every anchor whose value is a SHA-pinned toolchain reference # (`ACTION: &rust dtolnay/rust-toolchain@`), wherever it is - # defined: an alias of it is that reference. - if (plain($0)) { - t = $0 - while (match(t, /&[A-Za-z0-9_.-]+[[:space:]]+["'"'"']?[^[:space:]"'"'"',}]+/)) { - a = substr(t, RSTART + 1, RLENGTH - 1); t = substr(t, RSTART + RLENGTH) - v = a; sub(/^[^[:space:]]+[[:space:]]+["'"'"']?/, "", v); sub(/[[:space:]].*$/, "", a) - if (sha_ref(v)) tc_anchor[tolower(a)] = 1 + # defined: an alias of it is that reference. Anchors are read by + # lib/yaml-anchors.awk (outside quoted scalars and comments) and + # never from a block scalar'"'"'s content (`run: |` text); a later + # anchor of the same name replaces an earlier one. + if (anc_scalar >= 0 && (!plain($0) || ind > anc_scalar)) { } + else { + anc_scalar = -1 + if (plain($0)) { + anchor_defs($0, d) + for (a in d) { if (sha_ref(d[a])) tc_anchor[tolower(a)] = 1; else delete tc_anchor[tolower(a)] } + if ($0 ~ SCALAR_RE) anc_scalar = length(key_indent_of($0)) } } # A block scalar outside any step (`FIXTURE: |` under `env:`) is text diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 7a7367a6..eba50ed0 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -79,6 +79,28 @@ validate_table [ -d "$REPO_PATH/.github/workflows" ] || { echo "OK (no workflows): $REPO_PATH"; exit 0; } # One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh). . "$SCRIPT_DIR/lib/pin-fixer-lock.sh" +# anchor_defs/2: the anchors a line defines, outside quoted scalars and comments. +ANCHORS_AWK=$(cat "$SCRIPT_DIR/lib/yaml-anchors.awk") + +# The value of anchor as an alias on line of sees it: the +# last `&name` defined above that line (lib/yaml-anchors.awk reads anchors +# outside quoted scalars and comments, and a block scalar's content is +# skipped as text), or "" when there is none. +anchor_value() { # + awk -v want="$2" -v upto="$3" "$ANCHORS_AWK"' + function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } + BEGIN { blk = -1; got = "" } + NR >= upto { exit } + { + plain = ($0 !~ /^[[:space:]]*$/ && $0 !~ /^[[:space:]]*#/) + if (blk >= 0 && (!plain || indent_of($0) > blk)) next + blk = -1 + if (!plain) next + if (anchor_defs($0, d) && (want in d)) got = d[want] + if ($0 ~ /:[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$/) { t = $0; sub(/[^[:space:]-].*$/, "", t); blk = length(t) } + } + END { print got }' "$1" +} pin_fixer_lock "$REPO_PATH" LOCK="$REPO_PATH/.github/workflows/actions.lock" @@ -360,7 +382,6 @@ while IFS= read -r -d '' wf; do for lineno in $klines; do alias=$(sed -n "${lineno}p" "$wf" | grep -oE "^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*\*[A-Za-z0-9_.-]+" | sed 's/.*\*//' || true) [ -n "$alias" ] || continue - alias_re=$(printf '%s' "$alias" | sed 's/\./\\./g') while IFS= read -r ref; do [ -n "$ref" ] || continue sha="${ref##*@}" @@ -368,7 +389,7 @@ while IFS= read -r -d '' wf; do echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` runs $ref, a known-dead pin held by the anchor \`&$alias\`. Fix by hand; the file was not changed." >&2 skip=1 fi - done < <(grep -oE "&${alias_re}[[:space:]]+[\"']?[^[:space:]\"',}]+" "$wf" | grep -oiE "${REUSABLE_REF_RE}" || true) + done < <(anchor_value "$wf" "$alias" "$lineno" | grep -oiE "^${REUSABLE_REF_RE}\$" || true) done if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() diff --git a/scripts/lib/yaml-anchors.awk b/scripts/lib/yaml-anchors.awk new file mode 100644 index 00000000..f9cdfffc --- /dev/null +++ b/scripts/lib/yaml-anchors.awk @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: MPL-2.0 +# yaml-anchors.awk — the YAML anchors one line of a workflow defines. +# +# Shared by the workflow-pin fixers, which prepend this file to their own awk +# program (`awk "$(cat lib/yaml-anchors.awk)"''`); POSIX awk only, so +# mawk reads it as gawk does. +# +# anchor_defs(s, defs) clears defs and sets defs[name] = value for every +# `&name value` on line s, and returns how many there are. An anchor is +# `&name` at the start of a token outside any quoted scalar and before any +# comment: `&call` written inside `NOTE: "fixture &call a/b@"` or after a +# `#` is text, not an anchor. Its value is the scalar after it: a quoted one +# read to its closing quote (`\` escapes inside double quotes, `''` inside +# single ones), a plain one up to whitespace, `,`, `}`, `]` or a comment; "" +# when the anchor names a mapping or sequence that follows. The caller skips +# the content lines of block scalars (`run: |`), which are text throughout. +function anchor_defs(s, defs, n, i, len, c, q, name, v) { + split("", defs); n = 0; i = 1; q = ""; len = length(s) + while (i <= len) { + c = substr(s, i, 1) + if (q != "") { + if (q == "\"" && c == "\\") { i += 2; continue } + if (c == q) { + if (q == "'" && substr(s, i + 1, 1) == "'") { i += 2; continue } + q = "" + } + i++; continue + } + if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break + if (c == "\"" || c == "'") { q = c; i++; continue } + if (c == "&" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]:,{[-]/) && match(substr(s, i + 1), /^[A-Za-z0-9_.-]+/)) { + name = substr(s, i + 1, RLENGTH); i += RLENGTH + 1 + while (i <= len && substr(s, i, 1) ~ /[[:space:]]/) i++ + c = substr(s, i, 1); v = "" + if (c == "\"" || c == "'") { + q = c; i++ + while (i <= len) { + c = substr(s, i, 1) + if (q == "\"" && c == "\\") { v = v substr(s, i, 2); i += 2; continue } + if (c == q) { + if (q == "'" && substr(s, i + 1, 1) == "'") { v = v "'"; i += 2; continue } + break + } + v = v c; i++ + } + q = ""; i++ + } else { + while (i <= len && substr(s, i, 1) !~ /[],}[:space:]]/) { v = v substr(s, i, 1); i++ } + } + defs[name] = v; n++ + continue + } + i++ + } + return n +} diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index dedd7656..009b9f65 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2898,6 +2898,42 @@ TAG_MAP="$fx/ls29-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls33 an alias of no anchor is refused: exit 2, lock untouched" \ "[ \$rc -eq 2 ] && cmp -s '$fx/ls33-before.lock' '$fx/ls33/.github/workflows/actions.lock'" +# tc30: TOOLCHAIN=null is a name YAML reads as null: written quoted, so the +# input is the string, and a second run finds it present (no FIXED again). +mkdir -p "$fx/tc30/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/tc30/.github/workflows/x.yml" +run_fix env TOOLCHAIN=null bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc30" +first_rc=$rc +run_fix env TOOLCHAIN=null bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc30" +check "tc30 TOOLCHAIN=null is written quoted, and a rerun repairs nothing" \ + "[ $first_rc -eq 0 ] && [ \$rc -eq 0 ] && grep -q \"^ toolchain: 'null'\$\" '$fx/tc30/.github/workflows/x.yml' && ! grep -q '^FIXED' <<<\"\$out\"" + +# tc31 / sp38 / ls34: anchor-like text inside a quoted scalar is text, not +# the anchor an alias resolves to (lib/yaml-anchors.awk). +mkdir -p "$fx/tc31/.github/workflows" "$fx/sp38/.github/workflows" "$fx/ls34/.github/workflows" +printf 'env:\n ACTION: &rust dtolnay/rust-toolchain@stable\n NOTE: "fixture &rust dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772"\njobs:\n check:\n steps:\n - uses: *rust\n' > "$fx/tc31/.github/workflows/x.yml" +cp "$fx/tc31/.github/workflows/x.yml" "$fx/tc31-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc31" +check "tc31 a quoted &rust is no anchor: the alias of a tag-pinned anchor is fine, not UNSUPPORTED" \ + "[ \$rc -eq 0 ] && ! grep -q UNSUPPORTED <<<\"\$out\" && cmp -s '$fx/tc31-before.yml' '$fx/tc31/.github/workflows/x.yml'" +printf 'env:\n CALL: &call %s@%s\n NOTE: "fixture &call %s@%s"\njobs:\n rust:\n uses: *call\n' "$RC_REUSABLE" "$GOOD" "$RC_REUSABLE" "$STALE_A" > "$fx/sp38/.github/workflows/ci.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp38" +check "sp38 a quoted &call naming a dead pin is no anchor: the live alias is not UNSUPPORTED" \ + "[ \$rc -eq 0 ] && ! grep -q UNSUPPORTED <<<\"\$out\"" +cat > "$fx/ls34/.github/workflows/ci.yml" < "$fx/ls34-tags.tsv" +TAG_MAP="$fx/ls34-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls34" --offline +check "ls34 a quoted &call does not replace the real anchor: the aliased reusable keeps its SHA ref" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: '$SHA_A'\$\" '$fx/ls34/.github/workflows/actions.lock'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From c9754637972f33a2aa71b93d80a178837fe7a8b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:41:08 +0000 Subject: [PATCH 33/44] fix(fix-scripts): anchors only where a node starts, case kept; commented uses: list header; non-string tags quoted Codex round on 64c887c. - lib/yaml-anchors.awk: `&name` is an anchor only where a YAML node can start (line start or after list dashes, after `key: `, after `[`, `{` or `,` inside a flow collection, past any tag), so `&call` inside a plain scalar (`NOTE: fixture &call ...`) is text and no longer overwrites a real anchor for the self-SHA fixer. - fix-rust-toolchain-input.sh: anchor names keep their case (`&Rust` is not `&rust`); an alias of no anchor read, in a file that carries a SHA-pinned toolchain reference, is UNSUPPORTED instead of passed over. - fix-stale-reusable-pin.sh: the record merger takes `uses: # comment` as the nested list header (the first spelling, comment kept) and collects entries only inside a list it opened, so a merged record no longer gets a second `uses:` key. - lib/pin-fixer-lock.sh: pin_fixer_yaml_nonstring, the one rule for a value YAML would read as a number, null or boolean; TOOLCHAIN uses it, and fix-lock-self-sha-refs.sh now quotes such a tag (`true`, `1`, `null`) written into a plain `ref:` field. Tests: 5 new contract cases; tc32, sp39, ls35 and ls36 fail on 64c887c, tc33 guards the stricter anchor reader. Suite: 245 + 91 passed under gawk and mawk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 6 ++- scripts/fix-rust-toolchain-input.sh | 16 +++---- scripts/fix-stale-reusable-pin.sh | 8 ++-- scripts/lib/pin-fixer-lock.sh | 10 +++++ scripts/lib/yaml-anchors.awk | 33 ++++++++++----- scripts/tests/fix-workflow-pins.sh | 65 +++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 22 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index d8a308e4..fe2d76f5 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -381,7 +381,11 @@ while IFS=$'\t' read -r lineno repo sha ref_raw; do # lineno is the ref: line itself; its quote style (or none) is kept, and # the SHA is matched as that line spells it (its case may differ from the # key's). - sed -i "${lineno}s|^\( ['\"]\{0,1\}ref['\"]\{0,1\}[[:space:]]*:[[:space:]]*\)\(['\"]\{0,1\}\)$ref_raw\2|\1\2$tag\2|" "$tmp" + # A plain field whose tag YAML would read as a non-string (`true`, `1`, + # `null`) is written quoted, so the lock keeps a tag, not a boolean. + q="" + if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "^ [\"']?ref[\"']?[[:space:]]*:[[:space:]]*[\"']"; then q="'"; fi + sed -i "${lineno}s|^\( ['\"]\{0,1\}ref['\"]\{0,1\}[[:space:]]*:[[:space:]]*\)\(['\"]\{0,1\}\)$ref_raw\2|\1\2$q$tag$q\2|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 3b100c5e..d141d6dc 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -79,11 +79,6 @@ if [[ ! "$TOOLCHAIN" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then printf 'fix-rust-toolchain-input: TOOLCHAIN=%q is not a toolchain name (letters, digits, ., _, -); nothing was changed\n' "$TOOLCHAIN" >&2 exit 2 fi -TOOLCHAIN_YAML="$TOOLCHAIN" -if [[ "$TOOLCHAIN" =~ ^([0-9][0-9_]*(\.[0-9_]*)?([eE][-+]?[0-9]+)?|0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+)$ ]] \ - || [[ "${TOOLCHAIN,,}" =~ ^(null|true|false|yes|no|on|off|y|n)$ ]]; then - TOOLCHAIN_YAML="'$TOOLCHAIN'" -fi TARGET="${1:?Usage: fix-rust-toolchain-input.sh }" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # One workflow-pin fixer per checkout at a time (see lib/pin-fixer-lock.sh): @@ -91,6 +86,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # the file'"'"'s directory (`/.github/workflows/`, relative or # absolute), so every fixer on that checkout contends for the same lock. . "$SCRIPT_DIR/lib/pin-fixer-lock.sh" +TOOLCHAIN_YAML="$TOOLCHAIN" +if pin_fixer_yaml_nonstring "$TOOLCHAIN"; then TOOLCHAIN_YAML="'$TOOLCHAIN'"; fi # anchor_defs/2: the anchors a line defines, outside quoted scalars and comments. ANCHORS_AWK=$(cat "$SCRIPT_DIR/lib/yaml-anchors.awk") if [ -d "$TARGET" ]; then @@ -255,9 +252,12 @@ apply_to_file() { # anchor that holds a SHA-pinned toolchain reference: the step # runs that action, but the reference is not where the parser # places a step'"'"'s action. Reported, never passed over. + # An alias of no anchor read here could be that reference too. + # Anchor names are case-sensitive: matched in lower case for + # the key, the name is taken as written. if ((flow_item || length(key_indent_of(buf[i])) == skey) && match(tolower(site_view(buf[i])), USES_ALIAS_RE)) { - m = substr(tolower(site_view(buf[i])), RSTART, RLENGTH); sub(/^.*\*/, "", m) - if (m in tc_anchor) unrec[++nunrec] = lnum[i] + m = substr(site_view(buf[i]), RSTART, RLENGTH); sub(/^.*\*/, "", m) + if ((m in tc_anchor) || !(m in any_anchor)) unrec[++nunrec] = lnum[i] } if (!sha_ref(buf[i])) continue if (!flow_item && tolower(buf[i]) ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } @@ -415,7 +415,7 @@ apply_to_file() { anc_scalar = -1 if (plain($0)) { anchor_defs($0, d) - for (a in d) { if (sha_ref(d[a])) tc_anchor[tolower(a)] = 1; else delete tc_anchor[tolower(a)] } + for (a in d) { any_anchor[a] = 1; if (sha_ref(d[a])) tc_anchor[a] = 1; else delete tc_anchor[a] } if ($0 ~ SCALAR_RE) anc_scalar = length(key_indent_of($0)) } } diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index eba50ed0..c7aefe48 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -125,7 +125,7 @@ merge_duplicate_records() { n = 0 for (u in uses_of) if (index(u, cur SUBSEP) == 1) n++ if (n > 0 || nextra[cur] > 0) { - print " uses:" + print (cur in uses_head ? uses_head[cur] : " uses:") fflush() cmd = "sort" for (u in uses_of) if (index(u, cur SUBSEP) == 1) print " - " uses_of[u] | cmd @@ -157,8 +157,10 @@ merge_duplicate_records() { if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } cur = key; next } - /^ uses:[[:space:]]*$/ { in_uses = 1; next } - uses_entry($0) { u = $0; sub(/^ - /, "", u); k = cur SUBSEP entry_value($0); if (!(k in uses_of)) uses_of[k] = u; next } + # The nested list header, with or without a trailing comment (kept as + # first spelled); only lines inside a list it opens are its entries. + /^ uses:[[:space:]]*(#.*)?$/ { in_uses = 1; if (!(cur in uses_head)) uses_head[cur] = $0; next } + in_uses && uses_entry($0) { u = $0; sub(/^ - /, "", u); k = cur SUBSEP entry_value($0); if (!(k in uses_of)) uses_of[k] = u; next } # Any other line inside the list (a comment, a shape this merger has # no rule for) stays under the `uses:` header it came from: moved # above it, it would be a child with no parent key. diff --git a/scripts/lib/pin-fixer-lock.sh b/scripts/lib/pin-fixer-lock.sh index 238e0bcd..79c6d9e3 100644 --- a/scripts/lib/pin-fixer-lock.sh +++ b/scripts/lib/pin-fixer-lock.sh @@ -62,3 +62,13 @@ pin_fixer_replace() { # chown --reference="$2" "$1" 2>/dev/null || true mv -f "$1" "$2" } + +# Would YAML read , written as a plain (unquoted) scalar, as anything +# but a string? A number (`1`, `1.80` the float 1.8, `1e3`, `0x1f`, `0o17`, +# `0b1`) or a null or boolean spelling as YAML 1.1 and the core schema read +# them (`null`, `true`, `no`, `on`, `y`, ... in any case). A fixer writing +# such a value into a workflow or lock quotes it. +pin_fixer_yaml_nonstring() { # + [[ "$1" =~ ^([0-9][0-9_]*(\.[0-9_]*)?([eE][-+]?[0-9]+)?|0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+)$ ]] \ + || [[ "${1,,}" =~ ^(null|true|false|yes|no|on|off|y|n)$ ]] +} diff --git a/scripts/lib/yaml-anchors.awk b/scripts/lib/yaml-anchors.awk index f9cdfffc..a12169ff 100644 --- a/scripts/lib/yaml-anchors.awk +++ b/scripts/lib/yaml-anchors.awk @@ -7,28 +7,33 @@ # # anchor_defs(s, defs) clears defs and sets defs[name] = value for every # `&name value` on line s, and returns how many there are. An anchor is -# `&name` at the start of a token outside any quoted scalar and before any -# comment: `&call` written inside `NOTE: "fixture &call a/b@"` or after a -# `#` is text, not an anchor. Its value is the scalar after it: a quoted one +# `&name` where a YAML node starts, outside any quoted scalar and before any +# comment: at the line's start or after its list dashes, after `key: `, or +# inside a flow collection after `[`, `{` or `,`, past any tag (`!!map &x`). +# `&call` written inside `NOTE: "fixture &call a/b@"`, inside a plain +# scalar (`NOTE: fixture &call a/b@`) or after a `#` is text, not an +# anchor. Its value is the scalar after it: a quoted one # read to its closing quote (`\` escapes inside double quotes, `''` inside # single ones), a plain one up to whitespace, `,`, `}`, `]` or a comment; "" # when the anchor names a mapping or sequence that follows. The caller skips # the content lines of block scalars (`run: |`), which are text throughout. -function anchor_defs(s, defs, n, i, len, c, q, name, v) { - split("", defs); n = 0; i = 1; q = ""; len = length(s) +function anchor_defs(s, defs, n, i, len, c, q, name, v, seen, depth) { + split("", defs); n = 0; i = 1; q = ""; len = length(s); seen = ""; depth = 0 while (i <= len) { c = substr(s, i, 1) if (q != "") { if (q == "\"" && c == "\\") { i += 2; continue } if (c == q) { if (q == "'" && substr(s, i + 1, 1) == "'") { i += 2; continue } - q = "" + q = ""; seen = seen "x" } i++; continue } if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break - if (c == "\"" || c == "'") { q = c; i++; continue } - if (c == "&" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]:,{[-]/) && match(substr(s, i + 1), /^[A-Za-z0-9_.-]+/)) { + if (c == "\"" || c == "'") { q = c; seen = seen "x"; i++; continue } + if (c == "{" || c == "[") depth++ + else if ((c == "}" || c == "]") && depth > 0) depth-- + if (c == "&" && anchor_at(seen, depth) && match(substr(s, i + 1), /^[A-Za-z0-9_.-]+/)) { name = substr(s, i + 1, RLENGTH); i += RLENGTH + 1 while (i <= len && substr(s, i, 1) ~ /[[:space:]]/) i++ c = substr(s, i, 1); v = "" @@ -47,10 +52,18 @@ function anchor_defs(s, defs, n, i, len, c, q, name, v) { } else { while (i <= len && substr(s, i, 1) !~ /[],}[:space:]]/) { v = v substr(s, i, 1); i++ } } - defs[name] = v; n++ + defs[name] = v; n++; seen = seen "x" continue } - i++ + seen = seen c; i++ } return n } + +# Can a node start after `seen` (the line so far, quoted scalars as `x`), at +# flow depth `depth`? At the line's start or after its list dashes, after +# `key: `, or inside a flow collection after `[`, `{` or `,`; past any tag. +function anchor_at(seen, depth) { + if (seen ~ /(^[[:space:]]*(-[[:space:]]+)*|:[[:space:]]+)(![^[:space:]]*[[:space:]]+)*$/) return 1 + return depth > 0 && seen ~ /[{,[][[:space:]]*(![^[:space:]]*[[:space:]]+)*$/ +} diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 009b9f65..c9d2b596 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2934,6 +2934,71 @@ TAG_MAP="$fx/ls34-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls34 a quoted &call does not replace the real anchor: the aliased reusable keeps its SHA ref" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: '$SHA_A'\$\" '$fx/ls34/.github/workflows/actions.lock'" +# tc32 / tc33: anchor names are case-sensitive (`&Rust` is not `&rust`), and +# an alias of no anchor read could be the pinned toolchain: UNSUPPORTED. +mkdir -p "$fx/tc32/.github/workflows" "$fx/tc33/.github/workflows" +printf 'env:\n A: &Rust dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n B: &rust dtolnay/rust-toolchain@stable\njobs:\n check:\n steps:\n - uses: *Rust\n' > "$fx/tc32/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc32" +check "tc32 *Rust is the SHA-pinned &Rust, not the tag-pinned &rust: UNSUPPORTED" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 7\\.' <<<\"\$out\"" +# The anchor sits mid-line in a flow sequence continued from the line +# above, where the line-local reader does not take `&` for an anchor; the +# file does carry the pinned reference, so the unread alias is reported. +printf 'x-list: [a,\n b, &rust dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772]\njobs:\n check:\n steps:\n - uses: *rust\n' > "$fx/tc33/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc33" +check "tc33 an alias of no anchor read, in a file carrying a SHA-pinned toolchain ref, is UNSUPPORTED" \ + "[ \$rc -eq 2 ] && grep -q UNSUPPORTED <<<\"\$out\"" + +# sp39: a nested `uses: # rationale` header is the list header: two records +# merging onto the replacement leave one header, its comment kept. +mkdir -p "$fx/sp39/.github/workflows" +printf 'jobs:\n governance:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STALE_B" > "$fx/sp39/.github/workflows/governance.yml" +printf 'jobs:\n scorecard:\n uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@%s\n' "$STALE_C" > "$fx/sp39/.github/workflows/scorecard.yml" +cat > "$fx/sp39/.github/workflows/actions.lock" </dev/null 2>&1 || true +check "sp39 a commented nested uses: header is the one header of the merged record" \ + "[ \$(grep -c '^ uses:' '$fx/sp39/.github/workflows/actions.lock') -eq 1 ] && grep -q '^ uses: # transitive actions of the reusable\$' '$fx/sp39/.github/workflows/actions.lock' && grep -q 'ossf/scorecard-action' '$fx/sp39/.github/workflows/actions.lock'" + +# ls35 / ls36: `&call` inside a plain scalar is text; a tag YAML would read +# as a non-string is written quoted into a plain ref field. +mkdir -p "$fx/ls35/.github/workflows" "$fx/ls36/.github/workflows" +cat > "$fx/ls35/.github/workflows/ci.yml" < "$fx/ls36/.github/workflows/ci.yml" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s':\n ref: %s\n commit: 'sha1-%s'\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls36/.github/workflows/actions.lock" +printf 'foo/bar\t%s\ttrue\n' "$SHA_B" > "$fx/ls36-tags.tsv" +TAG_MAP="$fx/ls36-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls36" --offline +check "ls36 a tag YAML reads as a boolean is written quoted into a plain ref field" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'true'\$\" '$fx/ls36/.github/workflows/actions.lock'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From ccfc924dfeb65d6dc42583e8d77b1642c52cbc6f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:03:07 +0000 Subject: [PATCH 34/44] fix(fix-scripts): flow-style nested uses: lists merge to one key; no flock falls back to a mkdir lock merge_duplicate_records read only block-style nested `uses:` lists. A record spelled `uses: []` kept that line as a header field and then got a second, block `uses:` key when a duplicate's entries were folded in; a flow list on the duplicate was dropped with the duplicate. One-line flow lists now merge like block lists, and a merged record has one `uses:` key (a lone flow list nothing was merged into is kept as written). A duplicated record whose `uses:` the merger cannot read (multi-line flow, alias, scalar) is not folded: it is named UNSUPPORTED and the run exits 2. pin_fixer_lock proceeded unlocked when flock was missing. It now takes an atomic mkdir lock on the same key instead, removed by the holder's EXIT trap; a held one is waited for, reported with its recorded pid, and the fixer exits 2 having changed nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-stale-reusable-pin.sh | 68 ++++++++++++++++++++++++++---- scripts/lib/pin-fixer-lock.sh | 27 +++++++++++- scripts/tests/fix-workflow-pins.sh | 63 +++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 9 deletions(-) diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index c7aefe48..f0f6cc67 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -28,9 +28,10 @@ # Idempotent. Rewrites nothing it does not recognise. Exit 0 whether or not # anything changed; exit 2 when the table is unreadable or a file carries a # known-dead pin the parser cannot place as a `uses` key (that file is left -# untouched and named); exit 3 when the pins were rewritten but actions.lock -# was touched and still needs `gh actions-lock` — a dispatcher must not -# record either as a clean success. +# untouched and named), or when actions.lock is left listing a dependency +# twice that the record merger cannot fold (named); exit 3 when the pins were +# rewritten but actions.lock was touched and still needs `gh actions-lock` — +# a dispatcher must not record either as a clean success. # # Usage: # fix-stale-reusable-pin.sh @@ -112,10 +113,19 @@ rewrites=0; lock_touched=0 # others' nested `uses:` into it (deduplicated, sorted as gh actions-lock sorts), # and drop them. Rewrite lockfile $1 in place, preserving gh-actions-lock's # 4-space layout and leaving the file alone when the output is identical. +# +# The nested list may be written flow-style on one line (`uses: []`, +# `uses: ['a@x', 'b@y']`); its entries merge like a block list's, and a +# record keeps ONE `uses:` key — the block form when lists were merged, the +# line as written when there was only the one. A record listed twice whose +# `uses:` is a shape this merger cannot read (a flow list over several lines, +# an alias, a scalar) is not folded at all: merging it could drop its entries +# or leave two `uses:` keys. The merger then changes nothing, names the record +# (UNSUPPORTED) and sets merge_refused. merge_duplicate_records() { local lock="$1" tmp tmp=$(mktemp) - awk ' + if ! awk -v sq="'" ' # Print dependency cur using its saved header and sorted, unique uses. function emit_record( i, n, out) { if (!cur) return @@ -124,14 +134,33 @@ merge_duplicate_records() { for (i = 1; i <= nhead[cur]; i++) print head[cur, i] n = 0 for (u in uses_of) if (index(u, cur SUBSEP) == 1) n++ + # One flow list and nothing merged into it: kept as written. + if (nlists[cur] == 1 && (cur in flow_line) && nextra[cur] == 0) { print flow_line[cur]; return } if (n > 0 || nextra[cur] > 0) { - print (cur in uses_head ? uses_head[cur] : " uses:") + print (cur in uses_head ? uses_head[cur] : " uses:" flow_cmt[cur]) fflush() cmd = "sort" for (u in uses_of) if (index(u, cur SUBSEP) == 1) print " - " uses_of[u] | cmd close(cmd) for (i = 1; i <= nextra[cur]; i++) print uses_extra[cur, i] + } else if (cur in flow_line) print flow_line[cur] + } + # A one-line flow list: its items, each a quoted or plain scalar (sq is + # the single quote), go into uses_of as block entries would. 0, with + # nothing stored, when an item is any other shape — a nested + # collection, a key, an embedded quote. + function flow_items(s, body, n, parts, i, it, k) { + body = s; sub(/^ uses:[[:space:]]*\[/, "", body); sub(/\][[:space:]]*(#.*)?$/, "", body) + if (body ~ /^[[:space:]]*$/) return 1 + n = split(body, parts, ",") + for (i = 1; i <= n; i++) { + it = parts[i]; sub(/^[[:space:]]+/, "", it); sub(/[[:space:]]+$/, "", it) + if (it == "" && i == n && n > 1) { parts[i] = ""; continue } + if (!(it ~ ("^" sq "[^\"" sq "]+" sq "$") || it ~ ("^\"[^\"" sq "]+\"$") || it ~ ("^[^][{}:,\"" sq "#[:space:]]+$"))) return 0 + parts[i] = it } + for (i = 1; i <= n; i++) if (parts[i] != "") { k = cur SUBSEP unq(parts[i]); if (!(k in uses_of)) uses_of[k] = parts[i] } + return 1 } # A record key is the dependency between its quotes — or a plain # scalar, as a hand-added record may write it (`actions/checkout@:`, @@ -155,11 +184,21 @@ merge_duplicate_records() { record_key($0) { key = $0; sub(/[[:space:]]+#.*$/, "", key); key = unq(key) if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } - cur = key; next + nrec[key]++; cur = key; next } # The nested list header, with or without a trailing comment (kept as # first spelled); only lines inside a list it opens are its entries. - /^ uses:[[:space:]]*(#.*)?$/ { in_uses = 1; if (!(cur in uses_head)) uses_head[cur] = $0; next } + /^ uses:[[:space:]]*(#.*)?$/ { in_uses = 1; nlists[cur]++; if (!(cur in uses_head)) uses_head[cur] = $0; next } + /^ uses:[[:space:]]*\[.*\][[:space:]]*(#.*)?$/ && flow_items($0) { + nlists[cur]++ + if (!(cur in flow_line)) { + flow_line[cur] = $0; c = $0; sub(/^.*\]/, "", c) + flow_cmt[cur] = (c ~ /#/ ? c : "") + } + next + } + # Any other `uses:` value is one the merger cannot read. + /^ uses:/ { odd[cur] = 1 } in_uses && uses_entry($0) { u = $0; sub(/^ - /, "", u); k = cur SUBSEP entry_value($0); if (!(k in uses_of)) uses_of[k] = u; next } # Any other line inside the list (a comment, a shape this merger has # no rule for) stays under the `uses:` header it came from: moved @@ -173,12 +212,20 @@ merge_duplicate_records() { next } END { + for (k = 1; k <= norder; k++) if (odd[order[k]] && nrec[order[k]] > 1) { + name = order[k]; sub(/^ +/, "", name); sub(/:$/, "", name) + printf "UNSUPPORTED .github/workflows/actions.lock: the record %s is listed %d times and its uses: is a shape the merger cannot fold (a flow list over several lines, an alias, a scalar); the duplicates were left in place. Merge them by hand.\n", name, nrec[order[k]] > "/dev/stderr" + refused = 1 + } + if (refused) exit 4 for (k = 1; k <= norder; k++) { cur = order[k] emit_record() } } - ' "$lock" > "$tmp" + ' "$lock" > "$tmp"; then + rm -f "$tmp"; merge_refused=1; return 0 + fi if ! cmp -s "$lock" "$tmp"; then pin_fixer_replace "$tmp" "$lock"; else rm -f "$tmp"; fi } @@ -335,6 +382,7 @@ done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml edits=$(mktemp) # workflow-keystalereplacementkeep-stale-edgerepository lock_stale=0 # a rewritten caller whose lock entry never had the edge: the lock was already out of sync unsupported=0 +merge_refused=0 # duplicate lock records the merger could not fold (UNSUPPORTED) while IFS= read -r -d '' wf; do ctx=$(yaml_context "$wf") klines=$(grep -o '^K[0-9]*' <<<"$ctx" | tr -d K | sort -n || true) @@ -563,6 +611,10 @@ if [ "$unsupported" -ne 0 ]; then echo "fix-stale-reusable-pin: $unsupported file(s) left untouched (UNSUPPORTED above); not a clean success." >&2 exit 2 fi +if [ "$merge_refused" -ne 0 ]; then + echo "fix-stale-reusable-pin: actions.lock still lists a dependency twice (UNSUPPORTED above); not a clean success." >&2 + exit 2 +fi if [ "$lock_stale" -eq 1 ]; then echo "fix-stale-reusable-pin: a rewritten caller had no matching lock edge (WARN above); the lock is out of sync. Exit 3: not a clean success." >&2 exit 3 diff --git a/scripts/lib/pin-fixer-lock.sh b/scripts/lib/pin-fixer-lock.sh index 79c6d9e3..d6cb21b7 100644 --- a/scripts/lib/pin-fixer-lock.sh +++ b/scripts/lib/pin-fixer-lock.sh @@ -42,7 +42,7 @@ pin_fixer_lock() { key=$(cd "$repo" 2>/dev/null && pwd -P || printf '%s' "$repo") key=$(printf '%s' "$key" | sha256sum | cut -c1-16) if ! command -v flock >/dev/null 2>&1; then - echo "WARN: flock not found; workflow-pin fixers on $repo are not serialised" >&2 + pin_fixer_lock_mkdir "$repo" "$dir/pin-fixer-$key.lock.d" "$wait" return 0 fi exec 9>"$dir/pin-fixer-$key.lock" @@ -52,6 +52,31 @@ pin_fixer_lock() { } } +# Without flock the same lock is a directory: `mkdir` either creates it or +# fails because another fixer has, atomically, so two runs still never hold +# it together. The holder's EXIT trap removes it (an interrupt or termination +# exits through that trap too). A run killed outright leaves it behind; the +# next fixer then waits, reports the directory and its recorded holder, and +# exits 2 with nothing changed — it is never taken over silently, and never +# skipped. +pin_fixer_lock_mkdir() { # + local repo="$1" ldir="$2" wait="$3" waited=0 + until mkdir "$ldir" 2>/dev/null; do + if [ "$waited" -ge "$wait" ]; then + echo "fix-workflow-pins: another workflow-pin fixer holds $repo (waited ${wait}s; flock is not installed, so the lock is the directory $ldir, held by pid $(cat "$ldir/pid" 2>/dev/null || echo unknown) — remove it by hand only if that run is gone); nothing was changed" >&2 + exit 2 + fi + sleep 1 + waited=$((waited + 1)) + done + PIN_FIXER_LOCK_HELD="$ldir" + echo "$$" > "$ldir/pid" + trap 'rm -f "$PIN_FIXER_LOCK_HELD/pid"; rmdir "$PIN_FIXER_LOCK_HELD" 2>/dev/null' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + # Replace with the rewritten the way `sed -i` does: by a rename # (never a half-written file), keeping 's mode, and its owner and group # where this user may set them. A `mktemp` file is created 0600; renamed as it diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index c9d2b596..b11716f0 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2999,6 +2999,69 @@ TAG_MAP="$fx/ls36-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls36 a tag YAML reads as a boolean is written quoted into a plain ref field" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: 'true'\$\" '$fx/ls36/.github/workflows/actions.lock'" +# sp40 / sp41: a nested `uses:` list written flow-style merges like a block +# one — the merged record keeps ONE `uses:` key and every entry: `uses: []` +# on the first record does not stand beside the block list folded into it, +# and a flow list on the record folded away is not dropped with it. +sp_flow_fixture() { # + mkdir -p "$1/.github/workflows" + printf 'jobs:\n governance:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STALE_B" > "$1/.github/workflows/governance.yml" + printf 'jobs:\n scorecard:\n uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@%s\n' "$STALE_C" > "$1/.github/workflows/scorecard.yml" + printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/governance.yml':\n - 'hyperpolymath/standards@%s'\n '.github/workflows/scorecard.yml':\n - 'hyperpolymath/standards@%s'\ndependencies:\n 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n%b 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n%b" \ + "$STALE_B" "$STALE_C" "$STALE_B" "$STALE_B" "$STALE_B" "$2" "$STALE_C" "$STALE_C" "$STALE_C" "$3" > "$1/.github/workflows/actions.lock" +} +sp_flow_fixture "$fx/sp40" " uses: []\n" " uses:\n - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc'\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp40" +check "sp40 uses: [] on the first record and a block list on the merged one leave one uses: key holding the entry" \ + "[ \$rc -eq 3 ] && [ \$(grep -c '^ uses:' '$fx/sp40/.github/workflows/actions.lock') -eq 1 ] && grep -q \"^ - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc'\\\$\" '$fx/sp40/.github/workflows/actions.lock'" +sp_flow_fixture "$fx/sp41" " uses:\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\n" " uses: ['ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc', \"actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\"]\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp41" +check "sp41 a one-line flow list on the record folded away merges into the block list: one key, each entry once" \ + "[ \$rc -eq 3 ] && [ \$(grep -c '^ uses:' '$fx/sp41/.github/workflows/actions.lock') -eq 1 ] && grep -q \"^ - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc'\\\$\" '$fx/sp41/.github/workflows/actions.lock' && [ \$(grep -c 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '$fx/sp41/.github/workflows/actions.lock') -eq 1 ]" +sp_flow_fixture "$fx/sp41b" " uses: [] # none yet\n" " uses: []\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp41b" +check "sp41b two empty flow lists merge to the first one as written" \ + "[ \$rc -eq 3 ] && [ \$(grep -c '^ uses:' '$fx/sp41b/.github/workflows/actions.lock') -eq 1 ] && grep -q '^ uses: \\[\\] # none yet\$' '$fx/sp41b/.github/workflows/actions.lock'" + +# sp42: a record listed twice whose `uses:` is a flow list over several lines +# is a shape the merger cannot fold. It is not folded with its entries lost: +# the duplicates stay, the record is named UNSUPPORTED, exit 2. +sp_flow_fixture "$fx/sp42" " uses:\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\n" " uses: [\n 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' ]\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp42" +check "sp42 a duplicated record with a multi-line flow uses: is refused, named and left with every entry" \ + "[ \$rc -eq 2 ] && grep -q \"UNSUPPORTED .*actions.lock: the record hyperpolymath/standards@$GOOD is listed 2 times\" <<<\"\$out\" && grep -q 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' '$fx/sp42/.github/workflows/actions.lock' && grep -q 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '$fx/sp42/.github/workflows/actions.lock'" + +# sp43 (control): a record with a flow list and nothing merged into it is +# written back exactly as it was, not re-spelled as a block list. +mkdir -p "$fx/sp43/.github/workflows" +printf 'jobs:\n governance:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STALE_B" > "$fx/sp43/.github/workflows/governance.yml" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/governance.yml':\n - 'hyperpolymath/standards@%s'\ndependencies:\n 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n uses: ['b/b@3d3c42e5aac5ba805825da76410c181273ba90b1', 'a/a@3d3c42e5aac5ba805825da76410c181273ba90b1']\n" \ + "$STALE_B" "$STALE_B" "$STALE_B" "$STALE_B" > "$fx/sp43/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp43" +check "sp43 a flow list nothing was merged into is kept as written" \ + "[ \$rc -eq 3 ] && grep -qF \" uses: ['b/b@3d3c42e5aac5ba805825da76410c181273ba90b1', 'a/a@3d3c42e5aac5ba805825da76410c181273ba90b1']\" '$fx/sp43/.github/workflows/actions.lock'" + +# pl4: without flock the lock is a directory created with mkdir — still one +# fixer per checkout. A held lock is waited for, reported with its holder, +# and the run exits 2 having changed nothing; a free one is taken, and the +# run removes it on exit. +nofl="$fx/no-flock-bin"; mkdir -p "$nofl" +IFS=: read -ra pl4_dirs <<<"$PATH" +for d in "${pl4_dirs[@]}"; do [ -d "$d" ] && { ln -s "$d"/* "$nofl"/ 2>/dev/null || true; }; done +rm -f "$nofl/flock" +mkdir -p "$fx/pl4/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/pl4/.github/workflows/ci.yml" +cp "$fx/pl4/.github/workflows/ci.yml" "$fx/pl4-before.yml" +pl4_key=$(cd "$fx/pl4" && pwd -P | tr -d '\n' | sha256sum | cut -c1-16) +mkdir "$fx/pin-fixer-$pl4_key.lock.d"; echo 4242 > "$fx/pin-fixer-$pl4_key.lock.d/pid" +PATH="$nofl" PIN_FIXER_LOCK_DIR="$fx" PIN_FIXER_LOCK_WAIT=1 run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl4" +check "pl4 without flock a held lock directory makes the fixer report its holder, exit 2 and change nothing" \ + "[ \$rc -eq 2 ] && grep -q 'another workflow-pin fixer holds.*held by pid 4242' <<<\"\$out\" && cmp -s '$fx/pl4-before.yml' '$fx/pl4/.github/workflows/ci.yml'" +rm -rf "$fx/pin-fixer-$pl4_key.lock.d" +PATH="$nofl" PIN_FIXER_LOCK_DIR="$fx" PIN_FIXER_LOCK_WAIT=1 run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl4" +check "pl4 without flock a free lock is taken, the run repairs, and the lock directory is gone afterwards" \ + "[ \$rc -eq 0 ] && grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/pl4/.github/workflows/ci.yml' && [ ! -e '$fx/pin-fixer-$pl4_key.lock.d' ]" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From dcfca15b0cf4f0589ba37742d58ae0fdf11012e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:25:22 +0000 Subject: [PATCH 35/44] fix(fix-scripts): lock records merge case-folded; next-line with: values, unread aliases and transitive records refused; timestamp tags quoted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - merge_duplicate_records keyed records by their spelling, so `Fixture/Repo@` renamed beside `fixture/repo@` left two records for one dependency. Keys and nested entries now compare as GitHub resolves them (repository and commit ID case-folded; a tag is a name), the first spelling kept. - fix-rust-toolchain-input: a `with:` whose value is written on the next line as a flow mapping or alias (`{ components: rustfmt }`, `*inputs`) was taken for a block mapping and given a block child — invalid YAML, exit 0. The first child must now be a block mapping key; anything else is reported UNSUPPORTED, file untouched. - fix-stale-reusable-pin: the anchor reader is line-local, so `&call` after another item of a flow sequence begun on an earlier line went unseen and `uses: *call` passed as OK. An alias it cannot resolve, in a file holding a known-dead pin, is now reported UNSUPPORTED (exit 2). - pin_fixer_yaml_nonstring also recognises timestamps (`2026-10-20`), signed and dotted numbers, base 60, .inf/.nan, `~` and the empty value, so such a tag or TOOLCHAIN is written quoted. - fix-lock-self-sha-refs treated every record no workflow here uses as an action and could rewrite a nested reusable's legitimate SHA ref. Only a workflow site proves an action now; any other record (bar the two estate reusable sources) is UNRESOLVED and left as is. Lock-only test fixtures gain the step that makes their record an action. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 30 ++++--- scripts/fix-rust-toolchain-input.sh | 10 ++- scripts/fix-stale-reusable-pin.sh | 46 +++++++++-- scripts/lib/pin-fixer-lock.sh | 15 ++-- scripts/tests/fix-workflow-pins.sh | 117 ++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 21 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index fe2d76f5..852a4920 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -73,8 +73,9 @@ lookup_tag() { # # `uses: owner/repo[/path]@` is an action (the SHA ref is the defect). # One repository can be both at different revisions (cicd-suite ships gates # AND composite actions), so the class is per repository@revision. A record no -# workflow uses directly (a nested dependency, a tree without workflows) falls -# back to the two estate reusable sources by name. +# workflow uses directly (a nested dependency, a tree without workflows) is +# not classified by guess: the two estate reusable sources keep their SHA ref +# by name, and any other such record is reported UNRESOLVED and left as is. # # Only a `uses:` that is a real key counts: a job's key under `jobs:` is a # reusable call (R), a step's key under `steps:` is an action (A). The same @@ -318,14 +319,20 @@ usage_of() { # -> reusable | action | unknown END { print (reusable ? "reusable" : (action ? "action" : "unknown")) }' "$SITES" } -# Return 0 if owner/repo $1 at SHA $2 should keep its SHA ref, or 1 otherwise. -# Use workflow classification, then the estate source fallback for unknown use. -keeps_sha_ref() { # +# How the record for owner/repo $1 at SHA $2 is treated: `keep` its SHA ref +# (a reusable workflow, whose lock ref is the SHA), `rewrite` it to a tag (an +# action), or `unknown`. Only a site in this repository's workflows proves an +# action: a record no workflow here uses is a transitive dependency (a +# reusable's own `uses:`), whose edge `owner/repo@sha` says nothing of its +# kind, and a nested reusable's SHA ref is legitimate. Such a record is left +# as is and reported, never rewritten on a guess; the estate's two reusable +# sources are known reusables. +sha_ref_disposition() { # -> keep | rewrite | unknown case "$(usage_of "$1" "$2")" in - reusable) return 0 ;; - action) return 1 ;; + reusable) echo keep ;; + action) echo rewrite ;; # GitHub reads `Hyperpolymath/Standards` as hyperpolymath/standards. - *) case "${1,,}" in hyperpolymath/standards|hyperpolymath/cicd-suite) return 0 ;; esac; return 1 ;; + *) case "${1,,}" in hyperpolymath/standards|hyperpolymath/cicd-suite) echo keep ;; *) echo unknown ;; esac ;; esac } @@ -366,7 +373,12 @@ if ! awk ' fi while IFS=$'\t' read -r lineno repo sha ref_raw; do sha_lc="${sha,,}" - if keeps_sha_ref "$repo" "$sha_lc"; then continue; fi + case "$(sha_ref_disposition "$repo" "$sha_lc")" in + keep) continue ;; + unknown) + echo "UNRESOLVED $repo@${sha:0:7}: no workflow here uses it (a transitive dependency), so whether it is an action or a reusable workflow cannot be told; record left as is" >&2 + unresolved=$((unresolved + 1)); continue ;; + esac tag=$(lookup_tag "$repo" "$sha_lc") if [ -z "$tag" ]; then echo "UNRESOLVED $repo@${sha:0:7}: no tag found for this commit (record left as is; GitHub rejects it)" >&2 diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index d141d6dc..e0f254dd 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -295,7 +295,14 @@ apply_to_file() { else if (plain(line)) { # Only a DIRECT child of `with:` is an input: the contents of # another input'"'"'s block scalar (`components: |`) sit deeper. - if (child_indent < 0) child_indent = ind + # The first must be a block mapping key: `with:` whose value + # is a flow mapping, an alias or anything else written on the + # next line (`{ components: rustfmt }`, `*inputs`) is a shape + # a block child cannot be added to, so the step is reported. + if (child_indent < 0) { + child_indent = ind + if (line !~ WITH_CHILD_KEY_RE) { with_bad = 1; unrec[++nunrec] = lnum[i] } + } # An empty `toolchain:` whose next plain line (blank and # comment lines between them notwithstanding) sits deeper is # a nested mapping, not an empty value: nothing this fixer @@ -385,6 +392,7 @@ apply_to_file() { WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" + WITH_CHILD_KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" TC_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:]]" TC_FLOW_RE = "[{,][[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:],}]" # The key present with no value — YAML null in the spellings the core diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index f0f6cc67..242d6b61 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -111,7 +111,9 @@ rewrites=0; lock_touched=0 # so after substitution the lock can hold the same dependency key twice. A # duplicate key is a lockfile GitHub refuses. Keep the first record, fold the # others' nested `uses:` into it (deduplicated, sorted as gh actions-lock sorts), -# and drop them. Rewrite lockfile $1 in place, preserving gh-actions-lock's +# and drop them. Keys and entries compare as GitHub resolves them — the +# repository and a commit ID in either case — so `Fixture/Repo@` and +# `fixture/repo@` are one record, written back as first spelled. Rewrite lockfile $1 in place, preserving gh-actions-lock's # 4-space layout and leaving the file alone when the output is identical. # # The nested list may be written flow-style on one line (`uses: []`, @@ -159,7 +161,7 @@ merge_duplicate_records() { if (!(it ~ ("^" sq "[^\"" sq "]+" sq "$") || it ~ ("^\"[^\"" sq "]+\"$") || it ~ ("^[^][{}:,\"" sq "#[:space:]]+$"))) return 0 parts[i] = it } - for (i = 1; i <= n; i++) if (parts[i] != "") { k = cur SUBSEP unq(parts[i]); if (!(k in uses_of)) uses_of[k] = parts[i] } + for (i = 1; i <= n; i++) if (parts[i] != "") { k = cur SUBSEP dep_id(unq(parts[i])); if (!(k in uses_of)) uses_of[k] = parts[i] } return 1 } # A record key is the dependency between its quotes — or a plain @@ -170,6 +172,16 @@ merge_duplicate_records() { # merger did not recognise was not kept: every line of it fell to the # catch-all below with no current record, and was dropped. function unq(s) { gsub(/["'"'"']/, "", s); return s } + # The dependency a record key or entry names, as GitHub resolves it: + # the repository in either case (`Fixture/Repo` is `fixture/repo`), + # a commit ID in either case; a tag or branch is a name, kept as is. + function dep_id(s, at, j, ref) { + at = 0; while ((j = index(substr(s, at + 1), "@")) > 0) at += j + if (!at) return tolower(s) + ref = substr(s, at + 1) + if (length(ref) == 40 && ref !~ /[^0-9A-Fa-f]/) ref = tolower(ref) + return tolower(substr(s, 1, at)) ref + } function record_key(s) { sub(/[[:space:]]+#.*$/, "", s); return (s ~ /^ ["'"'"'][^"'"'"']+["'"'"']:$/ || s ~ /^ [^[:space:]"'"'"'#][^[:space:]]*:$/) } # A nested `uses:` entry, quoted or plain, with or without a trailing # comment; it is deduplicated on its value, the comment kept with @@ -183,6 +195,7 @@ merge_duplicate_records() { /^ [^[:space:]]/ || /^ [^[:space:]]/ { in_uses = 0 } record_key($0) { key = $0; sub(/[[:space:]]+#.*$/, "", key); key = unq(key) + sub(/^ +/, "", key); sub(/:$/, "", key); key = dep_id(key) if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } nrec[key]++; cur = key; next } @@ -199,7 +212,7 @@ merge_duplicate_records() { } # Any other `uses:` value is one the merger cannot read. /^ uses:/ { odd[cur] = 1 } - in_uses && uses_entry($0) { u = $0; sub(/^ - /, "", u); k = cur SUBSEP entry_value($0); if (!(k in uses_of)) uses_of[k] = u; next } + in_uses && uses_entry($0) { u = $0; sub(/^ - /, "", u); k = cur SUBSEP dep_id(entry_value($0)); if (!(k in uses_of)) uses_of[k] = u; next } # Any other line inside the list (a comment, a shape this merger has # no rule for) stays under the `uses:` header it came from: moved # above it, it would be a child with no parent key. @@ -213,7 +226,7 @@ merge_duplicate_records() { } END { for (k = 1; k <= norder; k++) if (odd[order[k]] && nrec[order[k]] > 1) { - name = order[k]; sub(/^ +/, "", name); sub(/:$/, "", name) + name = spelled[order[k]]; sub(/[[:space:]]+#.*$/, "", name); name = unq(name); sub(/^ +/, "", name); sub(/:$/, "", name) printf "UNSUPPORTED .github/workflows/actions.lock: the record %s is listed %d times and its uses: is a shape the merger cannot fold (a flow list over several lines, an alias, a scalar); the duplicates were left in place. Merge them by hand.\n", name, nrec[order[k]] > "/dev/stderr" refused = 1 } @@ -248,6 +261,18 @@ lookup_replacement() { # return 1 } +# Does hold, anywhere in its text, a reusable pin the table names as +# known-dead? +dead_ref_in_file() { # + local ref sha + while IFS= read -r ref; do + [ -n "$ref" ] || continue + sha="${ref##*@}" + lookup_replacement "${ref%@*}" "${sha,,}" && return 0 + done < <(grep -oiE "$REUSABLE_REF_RE" "$1" || true) + return 1 +} + # Print YAML context markers for workflow file $1, one per line that matters: # K line n is a `uses:` key of a job under `jobs:` — the one place a # reusable is called; @@ -428,10 +453,19 @@ while IFS= read -r -d '' wf; do # A job's `uses:` given as an alias (`uses: *call`) runs the anchor's # value, defined elsewhere in the file (`CALL: &call owner/repo/...@`): # the rewrite reads no reference on the key line. A known-dead pin as - # that value is reported; the anchor is not rewritten from here. + # that value is reported; the anchor is not rewritten from here. An alias + # whose anchor this reader cannot find (the anchor reader is line-local: + # `&call` after another item of a flow sequence begun on an earlier line + # is not seen) may be that pin: in a file that holds a known-dead pin + # anywhere, it is reported too, never passed as OK. for lineno in $klines; do alias=$(sed -n "${lineno}p" "$wf" | grep -oE "^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*\*[A-Za-z0-9_.-]+" | sed 's/.*\*//' || true) [ -n "$alias" ] || continue + value=$(anchor_value "$wf" "$alias" "$lineno") + if [ -z "$value" ] && dead_ref_in_file "$wf"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` names an anchor this parser cannot read, and the file holds a known-dead pin it may hold. Fix by hand; the file was not changed." >&2 + skip=1; continue + fi while IFS= read -r ref; do [ -n "$ref" ] || continue sha="${ref##*@}" @@ -439,7 +473,7 @@ while IFS= read -r -d '' wf; do echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` runs $ref, a known-dead pin held by the anchor \`&$alias\`. Fix by hand; the file was not changed." >&2 skip=1 fi - done < <(anchor_value "$wf" "$alias" "$lineno" | grep -oiE "^${REUSABLE_REF_RE}\$" || true) + done < <(printf '%s\n' "$value" | grep -oiE "^${REUSABLE_REF_RE}\$" || true) done if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() diff --git a/scripts/lib/pin-fixer-lock.sh b/scripts/lib/pin-fixer-lock.sh index d6cb21b7..64bda3cf 100644 --- a/scripts/lib/pin-fixer-lock.sh +++ b/scripts/lib/pin-fixer-lock.sh @@ -89,11 +89,16 @@ pin_fixer_replace() { # } # Would YAML read , written as a plain (unquoted) scalar, as anything -# but a string? A number (`1`, `1.80` the float 1.8, `1e3`, `0x1f`, `0o17`, -# `0b1`) or a null or boolean spelling as YAML 1.1 and the core schema read -# them (`null`, `true`, `no`, `on`, `y`, ... in any case). A fixer writing -# such a value into a workflow or lock quotes it. +# but a string? As YAML 1.1 and the core schema read them: a number (`1`, +# `1.80` the float 1.8, `1e3`, `-1`, `.5`, `0x1f`, `0o17`, `0b1`, `1:30` base +# 60, `.inf`, `.nan`), a timestamp (`2026-10-20`, the date — a tag may be +# spelled so — with or without a time), or a null or boolean spelling +# (`null`, `~`, nothing, `true`, `no`, `on`, `y`, ... in any case). A fixer +# writing such a value into a workflow or lock quotes it. pin_fixer_yaml_nonstring() { # - [[ "$1" =~ ^([0-9][0-9_]*(\.[0-9_]*)?([eE][-+]?[0-9]+)?|0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+)$ ]] \ + [[ -z "$1" || "$1" == "~" ]] \ + || [[ "$1" =~ ^[-+]?([0-9][0-9_]*(\.[0-9_]*)?([eE][-+]?[0-9]+)?|\.[0-9][0-9_]*([eE][-+]?[0-9]+)?|[0-9][0-9_]*(:[0-5]?[0-9])+(\.[0-9_]*)?|0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|\.(inf|Inf|INF))$ ]] \ + || [[ "$1" =~ ^\.(nan|NaN|NAN)$ ]] \ + || [[ "$1" =~ ^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(([Tt]|[[:space:]]+)[0-9]{1,2}:[0-9]{2}:[0-9]{2}(\.[0-9]*)?([[:space:]]*(Z|[-+][0-9]{1,2}(:[0-9]{2})?))?)?$ ]] \ || [[ "${1,,}" =~ ^(null|true|false|yes|no|on|off|y|n)$ ]] } diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index b11716f0..f00178be 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -1150,7 +1150,16 @@ fixture/reusable/.github/workflows/../ci.yml aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa TSV # ── 3. fix-lock-self-sha-refs.sh ───────────────────────────────────────────── +# Only a workflow site proves a record is an action (a record no workflow +# uses is a transitive dependency, which the fixer will not classify by +# guess), so each action fixture carries the step that uses it. +action_steps() { # ... : a ci.yml with one step per action + { printf 'jobs:\n build:\n steps:\n' + for a in "${@:2}"; do printf ' - uses: %s\n' "$a"; done + } > "$1/ci.yml" +} mkdir -p "$fx/ls/.github/workflows" +action_steps "$fx/ls/.github/workflows" "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" cat > "$fx/ls/.github/workflows/actions.lock" <<'LOCK' version: 'v0.0.3' workflows: @@ -1245,6 +1254,7 @@ check "same repository, two revisions: the reusable call keeps its SHA ref, the "[ \$rc5 -eq 0 ] && grep -A1 \"'hyperpolymath/cicd-suite@$SHA_A':\" '$fx/ls5/.github/workflows/actions.lock' | grep -q \"ref: '$SHA_A'\" && grep -A1 \"'hyperpolymath/cicd-suite@$SHA_B':\" '$fx/ls5/.github/workflows/actions.lock' | grep -q \"ref: 'v1.2.3'\" && ! grep -q UNRESOLVED '$fx/ls5.out'" # A double-quoted record is the same record: repaired, quote style kept. mkdir -p "$fx/ls6/.github/workflows" +action_steps "$fx/ls6/.github/workflows" "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" printf 'version: "v0.0.3"\nworkflows:\n ".github/workflows/ci.yml":\n - "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"\ndependencies:\n "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1":\n ref: "3d3c42e5aac5ba805825da76410c181273ba90b1"\n commit: "sha1-3d3c42e5aac5ba805825da76410c181273ba90b1"\n owner_id: 44036562\n repo_id: 197814629\n' > "$fx/ls6/.github/workflows/actions.lock" set +e bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls6" --offline > "$fx/ls6.out" 2>&1; rc6=$? @@ -1254,6 +1264,7 @@ check "a double-quoted self-SHA record is parsed and repaired, keeping its quote # A tag Git allows but this fixer will not write (`&`, `|`, a quote) is refused. SHA_C=cccccccccccccccccccccccccccccccccccccccc mkdir -p "$fx/ls7/.github/workflows" +action_steps "$fx/ls7/.github/workflows" "nobody/weird@$SHA_C" printf "version: 'v0.0.3'\nworkflows: {}\ndependencies:\n 'nobody/weird@%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: 1\n repo_id: 1\n" "$SHA_C" "$SHA_C" "$SHA_C" > "$fx/ls7/.github/workflows/actions.lock" printf 'nobody/weird\t%s\trelease&stable\n' "$SHA_C" > "$fx/ls7-tags.tsv" cp "$fx/ls7/.github/workflows/actions.lock" "$fx/ls7-before.lock" @@ -1280,6 +1291,7 @@ LOCK lookup_repo="$fx/tag lookup" lookup_lock="$lookup_repo/.github/workflows/actions.lock" mkdir -p "$lookup_repo/.github/workflows" "$fx/git-bin" +action_steps "$lookup_repo/.github/workflows" "fixture/action@$SHA_A" cat > "$fx/git-bin/git" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -1529,6 +1541,7 @@ check "without the reusable call the subdirectory action in a .yaml workflow get # stop that repair, alter neighboring records, or hide the unresolved status. ls_partial="$fx/ls-partial/.github/workflows" mkdir -p "$ls_partial" +action_steps "$ls_partial" "fixture/missing@$SHA_A" "fixture/resolved@$SHA_B" "fixture/unsafe@$SHA_C" cat > "$ls_partial/actions.lock" </dev/null; then mkdir -p "$fx/mawk-bin" "$fx/ls2/.github/workflows"; ln -sf "$(command -v mawk)" "$fx/mawk-bin/awk" cp "$fx/ls1.lock" "$fx/ls2/.github/workflows/actions.lock" + cp "$fx/ls/.github/workflows/ci.yml" "$fx/ls2/.github/workflows/ci.yml" sed -i "s/ref: 'v7.0.1'/ref: '3d3c42e5aac5ba805825da76410c181273ba90b1'/" "$fx/ls2/.github/workflows/actions.lock" sed -i '0,/ref: .3d3c42e5aac5ba805825da76410c181273ba90b1./!{0,/ref: .3d3c42e5aac5ba805825da76410c181273ba90b1./s//ref: '"'"'v7.0.1'"'"'/}' "$fx/ls2/.github/workflows/actions.lock" printf " 'x/thirty-nine@123456789012345678901234567890123456789':\n ref: '123456789012345678901234567890123456789'\n commit: 'sha1-123456789012345678901234567890123456789'\n" >> "$fx/ls2/.github/workflows/actions.lock" @@ -3062,6 +3076,109 @@ PATH="$nofl" PIN_FIXER_LOCK_DIR="$fx" PIN_FIXER_LOCK_WAIT=1 run_fix bash "$fleet check "pl4 without flock a free lock is taken, the run repairs, and the lock directory is gone afterwards" \ "[ \$rc -eq 0 ] && grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/pl4/.github/workflows/ci.yml' && [ ! -e '$fx/pin-fixer-$pl4_key.lock.d' ]" +# ── Review cases (gitbot-fleet#615, round on ccfc924) ─────────────────────── +# sp44: a stale record spelled `Hyperpolymath/Standards@` renamed onto +# the replacement that `hyperpolymath/standards@` already holds is the +# same dependency — GitHub reads the repository in either case — so the two +# records merge into one, the first spelling kept. +mkdir -p "$fx/sp44/.github/workflows" +printf 'jobs:\n governance:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STALE_B" > "$fx/sp44/.github/workflows/governance.yml" +cat > "$fx/sp44/.github/workflows/actions.lock" < "$fx/tc34/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + { components: rustfmt } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + # shared inputs + *inputs +YML +cp "$fx/tc34/.github/workflows/x.yml" "$fx/tc34-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc34" +check "tc34 with: holding a flow mapping or an alias on the next line is reported, exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6 10' <<<\"\$out\" && cmp -s '$fx/tc34-before.yml' '$fx/tc34/.github/workflows/x.yml'" +printf 'jobs:\n check:\n steps:\n - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n with:\n components: rustfmt\n' > "$fx/tc34b/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc34b" +check "tc34 (control) a block child on the next line is a block mapping: toolchain added beside it" \ + "[ \$rc -eq 0 ] && grep -q '^ toolchain: stable\$' '$fx/tc34b/.github/workflows/x.yml' && grep -q '^ components: rustfmt\$' '$fx/tc34b/.github/workflows/x.yml'" + +# sp45: an anchor defined after another item of a flow sequence begun on an +# earlier line is one the line-local anchor reader does not see; a job's +# `uses: *call` naming it, in a file that holds a known-dead pin, is +# reported (exit 2, untouched), never passed as OK. The same unreadable +# alias in a file with no known-dead pin is no finding. +mkdir -p "$fx/sp45/.github/workflows" "$fx/sp45b/.github/workflows" +cat > "$fx/sp45/.github/workflows/ci.yml" < "$fx/sp45b/.github/workflows/ci.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp45b" +check "sp45 (control) the same alias in a file with no known-dead pin is no finding" \ + "[ \$rc -eq 0 ] && grep -q '^OK (no known-dead reusable pins)' <<<\"\$out\"" + +# pin_fixer_yaml_nonstring: the YAML 1.1 / core-schema plain scalars that are +# not strings — timestamps, signed and dotted numbers, base 60, .inf/.nan, +# ~ and nothing — and names that only look like them. +yn_out=$(bash -c '. "$1"; for v in 2026-10-20 "2026-10-20T10:00:00Z" -1 +2 .5 1:30 .inf -.Inf .NaN "~" "" 0x1F 1e3 null Yes; do pin_fixer_yaml_nonstring "$v" || echo "STRING:$v"; done; for v in v2026-10-20 nightly-2026-10-20 2026-10 1.2.3 stable release v1 1.85.0 .nanx; do pin_fixer_yaml_nonstring "$v" && echo "NONSTRING:$v"; done; true' _ "$fleet/scripts/lib/pin-fixer-lock.sh") +check "pin_fixer_yaml_nonstring reads timestamps, signed/dotted numbers, base 60, .inf/.nan and ~ as non-strings, and only those" \ + "[ -z \"\$yn_out\" ]" + +# ls37: a tag spelled as a date is written quoted into a plain ref field, so +# the lock holds the tag string, not a timestamp. +mkdir -p "$fx/ls37/.github/workflows" +action_steps "$fx/ls37/.github/workflows" "foo/bar@$SHA_B" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s':\n ref: %s\n commit: 'sha1-%s'\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls37/.github/workflows/actions.lock" +printf 'foo/bar\t%s\t2026-10-20\n' "$SHA_B" > "$fx/ls37-tags.tsv" +TAG_MAP="$fx/ls37-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls37" --offline +check "ls37 a tag spelled as a date is written quoted into a plain ref field" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: '2026-10-20'\$\" '$fx/ls37/.github/workflows/actions.lock'" + +# ls38: a self-SHA record no workflow here uses (a transitive dependency: a +# reusable's own call, which may be a reusable whose SHA ref is legitimate) +# is not rewritten to a tag on a guess, even with a tag to hand: reported +# UNRESOLVED, the record unchanged, exit 1. +mkdir -p "$fx/ls38/.github/workflows" +action_steps "$fx/ls38/.github/workflows" "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" +lock_records "$fx/ls38" "third/party@$SHA_A" +printf 'third/party\t%s\tv1.0.0\n' "$SHA_A" > "$fx/ls38-tags.tsv" +cp "$fx/ls38/.github/workflows/actions.lock" "$fx/ls38-before.lock" +TAG_MAP="$fx/ls38-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls38" --offline +check "ls38 a record no workflow uses is UNRESOLVED and left as is, never rewritten on a guess" \ + "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED third/party@aaaaaaa: no workflow here uses it' <<<\"\$out\" && cmp -s '$fx/ls38-before.lock' '$fx/ls38/.github/workflows/actions.lock'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From 51ca9537c48fbdbb4db885c46fbfd9d3b6239e95 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:38:45 +0000 Subject: [PATCH 36/44] fix(fix-scripts): a uses: value carrying a YAML tag is refused, never passed as OK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixers knew a `uses:` value could carry an anchor (`uses: &rust ...`) but not a tag: `uses: !!str dtolnay/rust-toolchain@` and `uses: !!str @` matched no site, so both fixers reported the file clean while the pin stood (found while answering hypatia#931, whose WF026/WF027 and pin readers now see such values). - fix-rust-toolchain-input / fix-stale-reusable-pin: node properties — an anchor or a tag, either order, repeated — are read before the value (USES_SITE_RE, USES_OPEN_RE, ANCHOR_RE, and the quote masks, so a quoted value after a tag is still the `uses` value). Such a value is reported UNSUPPORTED, exit 2, file untouched, as an anchored one was. - fix-lock-self-sha-refs: a step whose value carries a tag still proves its record an action, so the classification no longer depends on the spelling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 8 +++---- scripts/fix-rust-toolchain-input.sh | 12 ++++++---- scripts/fix-stale-reusable-pin.sh | 16 ++++++++------ scripts/tests/fix-workflow-pins.sh | 34 ++++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 852a4920..70098393 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -90,7 +90,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A-`), or nothing but a comment — names its site on the # lines below, which this reader skips as scalar text. Such a site @@ -132,7 +132,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A-`) or nothing at all, the reference on the lines below. - USES_OPEN_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*(&[^[:space:],}]+[[:space:]]*)?([|>][-+0-9]*)?[[:space:]]*$" + USES_OPEN_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]*)*([|>][-+0-9]*)?[[:space:]]*$" WITH_BLOCK_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*(#.*)?$" WITH_FLOW_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:[[:space:]]*\\{" WITH_ANY_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "with" Q "[[:space:]]*:" diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 242d6b61..5fb2f185 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -347,14 +347,14 @@ FALLBACK_REF_END="([\"'[:space:],}]|]|$)" # looked up in lower case and rewritten as written. REUSABLE_REF_RE="(${TABLE_OWNERS_RE})/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-fA-F]{40}" # A line with its comment dropped and every quoted scalar that is neither a -# key nor the value of a `uses` key emptied, quote-aware. The fallback scan +# key nor the value of a `uses` key (past any anchor or tag) emptied, quote-aware. The fallback scan # for pins the parser cannot place reads this, so a dead pin named in a # trailing comment or inside a `name:` string is data, not an UNSUPPORTED # caller. mask_line() { printf '%s\n' "$1" | awk -v sq="'" '{ s = $0; out = ""; n = length(s); i = 1 - tail = "(^|[-{,[:space:]])[\"" sq "]?uses[\"" sq "]?[[:space:]]*:[[:space:]]*$" + tail = "(^|[-{,[:space:]])[\"" sq "]?uses[\"" sq "]?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*$" while (i <= n) { c = substr(s, i, 1) if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break @@ -371,10 +371,12 @@ mask_line() { print out }' } -# The value may carry a YAML anchor (`uses: &call owner/repo/...@`). -# The rewrite does not edit anchored values, since every alias of the -# anchor would change with it, so such a value is reported, never skipped. -ANCHOR_RE="&[^[:space:],}]+[[:space:]]+" +# The value may carry YAML node properties — an anchor or a tag, in either +# order (`uses: &call owner/repo/...@`, `uses: !!str owner/...@`). +# The rewrite does not edit such values (every alias of an anchor would +# change with it, and the rewrite reads the reference right after the +# colon), so such a value is reported, never skipped as clean. +ANCHOR_RE="([&!][^[:space:],}]*[[:space:]]+)+" FALLBACK_USES_RE="(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*(${ANCHOR_RE})?[\"']?${REUSABLE_REF_RE}${FALLBACK_REF_END}" ANCHORED_USES_RE="(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*${ANCHOR_RE}[\"']?${REUSABLE_REF_RE}${FALLBACK_REF_END}" # The dead reference a job-level `uses:` line carries, as written, or "". @@ -432,7 +434,7 @@ while IFS= read -r -d '' wf; do [ -n "$ref" ] || continue sha="${ref##*@}" if lookup_replacement "${ref%@*}" "${sha,,}"; then - echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (a flow-style job mapping, or an anchored value). Fix by hand; the file was not changed." >&2 + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin but not a \`uses\` key the parser can place (a flow-style job mapping, or a value carrying an anchor or tag). Fix by hand; the file was not changed." >&2 skip=1 fi done < <(mask_line "${numbered#*:}" | grep -oiE "$FALLBACK_USES_RE" | grep -oiE "$REUSABLE_REF_RE" || true) diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index f00178be..62a7bb9c 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -2694,7 +2694,7 @@ printf 'jobs:\n rust:\n uses: &call %s@%s\n' "$RC_REUSABLE" "$STALE_A" > "$f cp "$fx/sp33/.github/workflows/ci.yml" "$fx/sp33-before.yml" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp33" check "sp33 an anchored dead reusable value is UNSUPPORTED: exit 2, file untouched, never OK" \ - "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*anchored value' <<<\"\$out\" && cmp -s '$fx/sp33-before.yml' '$fx/sp33/.github/workflows/ci.yml'" + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*a value carrying an anchor or tag' <<<\"\$out\" && cmp -s '$fx/sp33-before.yml' '$fx/sp33/.github/workflows/ci.yml'" printf 'jobs:\n check:\n steps:\n - uses: &rt dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n' > "$fx/tc22/.github/workflows/x.yml" cp "$fx/tc22/.github/workflows/x.yml" "$fx/tc22-before.yml" run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc22" @@ -3179,6 +3179,38 @@ TAG_MAP="$fx/ls38-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls38 a record no workflow uses is UNRESOLVED and left as is, never rewritten on a guess" \ "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED third/party@aaaaaaa: no workflow here uses it' <<<\"\$out\" && cmp -s '$fx/ls38-before.lock' '$fx/ls38/.github/workflows/actions.lock'" +# tc35 / sp46: a `uses:` value carrying a YAML tag (`!!str`), alone or with +# an anchor, is still the step's or job's pin, but not one the rewrite +# places: reported UNSUPPORTED, exit 2, the file untouched — never passed as +# OK. (hypatia WF026/WF027 route such findings to review.) +mkdir -p "$fx/tc35/.github/workflows" "$fx/sp46/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: !!str dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n - uses: !!str &rust "dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772"\n' > "$fx/tc35/.github/workflows/x.yml" +cp "$fx/tc35/.github/workflows/x.yml" "$fx/tc35-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc35" +check "tc35 a tagged uses: value is reported UNSUPPORTED, exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 4 5' <<<\"\$out\" && cmp -s '$fx/tc35-before.yml' '$fx/tc35/.github/workflows/x.yml'" +printf 'jobs:\n rust:\n uses: !!str hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp46/.github/workflows/ci.yml" +cp "$fx/sp46/.github/workflows/ci.yml" "$fx/sp46-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp46" +check "sp46 a tagged job uses: holding a known-dead pin is UNSUPPORTED, exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:3: .*a value carrying an anchor or tag' <<<\"\$out\" && cmp -s '$fx/sp46-before.yml' '$fx/sp46/.github/workflows/ci.yml'" + +mkdir -p "$fx/sp46b/.github/workflows" +printf 'jobs:\n rust:\n uses: !!str "hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s"\n' "$STALE_A" > "$fx/sp46b/.github/workflows/ci.yml" +cp "$fx/sp46b/.github/workflows/ci.yml" "$fx/sp46b-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp46b" +check "sp46 a quoted value after a tag is the job's value too: UNSUPPORTED, exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:3: ' <<<\"\$out\" && cmp -s '$fx/sp46b-before.yml' '$fx/sp46b/.github/workflows/ci.yml'" +# ls39: a step whose `uses:` value carries a tag still proves its record an +# action: the lock's self-SHA ref gets its tag. +mkdir -p "$fx/ls39/.github/workflows" +printf 'jobs:\n build:\n steps:\n - uses: !!str "foo/bar@%s"\n' "$SHA_B" > "$fx/ls39/.github/workflows/ci.yml" +lock_records "$fx/ls39" "foo/bar@$SHA_B" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls39-tags.tsv" +TAG_MAP="$fx/ls39-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls39" --offline +check "ls39 a tagged step value still classifies its record as an action: the ref gets its tag" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls39/.github/workflows/actions.lock'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From d83bce8100ad855915c3329610d656b8f43af930 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:46:25 +0000 Subject: [PATCH 37/44] fix(fix-scripts): aliases judged at their line; quoted uses keys merge; maintenance runner keeps the caller's umask - fix-rust-toolchain-input judged a step's `uses: *alias` by the anchors known when the step ended, so a later line of the same step redefining `&rust` made a SHA-pinned toolchain alias pass as clean. The verdict is now taken as each line is buffered: the anchor defined before the alias decides, as YAML resolves it. - merge_duplicate_records recognised only a bare nested `uses:` key; a record spelling it `"uses":` or `'uses':` kept it as a header field and gained a second, bare key from the merged record. Quoted spellings are now the list key, as they are for `ref` and `commit`. Two awk comments whose apostrophes closed the shell quote are escaped. - maintenance-hard-pass.sh set umask 077 for the default report and left it in force for the repository's maintenance runner, whose own files then came out private. The report directory and an empty report are created under 077 and the caller's umask is restored before the runner starts; the runner writes into the existing private file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-rust-toolchain-input.sh | 30 +++++++++++++++++------- scripts/fix-stale-reusable-pin.sh | 18 +++++++------- scripts/maintenance-hard-pass.sh | 13 +++++++++-- scripts/tests/fix-workflow-pins.sh | 31 +++++++++++++++++++++++++ scripts/tests/fix-workflow-resources.sh | 3 +++ 5 files changed, 77 insertions(+), 18 deletions(-) diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index e3650cf8..43be6356 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -203,6 +203,22 @@ apply_to_file() { # only pin the parser can be asked to place. The same text inside # another key'"'"'s string (`name: "fixture dtolnay/...@"`) is data. function uses_pin(s) { return sha_ref(s) && tolower(site_view(s)) ~ USES_SITE_RE } + # Buffer line $0 of the current step as buf[n], with the verdict on + # its `uses:` alias, if any, as the anchors read so far give it — + # YAML resolves an alias to the latest anchor of that name BEFORE it, + # so the verdict cannot wait for the end of the step, when a later + # line may have redefined the name: "bad" when the anchor holds a + # SHA-pinned toolchain reference or no anchor of that name has been + # seen, else "ok". Anchor names are case-sensitive: the key is + # matched in lower case, the name is taken as written. + function buffer( v, m) { + buf[++n] = $0; lnum[n] = NR; alias_state[n] = "" + v = site_view($0) + if (match(tolower(v), USES_ALIAS_RE)) { + m = substr(v, RSTART, RLENGTH); sub(/^.*\*/, "", m) + alias_state[n] = ((m in tc_anchor) || !(m in any_anchor)) ? "bad" : "ok" + } + } # YAML context outside list items: the key at each indentation, so a # list item opens a step only when its parent key is `steps:`. function record_key(s, ind, k, t) { @@ -254,12 +270,10 @@ apply_to_file() { # runs that action, but the reference is not where the parser # places a step'"'"'s action. Reported, never passed over. # An alias of no anchor read here could be that reference too. - # Anchor names are case-sensitive: matched in lower case for - # the key, the name is taken as written. - if ((flow_item || length(key_indent_of(buf[i])) == skey) && match(tolower(site_view(buf[i])), USES_ALIAS_RE)) { - m = substr(site_view(buf[i]), RSTART, RLENGTH); sub(/^.*\*/, "", m) - if ((m in tc_anchor) || !(m in any_anchor)) unrec[++nunrec] = lnum[i] - } + # The verdict was taken when the line was read (alias_state): + # an alias names the anchor defined before it, not one a later + # line of the step defines again under the same name. + if ((flow_item || length(key_indent_of(buf[i])) == skey) && alias_state[i] == "bad") unrec[++nunrec] = lnum[i] if (!sha_ref(buf[i])) continue if (!flow_item && tolower(buf[i]) ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } if (!flow_item && i > 1 && indent_of(buf[i]) > skey) continue @@ -444,9 +458,9 @@ apply_to_file() { if ($0 ~ LIST_RE && ind <= step_indent) { flush(); in_step = 0 } else if (plain($0) && ind < step_indent) { flush(); in_step = 0 } } - if (in_step) { buf[++n] = $0; lnum[n] = NR; next } + if (in_step) { buffer(); next } if ($0 ~ LIST_RE) { - if (parent_is_steps(ind)) { in_step = 1; step_indent = ind; n = 0; buf[++n] = $0; lnum[n] = NR; next } + if (parent_is_steps(ind)) { in_step = 1; step_indent = ind; n = 0; buffer(); next } # A list item whose parent is not `steps:` (a matrix, a fixture) is data. print; next } diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 5fb2f185..bb02bbc9 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -152,7 +152,7 @@ merge_duplicate_records() { # nothing stored, when an item is any other shape — a nested # collection, a key, an embedded quote. function flow_items(s, body, n, parts, i, it, k) { - body = s; sub(/^ uses:[[:space:]]*\[/, "", body); sub(/\][[:space:]]*(#.*)?$/, "", body) + body = s; sub(/^ ["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*\[/, "", body); sub(/\][[:space:]]*(#.*)?$/, "", body) if (body ~ /^[[:space:]]*$/) return 1 n = split(body, parts, ",") for (i = 1; i <= n; i++) { @@ -167,7 +167,7 @@ merge_duplicate_records() { # A record key is the dependency between its quotes — or a plain # scalar, as a hand-added record may write it (`actions/checkout@:`, # exactly the kind of record this fixer meets); the key is compared - # unquoted so `'a@sha':`, `"a@sha":` and `a@sha:` are one record, and + # unquoted so `'"'"'a@sha'"'"':`, `"a@sha":` and `a@sha:` are one record, and # the first spelling seen is the one written back. A record this # merger did not recognise was not kept: every line of it fell to the # catch-all below with no current record, and was dropped. @@ -199,10 +199,11 @@ merge_duplicate_records() { if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } nrec[key]++; cur = key; next } - # The nested list header, with or without a trailing comment (kept as - # first spelled); only lines inside a list it opens are its entries. - /^ uses:[[:space:]]*(#.*)?$/ { in_uses = 1; nlists[cur]++; if (!(cur in uses_head)) uses_head[cur] = $0; next } - /^ uses:[[:space:]]*\[.*\][[:space:]]*(#.*)?$/ && flow_items($0) { + # The nested list header, quoted or not, with or without a trailing + # comment (kept as first spelled); only lines inside a list it opens + # are its entries. + /^ ["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*(#.*)?$/ { in_uses = 1; nlists[cur]++; if (!(cur in uses_head)) uses_head[cur] = $0; next } + /^ ["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*\[.*\][[:space:]]*(#.*)?$/ && flow_items($0) { nlists[cur]++ if (!(cur in flow_line)) { flow_line[cur] = $0; c = $0; sub(/^.*\]/, "", c) @@ -210,8 +211,9 @@ merge_duplicate_records() { } next } - # Any other `uses:` value is one the merger cannot read. - /^ uses:/ { odd[cur] = 1 } + # Any other `uses:` value is one the merger cannot read. The key may + # be quoted (`"uses":`, `'"'"'uses'"'"':`), as `ref` and `commit` may. + /^ ["'"'"']?uses["'"'"']?[[:space:]]*:/ { odd[cur] = 1 } in_uses && uses_entry($0) { u = $0; sub(/^ - /, "", u); k = cur SUBSEP dep_id(entry_value($0)); if (!(k in uses_of)) uses_of[k] = u; next } # Any other line inside the list (a comment, a shape this merger has # no rule for) stays under the `uses:` header it came from: moved diff --git a/scripts/maintenance-hard-pass.sh b/scripts/maintenance-hard-pass.sh index 4c056297..88275c67 100755 --- a/scripts/maintenance-hard-pass.sh +++ b/scripts/maintenance-hard-pass.sh @@ -59,9 +59,18 @@ repo_name="$(basename "$repo")" ts="$(date -u +%Y%m%dT%H%M%SZ)" if [[ -z "$output" ]]; then - # Reports persist between runs; keep them in the user's state directory. - umask 077 + # Reports persist between runs; keep them in the user's state directory, + # private to this user. Only the report is: its directory and the empty + # report are created under umask 077, and the caller's umask is back in + # force before the runner starts, so whatever else the runner creates is + # as the caller would have it. The runner writes into the existing file, + # which keeps its mode. output="${XDG_STATE_HOME:-$HOME/.local/state}/gitbot-fleet/maintenance/${repo_name}-${ts}.json" + caller_umask=$(umask) + umask 077 + mkdir -p "$(dirname "$output")" + : > "$output" + umask "$caller_umask" fi mkdir -p "$(dirname "$output")" diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 62a7bb9c..cb8dea3d 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3211,6 +3211,37 @@ TAG_MAP="$fx/ls39-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls39 a tagged step value still classifies its record as an action: the ref gets its tag" \ "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls39/.github/workflows/actions.lock'" +# ── Review cases (gitbot-fleet#615, round on dcfca15) ─────────────────────── +# tc36: an alias names the anchor defined before it. A step whose `uses: +# *rust` follows `&rust dtolnay/rust-toolchain@` runs that reference, +# even when a later line of the same step defines `&rust` again: reported, +# exit 2, file untouched — not judged by the later definition and passed. +mkdir -p "$fx/tc36/.github/workflows" +cat > "$fx/tc36/.github/workflows/x.yml" <<'YML' +env: + RUST: &rust dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 +jobs: + check: + steps: + - uses: *rust + env: + OTHER: &rust actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 +YML +cp "$fx/tc36/.github/workflows/x.yml" "$fx/tc36-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc36" +check "tc36 an alias is judged by the anchor before it, not a later redefinition in the step" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6' <<<\"\$out\" && cmp -s '$fx/tc36-before.yml' '$fx/tc36/.github/workflows/x.yml'" + +# sp47: a record whose nested list key is quoted (`"uses":`, `'uses':`) is +# the same list: merging a renamed record into it leaves one `uses` key. +for qk in '"uses"' "'uses'"; do + d="$fx/sp47-$([ "$qk" = '"uses"' ] && echo dq || echo sq)" + sp_flow_fixture "$d" " $qk:\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\n" " uses:\n - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc'\n" + run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$d" + check "sp47 a quoted $qk key is the nested list: one uses key after the merge, both entries kept" \ + "[ \$rc -eq 3 ] && [ \$(grep -cE \"^ [\\\"']?uses[\\\"']?:\" '$d/.github/workflows/actions.lock') -eq 1 ] && grep -q 'ossf/scorecard-action@' '$d/.github/workflows/actions.lock' && grep -q 'actions/checkout@' '$d/.github/workflows/actions.lock'" +done + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" diff --git a/scripts/tests/fix-workflow-resources.sh b/scripts/tests/fix-workflow-resources.sh index 0e903ca8..bde85847 100755 --- a/scripts/tests/fix-workflow-resources.sh +++ b/scripts/tests/fix-workflow-resources.sh @@ -286,6 +286,7 @@ cat > "$maintenance/repo/scripts/maintenance/run-maintenance.sh" <<'SH' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$@" > "$MAINTENANCE_CASE/args" +umask > "$MAINTENANCE_CASE/umask" while [ "$#" -gt 0 ]; do case "$1" in --repo) shift 2 ;; @@ -306,6 +307,8 @@ report=$(cat "$maintenance/case/output-path") check "maintenance default report lives under XDG_STATE_HOME" \ test "$(dirname "$report")" = "$maintenance/state with spaces/gitbot-fleet/maintenance" check "maintenance default report is private despite the caller's umask" test "$(stat -c %a "$report")" = 600 +check "the maintenance runner runs under the caller's umask, not the report's" test "$(cat "$maintenance/case/umask")" = 0022 +check "the default report directory is private" test "$(stat -c %a "$(dirname "$report")")" = 700 check "maintenance passes the strict gate to its runner" grep -qx -- --strict "$maintenance/case/args" check "maintenance passes the warning gate to its runner" grep -qx -- --fail-on-warn "$maintenance/case/args" run_fix env XDG_STATE_HOME="$maintenance/unused-state" REPOS_BASE="$maintenance/no-repos" \ From 01ee2aa525add840d50d9175b36482076f60bdd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:59:08 +0000 Subject: [PATCH 38/44] fix(fix-scripts): CRLF locks and trailing spaces read as records; flow-step inputs prove no action; `- # comment` opens a step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix-lock-self-sha-refs: a record key with spaces after its colon — a carriage return included, in a lock with CRLF endings — was no record, so a self-SHA action ref passed as OK. Trailing whitespace is now no part of the key; the rewrite keeps the line's ending. - fix-stale-reusable-pin: on a CRLF lock every pass reads `$`-anchored lines and found no record, so the merger dropped every dependency record. The lock is now edited as LF and written back with CRLF; the result matches its LF twin line for line. - fix-lock-self-sha-refs: in a flow `steps: [...]` any `uses` beneath the sequence counted as an action site, so an input (`with: { uses: foo/bar/...@ }`) could prove a nested reusable an action and have its legitimate SHA ref rewritten. Only the `uses` of a mapping that is itself an item of the steps sequence counts. - fix-rust-toolchain-input: a step opened by `- # install Rust` took the comment for its first key, so its `uses:` failed the key-column check and the step was refused. A dash followed only by a comment is a bare opener. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 15 +++++++---- scripts/fix-rust-toolchain-input.sh | 5 ++-- scripts/fix-stale-reusable-pin.sh | 12 +++++++++ scripts/tests/fix-workflow-pins.sh | 42 +++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 70098393..d98d33db 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -158,8 +158,8 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A 0) d-- } if (item) return (d == 1 && kind[1] == "{") ? "A" : "" - for (k = 1; k <= d; k++) if (kind[k] == "[" && kname[k] == "steps") return "A" + # A step is a mapping that is itself an item of the `[` that + # `steps:` opens: its own `uses` is the action. A `uses` deeper + # inside it (`with: { uses: ... }`) is an input'"'"'s data. + if (d >= 2 && kind[d] == "{" && kname[d] == "" && kind[d - 1] == "[" && kname[d - 1] == "steps") return "A" if (d >= 1 && kind[d] == "{" && kname[d] != "") { if (d >= 2 && kind[d - 1] == "{" && kname[d - 1] == "jobs") return "R" if (d == 1 && parent == "jobs") return "R" @@ -354,8 +357,10 @@ tmp=$(mktemp); cp "$LOCK" "$tmp" # line spells it. records=$(mktemp) if ! awk ' - /^ ["'"'"']?[^"'"'"'@[:space:]#]+@[0-9a-fA-F]+["'"'"']?:([[:space:]]+#.*)?$/ { - s = $0; sub(/^ ["'"'"']?/, "", s); sub(/["'"'"']?:([[:space:]]+#.*)?$/, "", s) + # Trailing whitespace after the colon, a carriage return included (a + # lock with CRLF line endings), is no part of the key. + /^ ["'"'"']?[^"'"'"'@[:space:]#]+@[0-9a-fA-F]+["'"'"']?:([[:space:]]+#.*|[[:space:]]*)$/ { + s = $0; sub(/^ ["'"'"']?/, "", s); sub(/["'"'"']?:([[:space:]]+#.*|[[:space:]]*)$/, "", s) i = index(s, "@"); repo = substr(s, 1, i - 1); sha = substr(s, i + 1) if (length(sha) == 40) { inrec = 1; key_repo = repo; key_sha = sha } else inrec = 0 next diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 43be6356..e3bd9148 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -245,9 +245,10 @@ apply_to_file() { function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_empty_eol, tc_nested, tc_await, tc_flow_await, m, pre, post, delim, comment, v, w) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of - # `uses`), or for a bare `-` the indentation of the first plain line under it. + # `uses`), or for a bare `-` — alone, or followed only by a comment + # (`- # install Rust`) — the indentation of the first plain line under it. flow_item = (buf[1] ~ /^[[:space:]]*-[[:space:]]*\{/) - if (buf[1] ~ /^[[:space:]]*-[[:space:]]+[^[:space:]]/) skey = length(key_indent_of(buf[1])) + if (buf[1] ~ /^[[:space:]]*-[[:space:]]+[^[:space:]#]/) skey = length(key_indent_of(buf[1])) else { skey = -1 for (j = 2; j <= n; j++) if (plain(buf[j])) { skey = indent_of(buf[j]); break } diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index bb02bbc9..df10e72b 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -525,6 +525,14 @@ done < <(find "$REPO_PATH/.github/workflows" -maxdepth 1 -type f \( -name '*.yml # said, and the run ends 3 (the lock needs `gh actions-lock`), never 0. if [ -f "$LOCK" ] && [ -s "$edits" ]; then sort -u "$edits" -o "$edits" + # A lock with CRLF line endings is edited as LF — every pass below reads + # `$`-anchored lines, and a carriage return would hide each record and + # edge from them — and written back with CRLF once the edits are done. + lock_crlf=0 + if grep -q $'\r$' "$LOCK"; then + lock_crlf=1; tmp=$(mktemp) + sed 's/\r$//' "$LOCK" > "$tmp" && pin_fixer_replace "$tmp" "$LOCK" + fi while IFS=$'\t' read -r wfkey stale new keep repo; do # The workflow key in either quote style, or none (`gh actions-lock` # writes single quotes; a hand edit may not): compared unquoted. @@ -638,6 +646,10 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # A kept stale edge means the new edge was ADDED, so the stale record is # kept too (the branch above copies it); nothing else to do here. [ "$lock_touched" -eq 1 ] && merge_duplicate_records "$LOCK" + if [ "$lock_crlf" -eq 1 ]; then + tmp=$(mktemp) + sed 's/$/\r/' "$LOCK" > "$tmp" && pin_fixer_replace "$tmp" "$LOCK" + fi fi rm -f "$edits" diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index cb8dea3d..a1f582f7 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3242,6 +3242,48 @@ for qk in '"uses"' "'uses'"; do "[ \$rc -eq 3 ] && [ \$(grep -cE \"^ [\\\"']?uses[\\\"']?:\" '$d/.github/workflows/actions.lock') -eq 1 ] && grep -q 'ossf/scorecard-action@' '$d/.github/workflows/actions.lock' && grep -q 'actions/checkout@' '$d/.github/workflows/actions.lock'" done +# ── Review cases (gitbot-fleet#615, round on d83bce8) ─────────────────────── +# ls40: a record key followed by trailing spaces, in a lock with CRLF line +# endings, is a record: the self-SHA action ref gets its tag, CRLF kept. +mkdir -p "$fx/ls40/.github/workflows" +action_steps "$fx/ls40/.github/workflows" "foo/bar@$SHA_B" +printf "version: 'v0.0.3'\r\nworkflows:\r\n '.github/workflows/ci.yml':\r\n - 'foo/bar@%s'\r\ndependencies:\r\n 'foo/bar@%s': \r\n ref: '%s'\r\n commit: 'sha1-%s'\r\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls40/.github/workflows/actions.lock" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls40-tags.tsv" +TAG_MAP="$fx/ls40-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls40" --offline +check "ls40 a CRLF lock's record key with trailing spaces is a record: its ref gets its tag, CRLF kept" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\"\$'\\r'\"\$\" '$fx/ls40/.github/workflows/actions.lock' && [ \$(grep -c \$'\\r\$' '$fx/ls40/.github/workflows/actions.lock') -eq 8 ]" + +# ls41: in a flow `steps:` sequence only the step mapping's own `uses` is an +# action; a `uses` inside a step input (`with: { uses: ... }`) is data and +# proves nothing — that record stays UNRESOLVED, untouched, exit 1. +mkdir -p "$fx/ls41/.github/workflows" +printf 'jobs:\n build:\n steps: [{ uses: fixture/wrapper@v1, with: { uses: foo/bar/.github/workflows/x.yml@%s } }]\n' "$SHA_A" > "$fx/ls41/.github/workflows/ci.yml" +lock_records "$fx/ls41" "foo/bar@$SHA_A" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_A" > "$fx/ls41-tags.tsv" +cp "$fx/ls41/.github/workflows/actions.lock" "$fx/ls41-before.lock" +TAG_MAP="$fx/ls41-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls41" --offline +check "ls41 a uses inside a flow step's input proves no action: the record stays UNRESOLVED and untouched" \ + "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED foo/bar@aaaaaaa' <<<\"\$out\" && cmp -s '$fx/ls41-before.lock' '$fx/ls41/.github/workflows/actions.lock'" + +# tc37: a step opened by `- # comment`, its `uses:` on the next line, is the +# bare `-` layout: repaired, not reported UNSUPPORTED. +mkdir -p "$fx/tc37/.github/workflows" +printf 'jobs:\n check:\n steps:\n - # install Rust\n uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\n - run: cargo test\n' > "$fx/tc37/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc37" +check "tc37 a step opened by '- # comment' is repaired like a bare '-' step" \ + "[ \$rc -eq 0 ] && grep -q '^ with:\$' '$fx/tc37/.github/workflows/x.yml' && grep -q '^ toolchain: stable\$' '$fx/tc37/.github/workflows/x.yml'" + +# sp48: a lock with CRLF line endings is edited and merged as one with LF +# endings would be — no record dropped — and written back with CRLF. +for v in lf cr; do + sp_flow_fixture "$fx/sp48-$v" " uses:\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\n" " uses:\n - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc'\n" +done +sed -i 's/$/\r/' "$fx/sp48-cr/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp48-lf" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp48-cr" +check "sp48 a CRLF lock is repaired and merged exactly as its LF twin, and keeps CRLF" \ + "[ \$rc -eq 3 ] && tr -d '\\r' < '$fx/sp48-cr/.github/workflows/actions.lock' | cmp -s - '$fx/sp48-lf/.github/workflows/actions.lock' && [ \$(grep -c \$'\\r\$' '$fx/sp48-cr/.github/workflows/actions.lock') -eq \$(wc -l < '$fx/sp48-cr/.github/workflows/actions.lock') ] && grep -q 'ossf/scorecard-action@' '$fx/sp48-cr/.github/workflows/actions.lock'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From 7547098de7f53c3714cb56a68f4d9b61ecc8f8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:15:31 +0000 Subject: [PATCH 39/44] fix(fix-scripts): one lock sentinel with or without flock; escaped anchor values decoded; merger keys tolerate trailing spaces - pin-fixer-lock: a run with flock locked `pin-fixer-.lock` and one without locked `pin-fixer-.lock.d`, so a host and a minimal container sharing a checkout could both enter. Every fixer now holds the directory sentinel; one with flock takes the flock first as well. - lib/yaml-anchors.awk: a double-quoted anchor value kept its escapes raw, so `&call "\x68yperpolymath/..."` (YAML reads it as the dead reusable) resolved to text no reference regex matched and both fixers passed the alias as OK. Escapes are decoded as YAML reads them (ASCII as itself, anything else to a byte no reference holds); the toolchain fixer's prefilter lets a file holding such an escape through. - merge_duplicate_records: a record key with trailing spaces after its colon was no record, so the record and its fields could vanish from the rewritten lock. Keys and nested entries are read without trailing whitespace. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-rust-toolchain-input.sh | 6 ++-- scripts/fix-stale-reusable-pin.sh | 10 +++--- scripts/lib/pin-fixer-lock.sh | 46 ++++++++++++++------------ scripts/lib/yaml-anchors.awk | 36 ++++++++++++++++++--- scripts/tests/fix-workflow-pins.sh | 50 +++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 31 deletions(-) diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index e3bd9148..f3dcfad2 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -107,8 +107,10 @@ apply_to_file() { local f="$1" tmp rc [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } # GitHub resolves `Dtolnay/Rust-Toolchain` to the same action: the name is - # matched in either case here and in the parser below. - grep -qiE 'dtolnay/rust-toolchain@[0-9a-fA-F]{40}' "$f" || return 0 + # matched in either case here and in the parser below. A double-quoted + # scalar may spell it with escapes (`"\x64tolnay/..."`), which only the + # parser decodes, so a file holding such an escape is read too. + grep -qiE 'dtolnay/rust-toolchain@[0-9a-fA-F]{40}|\\[xuU][0-9a-fA-F]' "$f" || return 0 tmp=$(mktemp) set +e diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index df10e72b..bb1c3e06 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -182,19 +182,21 @@ merge_duplicate_records() { if (length(ref) == 40 && ref !~ /[^0-9A-Fa-f]/) ref = tolower(ref) return tolower(substr(s, 1, at)) ref } - function record_key(s) { sub(/[[:space:]]+#.*$/, "", s); return (s ~ /^ ["'"'"'][^"'"'"']+["'"'"']:$/ || s ~ /^ [^[:space:]"'"'"'#][^[:space:]]*:$/) } + function record_key(s) { sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return (s ~ /^ ["'"'"'][^"'"'"']+["'"'"']:$/ || s ~ /^ [^[:space:]"'"'"'#][^[:space:]]*:$/) } # A nested `uses:` entry, quoted or plain, with or without a trailing # comment; it is deduplicated on its value, the comment kept with # the first spelling seen. - function uses_entry(s) { return (s ~ /^ - ["'"'"'][^"'"'"']+["'"'"']([[:space:]]+#.*)?$/ || s ~ /^ - [^[:space:]"'"'"'#][^[:space:]]*([[:space:]]+#.*)?$/) } - function entry_value(s) { sub(/^ - /, "", s); sub(/[[:space:]]+#.*$/, "", s); return unq(s) } + # Trailing whitespace (a carriage return too) is no part of a key or + # an entry: `owner/repo@sha: ` is the record `owner/repo@sha`. + function uses_entry(s) { return (s ~ /^ - ["'"'"'][^"'"'"']+["'"'"']([[:space:]]+#.*|[[:space:]]*)$/ || s ~ /^ - [^[:space:]"'"'"'#][^[:space:]]*([[:space:]]+#.*|[[:space:]]*)$/) } + function entry_value(s) { sub(/^ - /, "", s); sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return unq(s) } BEGIN { indep = 0; cur = ""; in_uses = 0 } /^dependencies:[[:space:]]*$/ { indep = 1; print; next } !indep { print; next } # A record key or one of its own fields ends a nested `uses:` list. /^ [^[:space:]]/ || /^ [^[:space:]]/ { in_uses = 0 } record_key($0) { - key = $0; sub(/[[:space:]]+#.*$/, "", key); key = unq(key) + key = $0; sub(/[[:space:]]+#.*$/, "", key); sub(/[[:space:]]+$/, "", key); key = unq(key) sub(/^ +/, "", key); sub(/:$/, "", key); key = dep_id(key) if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } nrec[key]++; cur = key; next diff --git a/scripts/lib/pin-fixer-lock.sh b/scripts/lib/pin-fixer-lock.sh index 64bda3cf..60f6aba0 100644 --- a/scripts/lib/pin-fixer-lock.sh +++ b/scripts/lib/pin-fixer-lock.sh @@ -10,10 +10,12 @@ # repository before it reads anything: their edits never interleave (one's # `sed -i` landing between another's read and write). # -# Sourced, then `pin_fixer_lock `. The lock file lives outside the -# checkout, named by the checkout's real path, so every fixer run on the same -# checkout — by any path that reaches it — contends for the same file. It is -# held until the fixer exits. A holder that does not go away within +# Sourced, then `pin_fixer_lock `. The lock — a sentinel directory, +# `pin-fixer-.lock.d`, that every fixer holds, and with flock installed a +# `pin-fixer-.lock` file it takes first — lives outside the checkout, +# keyed by the checkout's real path, so every fixer run on the same checkout +# — by any path that reaches it, with or without flock — contends for the +# same sentinel. It is held until the fixer exits. A holder that does not go away within # PIN_FIXER_LOCK_WAIT seconds (default 600) is reported and the fixer exits 2 # having changed nothing: a dispatcher must not record that run as a clean # repair. @@ -41,29 +43,33 @@ pin_fixer_lock() { wait="${PIN_FIXER_LOCK_WAIT:-600}" key=$(cd "$repo" 2>/dev/null && pwd -P || printf '%s' "$repo") key=$(printf '%s' "$key" | sha256sum | cut -c1-16) - if ! command -v flock >/dev/null 2>&1; then - pin_fixer_lock_mkdir "$repo" "$dir/pin-fixer-$key.lock.d" "$wait" - return 0 + # Every fixer holds the directory sentinel; one that has flock takes the + # flock first, as well. A run with flock and one without (a host and a + # minimal container sharing the checkout) therefore contend for the same + # object, the directory, and still never hold the checkout together. + if command -v flock >/dev/null 2>&1; then + exec 9>"$dir/pin-fixer-$key.lock" + flock -w "$wait" 9 || { + echo "fix-workflow-pins: another workflow-pin fixer holds $repo (waited ${wait}s); nothing was changed" >&2 + exit 2 + } fi - exec 9>"$dir/pin-fixer-$key.lock" - flock -w "$wait" 9 || { - echo "fix-workflow-pins: another workflow-pin fixer holds $repo (waited ${wait}s); nothing was changed" >&2 - exit 2 - } + pin_fixer_lock_mkdir "$repo" "$dir/pin-fixer-$key.lock.d" "$wait" } -# Without flock the same lock is a directory: `mkdir` either creates it or -# fails because another fixer has, atomically, so two runs still never hold -# it together. The holder's EXIT trap removes it (an interrupt or termination -# exits through that trap too). A run killed outright leaves it behind; the -# next fixer then waits, reports the directory and its recorded holder, and -# exits 2 with nothing changed — it is never taken over silently, and never -# skipped. +# The lock every fixer holds is a directory: `mkdir` either creates it or +# fails because another fixer has, atomically, so two runs never hold it +# together, whether or not either has flock. The holder's EXIT trap removes +# it (an interrupt or termination exits through that trap too). A run killed +# outright leaves it behind; the next fixer then waits, reports the directory +# and its recorded holder, and exits 2 with nothing changed — it is never +# taken over silently (a recorded pid may belong to another pid namespace, so +# a holder cannot be judged gone), and never skipped. pin_fixer_lock_mkdir() { # local repo="$1" ldir="$2" wait="$3" waited=0 until mkdir "$ldir" 2>/dev/null; do if [ "$waited" -ge "$wait" ]; then - echo "fix-workflow-pins: another workflow-pin fixer holds $repo (waited ${wait}s; flock is not installed, so the lock is the directory $ldir, held by pid $(cat "$ldir/pid" 2>/dev/null || echo unknown) — remove it by hand only if that run is gone); nothing was changed" >&2 + echo "fix-workflow-pins: another workflow-pin fixer holds $repo (waited ${wait}s; the lock is the directory $ldir, held by pid $(cat "$ldir/pid" 2>/dev/null || echo unknown) — remove it by hand only if that run is gone); nothing was changed" >&2 exit 2 fi sleep 1 diff --git a/scripts/lib/yaml-anchors.awk b/scripts/lib/yaml-anchors.awk index a12169ff..bf0505bd 100644 --- a/scripts/lib/yaml-anchors.awk +++ b/scripts/lib/yaml-anchors.awk @@ -13,10 +13,36 @@ # `&call` written inside `NOTE: "fixture &call a/b@"`, inside a plain # scalar (`NOTE: fixture &call a/b@`) or after a `#` is text, not an # anchor. Its value is the scalar after it: a quoted one -# read to its closing quote (`\` escapes inside double quotes, `''` inside -# single ones), a plain one up to whitespace, `,`, `}`, `]` or a comment; "" -# when the anchor names a mapping or sequence that follows. The caller skips -# the content lines of block scalars (`run: |`), which are text throughout. +# read to its closing quote (`''` is a quote inside single quotes; inside +# double quotes each `\` escape is decoded, yaml_unescape/1), a plain one up +# to whitespace, `,`, `}`, `]` or a comment; "" when the anchor names a +# mapping or sequence that follows. The caller skips the content lines of +# block scalars (`run: |`), which are text throughout. +# +# The character a double-quoted escape stands for, `t` being the text after +# its backslash; esc_len is set to how much of `t` it spans. YAML resolves +# `"\x64tolnay/rust-toolchain@"` to the action itself, so an anchored +# reference is read decoded, never as the raw bytes that hide it. An ASCII +# character comes back as itself (`\x6f`, `\u006f`, `\"`, `\\`, `\/`, an +# escaped space or tab); anything else — a code point past ASCII, `\n`, an +# unknown or malformed escape — as "\001", which no action reference holds. +function yaml_unescape(t, e, n, k, d, code) { + e = substr(t, 1, 1); esc_len = 1 + n = (e == "x") ? 2 : (e == "u") ? 4 : (e == "U") ? 8 : 0 + if (n) { + code = 0 + for (k = 2; k <= n + 1; k++) { + d = index("0123456789abcdef", tolower(substr(t, k, 1))) - 1 + if (d < 0) return "\001" + code = code * 16 + d + } + esc_len = n + 1 + return (code >= 32 && code < 127) ? sprintf("%c", code) : "\001" + } + if (e == "\"" || e == "\\" || e == "/" || e == " ") return e + if (e == "t" || e == "\t") return "\t" + return "\001" +} function anchor_defs(s, defs, n, i, len, c, q, name, v, seen, depth) { split("", defs); n = 0; i = 1; q = ""; len = length(s); seen = ""; depth = 0 while (i <= len) { @@ -41,7 +67,7 @@ function anchor_defs(s, defs, n, i, len, c, q, name, v, seen, depth) { q = c; i++ while (i <= len) { c = substr(s, i, 1) - if (q == "\"" && c == "\\") { v = v substr(s, i, 2); i += 2; continue } + if (q == "\"" && c == "\\") { v = v yaml_unescape(substr(s, i + 1, 9)); i += 1 + esc_len; continue } if (c == q) { if (q == "'" && substr(s, i + 1, 1) == "'") { v = v "'"; i += 2; continue } break diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index a1f582f7..3feacd78 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3284,6 +3284,56 @@ run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp48-cr" check "sp48 a CRLF lock is repaired and merged exactly as its LF twin, and keeps CRLF" \ "[ \$rc -eq 3 ] && tr -d '\\r' < '$fx/sp48-cr/.github/workflows/actions.lock' | cmp -s - '$fx/sp48-lf/.github/workflows/actions.lock' && [ \$(grep -c \$'\\r\$' '$fx/sp48-cr/.github/workflows/actions.lock') -eq \$(wc -l < '$fx/sp48-cr/.github/workflows/actions.lock') ] && grep -q 'ossf/scorecard-action@' '$fx/sp48-cr/.github/workflows/actions.lock'" +# ── Review cases (gitbot-fleet#615, round on 01ee2aa) ─────────────────────── +# pl5: a fixer with flock and one without contend for the same sentinel. A +# run that has flock still waits on the directory a flock-less run holds, +# reports it and exits 2 with nothing changed; once it is free the run +# repairs and removes the sentinel it took. +mkdir -p "$fx/pl5/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/pl5/.github/workflows/ci.yml" +cp "$fx/pl5/.github/workflows/ci.yml" "$fx/pl5-before.yml" +pl5_key=$(cd "$fx/pl5" && pwd -P | tr -d '\n' | sha256sum | cut -c1-16) +mkdir "$fx/pin-fixer-$pl5_key.lock.d"; echo 4343 > "$fx/pin-fixer-$pl5_key.lock.d/pid" +PIN_FIXER_LOCK_DIR="$fx" PIN_FIXER_LOCK_WAIT=1 run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl5" +check "pl5 a run with flock waits on the sentinel a flock-less run holds: exit 2, nothing changed" \ + "[ \$rc -eq 2 ] && grep -q 'held by pid 4343' <<<\"\$out\" && cmp -s '$fx/pl5-before.yml' '$fx/pl5/.github/workflows/ci.yml'" +rm -rf "$fx/pin-fixer-$pl5_key.lock.d" +PIN_FIXER_LOCK_DIR="$fx" PIN_FIXER_LOCK_WAIT=1 run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/pl5" +check "pl5 with the sentinel free the run with flock repairs and removes the sentinel it took" \ + "[ \$rc -eq 0 ] && grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/pl5/.github/workflows/ci.yml' && [ ! -e '$fx/pin-fixer-$pl5_key.lock.d' ]" + +# sp49: a dependency record key followed by trailing spaces is a record: the +# merger keeps it, with its fields, when it rewrites the lock. +sp_flow_fixture "$fx/sp49" " uses:\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\n" " uses:\n - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc'\n 'other/action@3d3c42e5aac5ba805825da76410c181273ba90b1': \n ref: 'v7.0.1'\n commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp49" +check "sp49 a record key with trailing spaces survives the merge with its fields" \ + "[ \$rc -eq 3 ] && grep -q \"^ 'other/action@3d3c42e5aac5ba805825da76410c181273ba90b1':\" '$fx/sp49/.github/workflows/actions.lock' && grep -q \"^ ref: 'v7.0.1'\$\" '$fx/sp49/.github/workflows/actions.lock'" + +# tc38 / sp50: an anchor whose double-quoted value hides the reference behind +# YAML escapes (`"\x64tolnay/..."`) holds that reference, decoded as YAML +# reads it: the alias of it is reported (exit 2), never passed as OK. +mkdir -p "$fx/tc38/.github/workflows" "$fx/sp50/.github/workflows" +cat > "$fx/tc38/.github/workflows/x.yml" <<'YML' +env: + RUST: &rust "\x64tolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772" +jobs: + check: + steps: + - uses: *rust +YML +cp "$fx/tc38/.github/workflows/x.yml" "$fx/tc38-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc38" +check "tc38 an alias of an escaped double-quoted toolchain anchor is reported, exit 2, file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6' <<<\"\$out\" && cmp -s '$fx/tc38-before.yml' '$fx/tc38/.github/workflows/x.yml'" +printf 'env:\n CALL: &call "\\x68yperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s"\njobs:\n rust:\n uses: *call\n' "$STALE_A" > "$fx/sp50/.github/workflows/ci.yml" +cp "$fx/sp50/.github/workflows/ci.yml" "$fx/sp50-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp50" +check "sp50 an alias of an escaped double-quoted reusable anchor holding a dead pin is UNSUPPORTED, exit 2" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:5: .*known-dead pin held by the anchor' <<<\"\$out\" && cmp -s '$fx/sp50-before.yml' '$fx/sp50/.github/workflows/ci.yml'" +ya_out=$(printf '%s\n' 'A: &a "\x6fcto/r\u0065po@\"x\"\\\/"' 'B: &b "x\ny\U0001F600z"' | awk "$(cat "$fleet/scripts/lib/yaml-anchors.awk")"'{ anchor_defs($0, d); for (k in d) printf "%s=%s|", k, d[k] }') +check "yaml-anchors decodes ASCII escapes and maps anything else to a byte no reference holds" \ + "[ \"\$ya_out\" = \"\$(printf 'a=octo/repo@\"x\"\\\\/|b=x\\001y\\001z|')\" ]" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From 23e1337e2245bc6f0a2b85097f6ab623d10a2a70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:58:25 +0000 Subject: [PATCH 40/44] fix(fix-scripts): escaped references decoded; flow-mapping lock records kept or refused; column-0 flow calls never passed as data - lib/yaml-anchors.awk: yaml_decode_dq decodes a line's double-quoted scalars as YAML reads them (an escaped quote to a byte no reference holds, so the scalars a reader sees are unchanged). - fix-rust-toolchain-input.sh: a step's `uses: "\x64tolnay/..."` is the SHA-pinned toolchain step it spells, and gets its input; detection reads the decoded line. - fix-stale-reusable-pin.sh: - a dead reusable spelled with escapes in a double-quoted `uses:` value, or given as a quoted value continued below its key, is UNSUPPORTED (exit 2), never OK; - inside a flow collection opened above, a `uses:` key line the indentation reads as data is left unplaced, so the fallback reports it: `jobs: {` / `rust: {` / `uses:` all in column 0 is a call. The indented KYAML form is still rewritten; - merger: a record written as a flow mapping on its key line, or any other unread line at record indentation, is kept as written in its place. The merge is refused when that record's dependency is listed again, or when a folded-away duplicate holds a line with no rule. `workflows:` after `dependencies:` ends the section and is written after the records; - a `workflows:` key with trailing whitespace is that workflow's entry. - fix-lock-self-sha-refs.sh: a self-SHA record written as a flow mapping on its key line has its ref rewritten inside the braces; one spread over several lines is UNRESOLVED unless its SHA ref is one to keep. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 50 ++++++++++- scripts/fix-rust-toolchain-input.sh | 14 +-- scripts/fix-stale-reusable-pin.sh | 129 +++++++++++++++++++++++++--- scripts/lib/yaml-anchors.awk | 21 +++++ scripts/tests/fix-workflow-pins.sh | 120 ++++++++++++++++++++++++++ 5 files changed, 314 insertions(+), 20 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index d98d33db..f3ec5548 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -355,10 +355,37 @@ tmp=$(mktemp); cp "$LOCK" "$tmp" # `commit:` and the other fields — exactly as a hand-added record might write # it. Each finding carries the line of the `ref:` itself, and the ref as that # line spells it. +# A record may also be written as a flow mapping on its key line (`'a/b@': +# { ref: '', commit: 'sha1-' }`): its `ref` entry is read from the +# braces, and rewritten there (kind I). One whose braces do not close on the +# key line is not read (kind M): it is reported unless its SHA ref is one to +# keep, never passed over as no record at all. Block records are kind B. records=$(mktemp) -if ! awk ' +if ! awk -v sq="'" ' # Trailing whitespace after the colon, a carriage return included (a # lock with CRLF line endings), is no part of the key. + # The record key of a flow-mapping record line: repo and SHA, or 0. + function flow_key(s, t, i) { + t = s; sub(/^ ["'"'"']?/, "", t) + if (!match(t, /^[^"'"'"'@[:space:]#]+@[0-9a-fA-F]+["'"'"']?[[:space:]]*:[[:space:]]*\{/)) return 0 + t = substr(t, 1, RLENGTH); sub(/["'"'"']?[[:space:]]*:[[:space:]]*\{$/, "", t) + i = index(t, "@"); f_repo = substr(t, 1, i - 1); f_sha = substr(t, i + 1) + return length(f_sha) == 40 + } + flow_key($0) { + inrec = 0 + body = $0; sub(/^[^{]*\{/, "", body) + if (body !~ /\}[[:space:]]*(#.*)?$/) { print NR "\tM\t" f_repo "\t" f_sha "\t"; next } + # The `ref` entry: after `{` or `,`, its key quoted or not, its value + # quoted or plain up to `,` or `}`. + r = "{" body + if (match(r, "[{,][[:space:]]*[\"" sq "]?ref[\"" sq "]?[[:space:]]*:[[:space:]]*[^,}]*")) { + r = substr(r, RSTART, RLENGTH); sub(/^[{,][[:space:]]*["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*/, "", r) + gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) + if (tolower(r) == tolower(f_sha)) print NR "\tI\t" f_repo "\t" f_sha "\t" r + } + next + } /^ ["'"'"']?[^"'"'"'@[:space:]#]+@[0-9a-fA-F]+["'"'"']?:([[:space:]]+#.*|[[:space:]]*)$/ { s = $0; sub(/^ ["'"'"']?/, "", s); sub(/["'"'"']?:([[:space:]]+#.*|[[:space:]]*)$/, "", s) i = index(s, "@"); repo = substr(s, 1, i - 1); sha = substr(s, i + 1) @@ -370,13 +397,13 @@ if ! awk ' # Quoted either way, or a plain scalar, in either case of hex: the # same ref to GitHub. The key may be quoted too (`"ref": ...`). r = $0; sub(/^ ["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*/, "", r); sub(/[[:space:]]+#.*$/, "", r); gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) - if (tolower(r) == tolower(key_sha)) print NR "\t" key_repo "\t" key_sha "\t" r + if (tolower(r) == tolower(key_sha)) print NR "\tB\t" key_repo "\t" key_sha "\t" r } ' "$LOCK" > "$records"; then echo "fix-lock-self-sha-refs: could not parse $LOCK (awk failed); lock left unchanged" >&2 rm -f "$tmp" "$records" "$SITES"; exit 2 fi -while IFS=$'\t' read -r lineno repo sha ref_raw; do +while IFS=$'\t' read -r lineno kind repo sha ref_raw; do sha_lc="${sha,,}" case "$(sha_ref_disposition "$repo" "$sha_lc")" in keep) continue ;; @@ -384,6 +411,10 @@ while IFS=$'\t' read -r lineno repo sha ref_raw; do echo "UNRESOLVED $repo@${sha:0:7}: no workflow here uses it (a transitive dependency), so whether it is an action or a reusable workflow cannot be told; record left as is" >&2 unresolved=$((unresolved + 1)); continue ;; esac + if [ "$kind" = M ]; then + echo "UNRESOLVED $repo@${sha:0:7}: the record is a flow mapping spread over several lines (line $lineno), which this fixer does not read; check its ref by hand (record left as is)" >&2 + unresolved=$((unresolved + 1)); continue + fi tag=$(lookup_tag "$repo" "$sha_lc") if [ -z "$tag" ]; then echo "UNRESOLVED $repo@${sha:0:7}: no tag found for this commit (record left as is; GitHub rejects it)" >&2 @@ -401,6 +432,19 @@ while IFS=$'\t' read -r lineno repo sha ref_raw; do # A plain field whose tag YAML would read as a non-string (`true`, `1`, # `null`) is written quoted, so the lock keeps a tag, not a boolean. q="" + if [ "$kind" = I ]; then + # The `ref` entry inside the braces, its value as written (quoted or + # plain) up to the `,` or `}` that ends it; nothing else on the line. + if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "[{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*[\"']"; then q="'"; fi + before=$(sed -n "${lineno}p" "$tmp") + sed -i -E "${lineno}s~([{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*)([\"']?)$ref_raw\2([[:space:]]*[,}])~\1\2$q$tag$q\2\3~" "$tmp" + if [ "$(sed -n "${lineno}p" "$tmp")" = "$before" ]; then + echo "UNRESOLVED $repo@${sha:0:7}: the ref inside the record's flow mapping (line $lineno) is not written in a form this fixer rewrites; set it to $tag by hand (record left as is)" >&2 + unresolved=$((unresolved + 1)); continue + fi + echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" + fixed=$((fixed + 1)); continue + fi if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "^ [\"']?ref[\"']?[[:space:]]*:[[:space:]]*[\"']"; then q="'"; fi sed -i "${lineno}s|^\( ['\"]\{0,1\}ref['\"]\{0,1\}[[:space:]]*:[[:space:]]*\)\(['\"]\{0,1\}\)$ref_raw\2|\1\2$q$tag$q\2|" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index f3dcfad2..9c197997 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -171,10 +171,12 @@ apply_to_file() { # The 40 hex characters must END the ref: `@-patched` is a tag # that merely begins with one, and carries its own channel. Either # case, for the commit ID (git resolves upper-case hex too) and for the - # action name (GitHub resolves `Dtolnay/Rust-Toolchain` too). + # action name (GitHub resolves `Dtolnay/Rust-Toolchain` too). The line + # is read as YAML reads it: a double-quoted value may spell the + # reference with escapes (`"\x64tolnay/..."`), lib/yaml-anchors.awk. function sha_ref(s, t, m, nxt) { if (!plain(s)) return 0 - t = tolower(s); sub(/[[:space:]]#.*$/, "", t) + t = tolower(yaml_decode_dq(s)); sub(/[[:space:]]#.*$/, "", t) if (!match(t, /dtolnay\/rust-toolchain@[0-9a-f]+/)) return 0 m = substr(t, RSTART + 23, RLENGTH - 23) nxt = substr(t, RSTART + RLENGTH, 1) @@ -204,7 +206,7 @@ apply_to_file() { # A SHA pin that is the value of a `uses` key, block or flow: the # only pin the parser can be asked to place. The same text inside # another key'"'"'s string (`name: "fixture dtolnay/...@"`) is data. - function uses_pin(s) { return sha_ref(s) && tolower(site_view(s)) ~ USES_SITE_RE } + function uses_pin(s) { return sha_ref(s) && tolower(site_view(yaml_decode_dq(s))) ~ USES_SITE_RE } # Buffer line $0 of the current step as buf[n], with the verdict on # its `uses:` alias, if any, as the anchors read so far give it — # YAML resolves an alias to the latest anchor of that name BEFORE it, @@ -215,7 +217,7 @@ apply_to_file() { # matched in lower case, the name is taken as written. function buffer( v, m) { buf[++n] = $0; lnum[n] = NR; alias_state[n] = "" - v = site_view($0) + v = site_view(yaml_decode_dq($0)) if (match(tolower(v), USES_ALIAS_RE)) { m = substr(v, RSTART, RLENGTH); sub(/^.*\*/, "", m) alias_state[n] = ((m in tc_anchor) || !(m in any_anchor)) ? "bad" : "ok" @@ -265,7 +267,7 @@ apply_to_file() { # (`uses: >-` then `dtolnay/rust-toolchain@`): the key line # names no action and the value reads as nested text, so a SHA # pin there is reported, never passed over as a step that is fine. - if (!flow_item && length(key_indent_of(buf[i])) == skey && tolower(site_view(buf[i])) ~ USES_OPEN_RE) + if (!flow_item && length(key_indent_of(buf[i])) == skey && tolower(site_view(yaml_decode_dq(buf[i]))) ~ USES_OPEN_RE) for (j = i + 1; j <= n && !(plain(buf[j]) && indent_of(buf[j]) <= skey); j++) if (sha_ref(buf[j])) unrec[++nunrec] = lnum[j] # The step'"'"'s own `uses:` given as an alias (`uses: *rust`) of an @@ -278,7 +280,7 @@ apply_to_file() { # line of the step defines again under the same name. if ((flow_item || length(key_indent_of(buf[i])) == skey) && alias_state[i] == "bad") unrec[++nunrec] = lnum[i] if (!sha_ref(buf[i])) continue - if (!flow_item && tolower(buf[i]) ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } + if (!flow_item && tolower(yaml_decode_dq(buf[i])) ~ USES_RE && length(key_indent_of(buf[i])) == skey) { if (!uses_i) uses_i = i; continue } if (!flow_item && i > 1 && indent_of(buf[i]) > skey) continue if (!uses_pin(buf[i])) continue unrec[++nunrec] = lnum[i] diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index bb1c3e06..8bb21baa 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -124,6 +124,17 @@ rewrites=0; lock_touched=0 # an alias, a scalar) is not folded at all: merging it could drop its entries # or leave two `uses:` keys. The merger then changes nothing, names the record # (UNSUPPORTED) and sets merge_refused. +# +# A record written as a flow mapping on its key line (`'a/b@': { ref: +# ..., commit: ... }`), or any other line at record indentation that is no +# record key this merger reads, is kept as written, in its place, a record of +# its own: never folded, never moved into the record before it. It refuses +# instead, changing nothing, when the dependency such a record names is +# listed again (a fold could not reach it, and the lock would keep the key +# twice), and when a duplicate being folded away holds a line it has no rule +# for (folded, that line would be lost). A top-level key after +# `dependencies:` (`workflows:` written last) ends the section, and what +# follows it is written after the records, as it stood. merge_duplicate_records() { local lock="$1" tmp tmp=$(mktemp) @@ -190,8 +201,18 @@ merge_duplicate_records() { # an entry: `owner/repo@sha: ` is the record `owner/repo@sha`. function uses_entry(s) { return (s ~ /^ - ["'"'"'][^"'"'"']+["'"'"']([[:space:]]+#.*|[[:space:]]*)$/ || s ~ /^ - [^[:space:]"'"'"'#][^[:space:]]*([[:space:]]+#.*|[[:space:]]*)$/) } function entry_value(s) { sub(/^ - /, "", s); sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return unq(s) } - BEGIN { indep = 0; cur = ""; in_uses = 0 } + # The dependency an unread record line names, or "": its key quoted + # either way, or plain, before the colon (sq is the single quote). + function line_dep(s, t) { + t = s; sub(/^ /, "", t) + if (match(t, "^(\"[^\"]*\"|" sq "[^" sq "]*" sq ")[[:space:]]*:")) { t = substr(t, 1, RLENGTH); sub(/[[:space:]]*:$/, "", t); return dep_id(unq(t)) } + if (match(t, "^[^[:space:]\"" sq "#][^[:space:]]*:([[:space:]]|$)")) { t = substr(t, 1, RLENGTH); sub(/:[[:space:]]*$/, "", t); return dep_id(t) } + return "" + } + BEGIN { indep = 0; cur = ""; in_uses = 0; after = 0 } + after { tail[++ntail] = $0; next } /^dependencies:[[:space:]]*$/ { indep = 1; print; next } + indep && /^[^[:space:]#]/ { after = 1; tail[++ntail] = $0; next } !indep { print; next } # A record key or one of its own fields ends a nested `uses:` list. /^ [^[:space:]]/ || /^ [^[:space:]]/ { in_uses = 0 } @@ -201,6 +222,14 @@ merge_duplicate_records() { if (!(key in seen)) { seen[key] = 1; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; first = 1 } else { first = 0 } nrec[key]++; cur = key; next } + /^ [^[:space:]#]/ { + key = "\034" NR; order[++norder] = key; spelled[key] = $0; nhead[key] = 0; nrec[key] = 1 + d = line_dep($0); if (d != "") { opaque_dep[key] = d; nopaque[d]++ } + cur = key; first = 1; next + } + # What stands between `dependencies:` and its first record (a + # comment) stays there. + cur == "" { print; next } # The nested list header, quoted or not, with or without a trailing # comment (kept as first spelled); only lines inside a list it opens # are its entries. @@ -224,8 +253,11 @@ merge_duplicate_records() { /^ [a-z_]+: / { if (first) { head[cur, ++nhead[cur]] = $0 } ; next } { # A line the merger does not understand is not the merger'"'"'s to drop: - # it is kept where it stands, under the record it follows. + # it is kept where it stands, under the record it follows. Under a + # duplicate being folded away it has nowhere to stand: unless it is + # blank or a comment, the merger refuses. if (first) { head[cur, ++nhead[cur]] = $0 } + else if ($0 !~ /^[[:space:]]*(#.*)?$/) { lost[cur] = lost[cur] (lost[cur] == "" ? "" : "; ") NR ": " $0 } next } END { @@ -234,11 +266,24 @@ merge_duplicate_records() { printf "UNSUPPORTED .github/workflows/actions.lock: the record %s is listed %d times and its uses: is a shape the merger cannot fold (a flow list over several lines, an alias, a scalar); the duplicates were left in place. Merge them by hand.\n", name, nrec[order[k]] > "/dev/stderr" refused = 1 } + for (k = 1; k <= norder; k++) { + cur = order[k] + if (cur in lost) { + name = spelled[cur]; sub(/[[:space:]]+#.*$/, "", name); name = unq(name); sub(/^ +/, "", name); sub(/:$/, "", name) + printf "UNSUPPORTED .github/workflows/actions.lock: the record %s is listed %d times and a duplicate holds a line the merger has no rule for (line %s); the duplicates were left in place. Merge them by hand.\n", name, nrec[cur], lost[cur] > "/dev/stderr" + refused = 1 + } + if ((cur in opaque_dep) && (nopaque[opaque_dep[cur]] > 1 || (opaque_dep[cur] in seen))) { + printf "UNSUPPORTED .github/workflows/actions.lock: %s is a record the merger cannot read (written as a flow mapping?) and its dependency is listed again; the duplicates were left in place. Merge them by hand.\n", spelled[cur] > "/dev/stderr" + refused = 1 + } + } if (refused) exit 4 for (k = 1; k <= norder; k++) { cur = order[k] emit_record() } + for (k = 1; k <= ntail; k++) print tail[k] } ' "$lock" > "$tmp"; then rm -f "$tmp"; merge_refused=1; return 0 @@ -273,10 +318,15 @@ dead_ref_in_file() { # [ -n "$ref" ] || continue sha="${ref##*@}" lookup_replacement "${ref%@*}" "${sha,,}" && return 0 - done < <(grep -oiE "$REUSABLE_REF_RE" "$1" || true) + done < <(decode_dq < "$1" | grep -oiE "$REUSABLE_REF_RE" || true) return 1 } +# Lines on stdin as YAML reads their double-quoted scalars: escapes decoded +# (`"\x68yperpolymath/..."` is the reference it spells), every other byte as +# written (lib/yaml-anchors.awk, yaml_decode_dq). +decode_dq() { awk "$ANCHORS_AWK"'{ print yaml_decode_dq($0) }'; } + # Print YAML context markers for workflow file $1, one per line that matters: # K line n is a `uses:` key of a job under `jobs:` — the one place a # reusable is called; @@ -286,14 +336,48 @@ dead_ref_in_file() { # # its own line (`uses: >-`, or `uses:` then the reference below): the # call is there, but not where the rewrite reads it. # A line with none of these that still carries a known-dead pin is one the -# parser cannot place (a flow-style job `rust: { uses: ... }`). +# parser cannot place (a flow-style job `rust: { uses: ... }`). Keys are +# placed by indentation, which says nothing inside a flow collection opened +# on an earlier line: there a `uses:` key line is K when its indentation +# reads a job key (`rust: {` then ` uses: ...`, as KYAML indents it), and +# otherwise is left unplaced — never D. `jobs: {`, `rust: {`, `uses: ...` may +# all stand in column 0, and that call must be reported, not passed as data. yaml_context() { # - awk ' + awk -v sq="'" ' # Return the number of leading spaces in source line s. function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } # Return true when s is neither blank nor a full-line comment. function plain(s) { return (s !~ /^[[:space:]]*$/ && s !~ /^[[:space:]]*#/) } + # Can a scalar start after `seen` (the line so far) at flow depth d? + # Where a value starts (`key: `, a list dash, past any anchor or tag), + # or inside a flow collection after `[`, `{`, `,` or `:`. + function scalar_start(seen, d) { + if (seen ~ VALUE_START_RE) return 1 + return d > 0 && seen ~ /[][{,:][[:space:]]*([&!][^[:space:]]*[[:space:]]+)*$/ + } + # The flow depth line s leaves, from depth d at its start: `{` and `[` + # open a collection where a value starts or inside one already open, + # `}` and `]` close one; quoted scalars and a comment are skipped. + # (Hypatia reads depth so: PinSites.line_depths/1.) + function depth_after(s, d, i, n, c, j, seen) { + n = length(s); i = 1; seen = "" + while (i <= n) { + c = substr(s, i, 1) + if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break + if ((c == "\"" || c == sq) && scalar_start(seen, d)) { + j = i + 1 + if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } + else { while (j <= n && substr(s, j, 1) != sq) j++ } + seen = seen "x"; i = j + 1; continue + } + if ((c == "{" || c == "[") && (d > 0 || seen ~ VALUE_START_RE)) d++ + else if ((c == "}" || c == "]") && d > 0) d-- + seen = seen c; i++ + } + return d + } BEGIN { + VALUE_START_RE = "(^[[:space:]]*(-[[:space:]]+)*|:[[:space:]]+)([&!][^[:space:]]*[[:space:]]+)*$" Q = "[\"'"'"']?" KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" ITEM_KEY_RE = "^[[:space:]]*-[[:space:]]+" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" @@ -306,6 +390,7 @@ yaml_context() { # if (in_scalar) { if (plain($0) && ind <= scalar_indent) in_scalar = 0; else { print (in_uses ? "U" : "S") NR; next } } in_uses = 0 if (!plain($0)) next + d0 = depth; depth = depth_after($0, depth) if ($0 ~ KEY_RE) { key = $0; sub(/^[[:space:]]*/, "", key); sub(/[[:space:]]*:.*$/, "", key); gsub(/["'"'"']/, "", key) for (k in keyat) if (k + 0 >= ind) delete keyat[k] @@ -316,7 +401,7 @@ yaml_context() { # print "K" NR # The value continues below the key: its lines are the call. if ($0 ~ OPEN_RE) { in_scalar = 1; scalar_indent = ind; in_uses = 1; keyat[ind] = key; next } - } else print "D" NR + } else if (d0 == 0) print "D" NR } keyat[ind] = key if ($0 ~ SCALAR_RE) { in_scalar = 1; scalar_indent = ind } @@ -351,7 +436,8 @@ FALLBACK_REF_END="([\"'[:space:],}]|]|$)" # looked up in lower case and rewritten as written. REUSABLE_REF_RE="(${TABLE_OWNERS_RE})/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-fA-F]{40}" # A line with its comment dropped and every quoted scalar that is neither a -# key nor the value of a `uses` key (past any anchor or tag) emptied, quote-aware. The fallback scan +# key, the value of a `uses` key (past any anchor or tag) nor the whole start +# of the line (a value continued from the key above it) emptied, quote-aware. The fallback scan # for pins the parser cannot place reads this, so a dead pin named in a # trailing comment or inside a `name:` string is data, not an UNSUPPORTED # caller. @@ -366,7 +452,7 @@ mask_line() { j = i + 1 if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } else { while (j <= n && substr(s, j, 1) != sq) j++ } - if (substr(s, j + 1) ~ /^[[:space:]]*:/ || tolower(out) ~ tail) out = out substr(s, i, j - i + 1) + if (substr(s, j + 1) ~ /^[[:space:]]*:/ || tolower(out) ~ tail || out ~ /^[[:space:]]*$/) out = out substr(s, i, j - i + 1) else out = out c c i = j + 1; continue } @@ -454,8 +540,29 @@ while IFS= read -r -d '' wf; do echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin in a \`uses:\` value continued below its key (a block scalar such as \`uses: >-\`). Fix by hand; the file was not changed." >&2 skip=1 fi - done < <(mask_line "$(sed -n "${lineno}p" "$wf")" | grep -oiE "${REUSABLE_REF_RE}${FALLBACK_REF_END}" | grep -oiE "$REUSABLE_REF_RE" || true) + done < <(mask_line "$(sed -n "${lineno}p" "$wf" | decode_dq)" | grep -oiE "${REUSABLE_REF_RE}${FALLBACK_REF_END}" | grep -oiE "$REUSABLE_REF_RE" || true) done + # A double-quoted value may spell the reference with escapes (`uses: + # "\x68yperpolymath/..."`): GitHub runs the decoded call, but the rewrite + # and the scan above read the bytes as written, find no pin, and would + # report the file clean. Every line outside data and text that holds an + # escape is read decoded; a known-dead pin found so at a `uses` key is + # reported, never rewritten through its escapes. + textual=$(grep -oE '^[DSU][0-9]*' <<<"$ctx" | tr -d DSU || true) + while IFS= read -r numbered; do + lineno="${numbered%%:*}" + grep -qx "$lineno" <<<"$textual" && continue + decoded=$(printf '%s\n' "${numbered#*:}" | decode_dq) + [ "$decoded" != "${numbered#*:}" ] || continue + while IFS= read -r ref; do + [ -n "$ref" ] || continue + sha="${ref##*@}" + if lookup_replacement "${ref%@*}" "${sha,,}"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: $ref is a known-dead pin written with escapes in a double-quoted \`uses\` value; the rewrite edits a reference as written only. Fix by hand; the file was not changed." >&2 + skip=1 + fi + done < <(mask_line "$decoded" | grep -oiE "$FALLBACK_USES_RE" | grep -oiE "$REUSABLE_REF_RE" || true) + done < <(grep -n "[\\]" "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) # A job's `uses:` given as an alias (`uses: *call`) runs the anchor's # value, defined elsewhere in the file (`CALL: &call owner/repo/...@`): # the rewrite reads no reference on the key line. A known-dead pin as @@ -542,7 +649,7 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # either case); every line keeps the spelling it has. if ! awk -v wfkey="$wfkey" -v edge="$repo@$stale" ' function unq(s) { gsub(/["'"'"']/, "", s); return s } - function keyv(s) { sub(/[[:space:]]+#.*$/, "", s); return unq(s) } + function keyv(s) { sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return unq(s) } function bare(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+(#.*)?$/, "", s); return s } BEGIN { edge = tolower(edge) } /^workflows:[[:space:]]*$/ { sec = "w" } @@ -563,7 +670,7 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # never given it a second time. awk -v wfkey="$wfkey" -v stale="$stale" -v new="$new" -v keep="$keep" -v repo="$repo" ' function unq(s) { gsub(/["'"'"']/, "", s); return s } - function keyv(s) { sub(/[[:space:]]+#.*$/, "", s); return unq(s) } + function keyv(s) { sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return unq(s) } # An edge compared by its value: the list dash, a trailing # comment, quotes and case are not part of it. function edge_val(s) { sub(/^ +- /, "", s); sub(/[[:space:]]+#.*$/, "", s); sub(/[[:space:]]+$/, "", s); return tolower(unq(s)) } diff --git a/scripts/lib/yaml-anchors.awk b/scripts/lib/yaml-anchors.awk index bf0505bd..78717f75 100644 --- a/scripts/lib/yaml-anchors.awk +++ b/scripts/lib/yaml-anchors.awk @@ -43,6 +43,27 @@ function yaml_unescape(t, e, n, k, d, code) { if (e == "t" || e == "\t") return "\t" return "\001" } +# Line s as YAML reads its double-quoted scalars: each `\` escape decoded +# (yaml_unescape), every other byte kept as written, so a reference spelled +# with escapes (`uses: "\x64tolnay/rust-toolchain@"`) is matched as +# the reference it is. An escaped quote decodes to "\001", never to `"`: the +# quotes that delimit a scalar stay the only ones, and a reader of the line +# sees the same scalars. Single-quoted scalars have no escapes; a comment is +# copied as written. +function yaml_decode_dq(s, out, i, len, c, q) { + out = ""; i = 1; len = length(s); q = "" + while (i <= len) { + c = substr(s, i, 1) + if (q == "\"" && c == "\\") { + c = yaml_unescape(substr(s, i + 1, 9)); out = out (c == "\"" ? "\001" : c); i += 1 + esc_len; continue + } + if (q != "") { if (c == q) q = "" } + else if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) { out = out substr(s, i); break } + else if (c == "\"" || c == "'") q = c + out = out c; i++ + } + return out +} function anchor_defs(s, defs, n, i, len, c, q, name, v, seen, depth) { split("", defs); n = 0; i = 1; q = ""; len = length(s); seen = ""; depth = 0 while (i <= len) { diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 3feacd78..8954635f 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3334,6 +3334,126 @@ ya_out=$(printf '%s\n' 'A: &a "\x6fcto/r\u0065po@\"x\"\\\/"' 'B: &b "x\ny\U0001F check "yaml-anchors decodes ASCII escapes and maps anything else to a byte no reference holds" \ "[ \"\$ya_out\" = \"\$(printf 'a=octo/repo@\"x\"\\\\/|b=x\\001y\\001z|')\" ]" +# ── Review cases (gitbot-fleet#615, round on 7547098) ─────────────────────── +# sp51: a dependency record written as a flow mapping on its key line, after +# the duplicate the merger folds away, is a record of its own: kept as +# written, in its place, never dropped with the duplicate's lines. +sp51_inline=" 'other/action@3d3c42e5aac5ba805825da76410c181273ba90b1': { ref: 'v7.0.1', commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' }" +sp_flow_fixture "$fx/sp51" " uses:\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'\n" " uses:\n - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc'\n$sp51_inline\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp51" +check "sp51 a flow-mapping record after a folded duplicate survives the merge as written" \ + "[ \$rc -eq 3 ] && grep -qxF \"\$sp51_inline\" '$fx/sp51/.github/workflows/actions.lock' && [ \$(grep -c \"^ 'hyperpolymath/standards@$GOOD':\" '$fx/sp51/.github/workflows/actions.lock') -eq 1 ]" +# sp51b: a flow-mapping record naming the very dependency being folded +# cannot be folded with it: refused, named, nothing of it lost. +sp51b_inline=" 'hyperpolymath/standards@$GOOD': { ref: '$GOOD', commit: 'sha1-$GOOD' }" +sp_flow_fixture "$fx/sp51b" " uses: []\n" " uses: []\n$sp51b_inline\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp51b" +check "sp51b a flow-mapping record of the dependency being folded is refused, named, and kept" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .*actions.lock: .*hyperpolymath/standards@$GOOD.*cannot read' <<<\"\$out\" && grep -qxF \"\$sp51b_inline\" '$fx/sp51b/.github/workflows/actions.lock'" +# sp51c: a duplicate holding a line the merger has no rule for is not folded +# with that line lost: refused, named, the line kept. +sp_flow_fixture "$fx/sp51c" " uses: []\n" " uses: []\n provenance:\n - 'x'\n" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp51c" +check "sp51c a duplicate holding a line the merger has no rule for is refused, named, the line kept" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .*the record hyperpolymath/standards@$GOOD is listed 2 times and a duplicate holds a line' <<<\"\$out\" && grep -q '^ provenance:\$' '$fx/sp51c/.github/workflows/actions.lock'" +# sp51d: `workflows:` written after `dependencies:` ends that section: the +# merged records come first, the workflow edges after them, as they stood. +sp_flow_fixture "$fx/sp51d" " uses: []\n" " uses: []\n" +awk '/^workflows:/ { w = 1 } /^dependencies:/ { w = 0 } w { wf = wf $0 "\n"; next } { print } END { printf "%s", wf }' "$fx/sp51d/.github/workflows/actions.lock" > "$fx/sp51d.lock" && mv "$fx/sp51d.lock" "$fx/sp51d/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp51d" +check "sp51d workflows: after dependencies: stays a section after the merged records, its edges moved" \ + "[ \$rc -eq 3 ] && [ \$(grep -c \"^ 'hyperpolymath/standards@$GOOD':\" '$fx/sp51d/.github/workflows/actions.lock') -eq 1 ] && tail -5 '$fx/sp51d/.github/workflows/actions.lock' | head -1 | grep -qx 'workflows:' && [ \$(grep -c \"^ - 'hyperpolymath/standards@$GOOD'\$\" '$fx/sp51d/.github/workflows/actions.lock') -eq 2 ]" + +# sp52: a `workflows:` key followed by trailing spaces is that workflow's +# entry: its edge moves with the caller, no out-of-sync warning. +mkdir -p "$fx/sp52/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp52/.github/workflows/ci.yml" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml': \n - 'hyperpolymath/standards@%s'\ndependencies:\n 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" > "$fx/sp52/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp52" +check "sp52 a workflows: key with trailing spaces has its edge moved, no out-of-sync warning" \ + "[ \$rc -eq 3 ] && ! grep -q 'has no edge' <<<\"\$out\" && grep -q \"^ - 'hyperpolymath/standards@$GOOD'\$\" '$fx/sp52/.github/workflows/actions.lock' && ! grep -q \"$STALE_A\" '$fx/sp52/.github/workflows/actions.lock'" + +# sp53: a job's `uses:` that names a dead reusable through double-quoted +# escapes, or as a quoted value continued below its key, is that call: the +# rewrite cannot reach either, so each is reported (exit 2), never OK. +mkdir -p "$fx/sp53/.github/workflows" "$fx/sp53b/.github/workflows" "$fx/sp53c/.github/workflows" +printf 'jobs:\n rust:\n uses: "\\x68yperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s"\n' "$STALE_A" > "$fx/sp53/.github/workflows/ci.yml" +printf 'jobs:\n rust:\n uses:\n "hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s"\n' "$STALE_A" > "$fx/sp53b/.github/workflows/ci.yml" +cp "$fx/sp53/.github/workflows/ci.yml" "$fx/sp53-before.yml"; cp "$fx/sp53b/.github/workflows/ci.yml" "$fx/sp53b-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp53" +check "sp53 a dead reusable spelled with escapes in a double-quoted uses: is UNSUPPORTED, exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:3: .*written with escapes' <<<\"\$out\" && cmp -s '$fx/sp53-before.yml' '$fx/sp53/.github/workflows/ci.yml'" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp53b" +check "sp53 a dead reusable as a quoted value continued below its key is UNSUPPORTED, exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:4: .*continued below its key' <<<\"\$out\" && cmp -s '$fx/sp53b-before.yml' '$fx/sp53b/.github/workflows/ci.yml'" +# Control: an escaped quote inside a `name:` string beside a live call is +# text; the escape hides no call. +printf 'jobs:\n rust:\n name: "say \\" uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s"\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" "$GOOD" > "$fx/sp53c/.github/workflows/ci.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp53c" +check "sp53 control: a dead pin inside a name: string holding an escaped quote is text: OK, exit 0" \ + "[ \$rc -eq 0 ] && grep -q '^OK' <<<\"\$out\"" + +# sp54: a job written as flow mappings all in column 0 (`jobs: {`, `rust: {`, +# `uses: ...`): indentation says nothing there, so the `uses:` cannot be +# passed over as data: reported, exit 2, untouched. Indented as KYAML +# indents it, the same call is rewritten. +mkdir -p "$fx/sp54/.github/workflows" "$fx/sp54b/.github/workflows" +printf 'jobs: {\nrust: {\nuses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n}\n}\n' "$STALE_A" > "$fx/sp54/.github/workflows/ci.yml" +printf 'jobs: {\n rust: {\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n }\n}\n' "$STALE_A" > "$fx/sp54b/.github/workflows/ci.yml" +cp "$fx/sp54/.github/workflows/ci.yml" "$fx/sp54-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp54" +check "sp54 a column-0 flow job's dead uses: is UNSUPPORTED, exit 2, untouched, never OK" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:3:' <<<\"\$out\" && cmp -s '$fx/sp54-before.yml' '$fx/sp54/.github/workflows/ci.yml'" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp54b" +check "sp54 control: the same flow job indented as KYAML indents it is rewritten, exit 0" \ + "[ \$rc -eq 0 ] && grep -q \"rust-ci-reusable.yml@$GOOD\" '$fx/sp54b/.github/workflows/ci.yml'" + +# tc39: a step's own `uses:` spelling the SHA-pinned toolchain with +# double-quoted escapes is that step: it gets its input. An escaped quote in +# another step's `name:` string hides no step. +mkdir -p "$fx/tc39/.github/workflows" +cat > "$fx/tc39/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: "\x64tolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772" + - name: "say \" uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772" + run: cargo test +YML +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc39" +check "tc39 an escaped double-quoted toolchain reference is the step's pin: it gets toolchain:, exit 0" \ + "[ \$rc -eq 0 ] && sed -n '5,6p' '$fx/tc39/.github/workflows/x.yml' | tr '\\n' '|' | grep -qx ' with:| toolchain: stable|' && [ \$(grep -c 'toolchain: stable' '$fx/tc39/.github/workflows/x.yml') -eq 1 ]" +cp "$fx/tc39/.github/workflows/x.yml" "$fx/tc39-after1.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc39" +check "tc39 second run is a no-op" "[ \$rc -eq 0 ] && cmp -s '$fx/tc39-after1.yml' '$fx/tc39/.github/workflows/x.yml'" + +# ls42: a self-SHA record written as a flow mapping on its key line has its +# ref rewritten inside the braces. One spread over several lines is not read: +# UNRESOLVED, untouched, exit 1. A reusable's flow record keeps its SHA ref. +mkdir -p "$fx/ls42/.github/workflows" "$fx/ls42b/.github/workflows" "$fx/ls42c/.github/workflows" +action_steps "$fx/ls42/.github/workflows" "foo/bar@$SHA_B" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s': { ref: '%s', commit: 'sha1-%s' }\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls42/.github/workflows/actions.lock" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls42-tags.tsv" +TAG_MAP="$fx/ls42-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls42" --offline +check "ls42 a flow-mapping self-SHA record gets its tag inside the braces, exit 0" \ + "[ \$rc -eq 0 ] && grep -qxF \" 'foo/bar@$SHA_B': { ref: 'v1.0.0', commit: 'sha1-$SHA_B' }\" '$fx/ls42/.github/workflows/actions.lock'" +action_steps "$fx/ls42b/.github/workflows" "foo/bar@$SHA_B" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s': {\n ref: '%s', commit: 'sha1-%s' }\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls42b/.github/workflows/actions.lock" +cp "$fx/ls42b/.github/workflows/actions.lock" "$fx/ls42b-before.lock" +TAG_MAP="$fx/ls42-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls42b" --offline +check "ls42b a flow-mapping record over several lines is UNRESOLVED, untouched, exit 1, never OK" \ + "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED foo/bar@bbbbbbb: the record is a flow mapping spread over several lines' <<<\"\$out\" && cmp -s '$fx/ls42b-before.lock' '$fx/ls42b/.github/workflows/actions.lock'" +printf 'jobs:\n call:\n uses: foo/bar/.github/workflows/x.yml@%s\n' "$SHA_B" > "$fx/ls42c/.github/workflows/ci.yml" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s': { ref: '%s', commit: 'sha1-%s' }\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls42c/.github/workflows/actions.lock" +cp "$fx/ls42c/.github/workflows/actions.lock" "$fx/ls42c-before.lock" +TAG_MAP="$fx/ls42-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls42c" --offline +check "ls42 control: a reusable's flow-mapping record keeps its SHA ref, exit 0" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/ls42c-before.lock' '$fx/ls42c/.github/workflows/actions.lock'" + +dq_out=$(printf '%s\n' 'uses: "\x64tolnay/x@1" # "\x41"' "name: 'a\\x41' \"b\\\"c\"" | awk "$(cat "$fleet/scripts/lib/yaml-anchors.awk")"'{ printf "%s|", yaml_decode_dq($0) }') +check "yaml_decode_dq decodes double-quoted escapes only, an escaped quote to a byte no reference holds" \ + "[ \"\$dq_out\" = \"\$(printf 'uses: \"dtolnay/x@1\" # \"\\\\x41\"|name: '\"'\"'a\\\\x41'\"'\"' \"b\\001c\"|')\" ]" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed" From 780bd7de5df75630775e3b6801c774c5a6155028 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:21:30 +0000 Subject: [PATCH 41/44] fix(fix-scripts): doubled single quotes; escaped references reach every reader; flow-mapping stale records renamed; WF016 overclaim corrected - A doubled quote inside a single-quoted scalar ('it''s }}') is a quote, not the scalar's end, in every quote walk of the three fixers. The stale- pin fixer's flow-depth tracker no longer closes a column-0 flow job on braces inside such a string and passes its dead call as data. - fix-rust-toolchain-input.sh: any backslash lets a file past the prefilter, so `"dtolnay\/rust-toolchain@"` reaches the decoder. - fix-lock-self-sha-refs.sh: workflow lines are read YAML-decoded, so a step's `uses: "\x66oo/bar@"` proves the action its record names. - fix-stale-reusable-pin.sh: a stale dependency record written as a flow mapping on its key line is renamed with its caller (key, ref, commit). - AGENTIC.a2ml and the fixer's header no longer claim WF016: its orphan SHAs have no replacement recorded in stale-reusable-pins.tsv, so routing WF016 to this fixer would end in a clean run with the pin untouched. A contract test now checks that every rule AGENTIC assigns to a pin fixer is the registry's route to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .machine_readable/descriptiles/AGENTIC.a2ml | 3 +- scripts/fix-lock-self-sha-refs.sh | 6 ++- scripts/fix-rust-toolchain-input.sh | 11 +++-- scripts/fix-stale-reusable-pin.sh | 21 ++++++-- scripts/tests/fix-workflow-pins.sh | 54 +++++++++++++++++++++ 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/.machine_readable/descriptiles/AGENTIC.a2ml b/.machine_readable/descriptiles/AGENTIC.a2ml index 1a352a3c..67211252 100644 --- a/.machine_readable/descriptiles/AGENTIC.a2ml +++ b/.machine_readable/descriptiles/AGENTIC.a2ml @@ -42,7 +42,8 @@ release-claim-requires-hard-pass = true # by rust.yml), and two data files that carry the SAME facts hypatia detects # on, so detection and repair cannot drift apart: # scripts/fix-rust-toolchain-input.sh WF026 SHA-pinned dtolnay/rust-toolchain with no toolchain: input -# scripts/fix-stale-reusable-pin.sh WF027, PIN-003, WF016 a caller pinned at a reusable revision the estate has MEASURED dead +# scripts/fix-stale-reusable-pin.sh WF027, PIN-003 a caller pinned at a reusable revision the estate has MEASURED dead +# (not WF016: its orphan SHAs have no recorded replacement in the table) # scripts/fix-lock-self-sha-refs.sh PI006 actions.lock record whose ref is its own SHA (GitHub: "invalid lockfile pin") # scripts/lib/stale-reusable-pins.tsv the dead revisions and the one replacement each moves to # scripts/lib/action-ref-tags.tsv the tag that resolves to each commit the estate pins by SHA diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index f3ec5548..d8e915bd 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -130,7 +130,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A"`) runs + # the reference it decodes to (lib/yaml-anchors.awk). + $0 = yaml_decode_dq($0) note_anchors($0) if (fdepth > 0) { ml = masked($0); facc = facc " " ml; fdepth += depth_of(ml) diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 9c197997..3bed283a 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -108,9 +108,10 @@ apply_to_file() { [ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; } # GitHub resolves `Dtolnay/Rust-Toolchain` to the same action: the name is # matched in either case here and in the parser below. A double-quoted - # scalar may spell it with escapes (`"\x64tolnay/..."`), which only the - # parser decodes, so a file holding such an escape is read too. - grep -qiE 'dtolnay/rust-toolchain@[0-9a-fA-F]{40}|\\[xuU][0-9a-fA-F]' "$f" || return 0 + # scalar may spell it with escapes (`"\x64tolnay/..."`, `"dtolnay\/..."`), + # which only the parser decodes, so a file holding any backslash is read + # too. + grep -qiE 'dtolnay/rust-toolchain@[0-9a-fA-F]{40}|[\]' "$f" || return 0 tmp=$(mktemp) set +e @@ -149,7 +150,7 @@ apply_to_file() { if (c == "\"" || c == "'"'"'") { j = i + 1 if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } - else { while (j <= n && substr(s, j, 1) != "'"'"'") j++ } + else { while (j <= n && (substr(s, j, 1) != "'"'"'" || substr(s, j + 1, 1) == "'"'"'")) j += (substr(s, j, 1) == "'"'"'" ? 2 : 1) } body = substr(s, i + 1, j - i - 1) if (depth == 1) { if (substr(s, j + 1) ~ /^[[:space:]]*:/) out = out c body c @@ -193,7 +194,7 @@ apply_to_file() { if (c == "\"" || c == "'"'"'") { j = i + 1 if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } - else { while (j <= n && substr(s, j, 1) != "'"'"'") j++ } + else { while (j <= n && (substr(s, j, 1) != "'"'"'" || substr(s, j + 1, 1) == "'"'"'")) j += (substr(s, j, 1) == "'"'"'" ? 2 : 1) } body = substr(s, i + 1, j - i - 1) if (substr(s, j + 1) ~ /^[[:space:]]*:/ || out ~ /(^|[{,[:space:]])["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*$/) out = out c body c else out = out c c diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 8bb21baa..e86e8829 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # fix-stale-reusable-pin.sh — move a caller off a reusable-workflow pin that is -# KNOWN DEAD. Hypatia rules WF016 (orphan pin) and PI001 via PIN-003 (standards -# pins in a measured dead window); the auto-fix half of +# KNOWN DEAD. Hypatia rules WF027 and PI001 via PIN-003 (standards pins in a +# measured dead window), for the pins scripts/lib/stale-reusable-pins.tsv +# names. WF016's orphan pins are not in that table, so they are not this +# script's until a human records each one's replacement there; the auto-fix half of # `reusable_workflow_sha_bump_needs_propagation` for pins the estate has already # diagnosed. # @@ -367,7 +369,7 @@ yaml_context() { # if ((c == "\"" || c == sq) && scalar_start(seen, d)) { j = i + 1 if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } - else { while (j <= n && substr(s, j, 1) != sq) j++ } + else { while (j <= n && (substr(s, j, 1) != sq || substr(s, j + 1, 1) == sq)) j += (substr(s, j, 1) == sq ? 2 : 1) } seen = seen "x"; i = j + 1; continue } if ((c == "{" || c == "[") && (d > 0 || seen ~ VALUE_START_RE)) d++ @@ -451,7 +453,7 @@ mask_line() { if (c == "\"" || c == sq) { j = i + 1 if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } - else { while (j <= n && substr(s, j, 1) != sq) j++ } + else { while (j <= n && (substr(s, j, 1) != sq || substr(s, j + 1, 1) == sq)) j += (substr(s, j, 1) == sq ? 2 : 1) } if (substr(s, j + 1) ~ /^[[:space:]]*:/ || tolower(out) ~ tail || out ~ /^[[:space:]]*$/) out = out substr(s, i, j - i + 1) else out = out c c i = j + 1; continue @@ -739,7 +741,16 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then while ((i = index(l, o)) > 0) { out = out substr(s, 1, i - 1) new; s = substr(s, i + length(old)); l = substr(l, i + length(old)) } return out s } - sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/[[:space:]]+#.*$/, "", k); sub(/:[[:space:]]*$/, "", k); gsub(/["'"'"']/, "", k); inrec = (tolower(k) == tolower(repo "@" stale)) } + # The key is read up to its own colon, so a record written as a + # flow mapping on its key line (`'"'"'repo@sha'"'"': { ref: ..., commit: + # ... }`) is the record too: the whole line is then its key, ref and + # commit, and is moved as one. + sec == "d" && /^ [^[:space:]#]/ { + k = $0; sub(/^ /, "", k); sub(/[[:space:]]+#.*$/, "", k) + if (k ~ /^["'"'"']/) { q = substr(k, 1, 1); k = substr(k, 2); k = substr(k, 1, index(k, q) - 1) } + else sub(/:([[:space:]].*)?$/, "", k) + inrec = (tolower(k) == tolower(repo "@" stale)) + } # The field keys may be quoted (`"ref": ...`, `'"'"'commit'"'"': ...`), as # a hand-written record may write them: the same fields. sec == "d" && inrec && (/^ [^[:space:]#]/ || /^ ["'"'"']?(ref|commit)["'"'"']?[[:space:]]*:/) { diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 8954635f..a3fd5e3d 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3450,6 +3450,60 @@ TAG_MAP="$fx/ls42-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs. check "ls42 control: a reusable's flow-mapping record keeps its SHA ref, exit 0" \ "[ \$rc -eq 0 ] && cmp -s '$fx/ls42c-before.lock' '$fx/ls42c/.github/workflows/actions.lock'" +# ── Review cases (gitbot-fleet#615, round on 23e1337) ─────────────────────── +# sp55: a doubled quote inside a single-quoted sibling is a quote, not the +# scalar's end: its braces are text, the flow job stays open, and the +# column-0 dead call below it is reported (exit 2), never passed as data. +mkdir -p "$fx/sp55/.github/workflows" +printf "jobs: {\nrust: {\nname: 'it''s }}',\nuses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n}\n}\n" "$STALE_A" > "$fx/sp55/.github/workflows/ci.yml" +cp "$fx/sp55/.github/workflows/ci.yml" "$fx/sp55-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp55" +check "sp55 braces inside a single-quoted scalar with a doubled quote keep the flow job open: UNSUPPORTED, exit 2" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:4:' <<<\"\$out\" && cmp -s '$fx/sp55-before.yml' '$fx/sp55/.github/workflows/ci.yml'" + +# sp56: a stale dependency record written as a flow mapping on its key line +# moves with its caller: key, ref and commit, on that line. +mkdir -p "$fx/sp56/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp56/.github/workflows/ci.yml" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'hyperpolymath/standards@%s'\ndependencies:\n 'hyperpolymath/standards@%s': { ref: '%s', commit: 'sha1-%s' }\n" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" > "$fx/sp56/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp56" +check "sp56 a flow-mapping stale record is renamed with its caller: key, ref and commit, no stale SHA left" \ + "[ \$rc -eq 3 ] && grep -qxF \" 'hyperpolymath/standards@$GOOD': { ref: '$GOOD', commit: 'sha1-$GOOD' }\" '$fx/sp56/.github/workflows/actions.lock' && ! grep -q \"$STALE_A\" '$fx/sp56/.github/workflows/actions.lock'" + +# tc40: `\/` is a YAML escape too: `"dtolnay\/rust-toolchain@"` is the +# SHA-pinned step, read past the prefilter, and gets its input. +mkdir -p "$fx/tc40/.github/workflows" +printf 'jobs:\n check:\n steps:\n - uses: "dtolnay\\/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772"\n' > "$fx/tc40/.github/workflows/x.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc40" +check "tc40 a \\/-escaped toolchain reference reaches the parser and the step gets toolchain:, exit 0" \ + "[ \$rc -eq 0 ] && grep -q '^ toolchain: stable\$' '$fx/tc40/.github/workflows/x.yml'" + +# ls43: a step's `uses:` spelling the action with escapes is that action: the +# self-SHA record it proves gets its tag. +mkdir -p "$fx/ls43/.github/workflows" +printf 'jobs:\n build:\n steps:\n - uses: "\\x66oo/bar@%s"\n' "$SHA_B" > "$fx/ls43/.github/workflows/ci.yml" +lock_records "$fx/ls43" "foo/bar@$SHA_B" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls43-tags.tsv" +TAG_MAP="$fx/ls43-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls43" --offline +check "ls43 an escaped step reference proves the action: its self-SHA record gets its tag, exit 0" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls43/.github/workflows/actions.lock'" + +# The rules AGENTIC.a2ml assigns to each workflow-pin fixer are the ones the +# registry routes to it: a rule advertised but not registered is never +# dispatched, and one registered for a fixer that cannot repair it is a +# silent clean run. +agentic_ok=1 +while IFS= read -r row; do + script=$(awk '{print $2}' <<<"$row"); script=${script#scripts/} + rules=$(sed -E 's/^#[[:space:]]+scripts\/[^[:space:]]+[[:space:]]+//; s/[[:space:]]{2,}.*$//' <<<"$row") + IFS=', ' read -r -a rule_list <<<"$rules" + for rule in "${rule_list[@]}"; do + [ -n "$rule" ] || continue + jq -e --arg k "$rule" --arg v "$script" '.registry.by_recipe[$k] == $v' "$fleet/scripts/fix-script-registry.json" >/dev/null || { agentic_ok=0; echo " (AGENTIC assigns $rule to $script; the registry does not)" >&2; } + done +done < <(grep -E '^#[[:space:]]+scripts/fix-[a-z-]+\.sh[[:space:]]' "$fleet/.machine_readable/descriptiles/AGENTIC.a2ml") +check "every rule AGENTIC assigns to a workflow-pin fixer is registered to it" "[ \$agentic_ok -eq 1 ]" + dq_out=$(printf '%s\n' 'uses: "\x64tolnay/x@1" # "\x41"' "name: 'a\\x41' \"b\\\"c\"" | awk "$(cat "$fleet/scripts/lib/yaml-anchors.awk")"'{ printf "%s|", yaml_decode_dq($0) }') check "yaml_decode_dq decodes double-quoted escapes only, an escaped quote to a byte no reference holds" \ "[ \"\$dq_out\" = \"\$(printf 'uses: \"dtolnay/x@1\" # \"\\\\x41\"|name: '\"'\"'a\\\\x41'\"'\"' \"b\\001c\"|')\" ]" From a5cd4389e5a4e576ee58dc8cbdb7023e55eea443 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:40:37 +0000 Subject: [PATCH 42/44] fix(fix-scripts): kept-record copies stay inside dependencies:; escaped line breaks joined; escaped lock refs and input keys read decoded - fix-stale-reusable-pin.sh: a top-level key ends `dependencies:` and the record in it, so a stale record kept for an uncovered caller is copied inside the section; a `workflows:` written after it is no longer swallowed into the copy and re-emitted as `workflows: null` plus a stray map. A quoted workflow edge moved to the replacement keeps its trailing comment. - lib/yaml-anchors.awk: yaml_dq_continued / yaml_dq_join rebuild a double-quoted scalar continued past an escaped line break. The stale-pin fixer reads such a line whole (dq_logical_line) and reports a dead call spelled so; the toolchain fixer reports a SHA-pinned toolchain spelled so; the self-SHA reader joins it before reading its site. - fix-lock-self-sha-refs.sh: lock lines are read YAML-decoded, so an escaped self-SHA ref is found; the rewrite edits bytes as written, and a block or flow ref it cannot reach is reported UNRESOLVED, never counted as fixed. - fix-rust-toolchain-input.sh: `with:` and its input keys are matched on the decoded line, so `"tool\x63hain": nightly` configures the step in flow or block form and no second toolchain key is written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 17 ++++++-- scripts/fix-rust-toolchain-input.sh | 31 +++++++++---- scripts/fix-stale-reusable-pin.sh | 27 ++++++++++-- scripts/lib/yaml-anchors.awk | 19 ++++++++ scripts/tests/fix-workflow-pins.sh | 67 +++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 15 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index d8e915bd..18180aaa 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -222,8 +222,10 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A"`) runs - # the reference it decodes to (lib/yaml-anchors.awk). + # reference with escapes (`uses: "\x66oo/bar@"`), or + # continued past an escaped line break onto the lines below, + # runs the reference it decodes to (lib/yaml-anchors.awk). + while (yaml_dq_continued($0) && (getline nxt) > 0) $0 = yaml_dq_join($0, nxt) $0 = yaml_decode_dq($0) note_anchors($0) if (fdepth > 0) { @@ -365,7 +367,11 @@ tmp=$(mktemp); cp "$LOCK" "$tmp" # key line is not read (kind M): it is reported unless its SHA ref is one to # keep, never passed over as no record at all. Block records are kind B. records=$(mktemp) -if ! awk -v sq="'" ' +if ! awk -v sq="'" "$ANCHORS_AWK"' + # Each line is read as YAML reads it: a double-quoted key or ref spelled + # with escapes (`ref: "\x30..."`) is the value it decodes to. The rewrite + # below edits the bytes as written; a ref it cannot reach so is reported. + { $0 = yaml_decode_dq($0) } # Trailing whitespace after the colon, a carriage return included (a # lock with CRLF line endings), is no part of the key. # The record key of a flow-mapping record line: repo and SHA, or 0. @@ -450,7 +456,12 @@ while IFS=$'\t' read -r lineno kind repo sha ref_raw; do fixed=$((fixed + 1)); continue fi if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "^ [\"']?ref[\"']?[[:space:]]*:[[:space:]]*[\"']"; then q="'"; fi + before=$(sed -n "${lineno}p" "$tmp") sed -i "${lineno}s|^\( ['\"]\{0,1\}ref['\"]\{0,1\}[[:space:]]*:[[:space:]]*\)\(['\"]\{0,1\}\)$ref_raw\2|\1\2$q$tag$q\2|" "$tmp" + if [ "$(sed -n "${lineno}p" "$tmp")" = "$before" ]; then + echo "UNRESOLVED $repo@${sha:0:7}: the record's ref (line $lineno) is not written in a form this fixer rewrites (spelled with escapes?); set it to $tag by hand (record left as is)" >&2 + unresolved=$((unresolved + 1)); continue + fi echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)) done < "$records" diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 3bed283a..50ab6817 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -247,7 +247,7 @@ apply_to_file() { } # Emit the buffered step, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, j, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_empty_eol, tc_nested, tc_await, tc_flow_await, m, pre, post, delim, comment, v, w) { + function flush( i, j, jl, dl, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_empty_eol, tc_nested, tc_await, tc_flow_await, m, pre, post, delim, comment, v, w) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` — alone, or followed only by a comment @@ -264,6 +264,15 @@ apply_to_file() { # parser cannot place (a flow-style step) — that is reported, not guessed. uses_i = 0 for (i = 1; i <= n; i++) { + # A double-quoted value continued past an escaped line break + # (`uses: "dtolnay/rust-\` then `toolchain@"`) is the + # reference YAML joins from the lines below: a SHA-pinned + # toolchain read so is reported, never passed over as no pin. + if (yaml_dq_continued(buf[i])) { + jl = buf[i] + for (j = i + 1; j <= n && yaml_dq_continued(jl); j++) jl = yaml_dq_join(jl, buf[j]) + if (uses_pin(jl)) unrec[++nunrec] = lnum[i] + } # The step'"'"'s own `uses:` with its value continued below the key # (`uses: >-` then `dtolnay/rust-toolchain@`): the key line # names no action and the value reads as nested text, so a SHA @@ -299,7 +308,9 @@ apply_to_file() { # at the mapping'"'"'s own depth names inputs — outside quoted # values and nested mappings, and including the rest of a line # on which a nested mapping closes. - v = level_view(line, flow_depth); flow_depth = lv_depth + # Keys are read as YAML reads them: `"tool\x63hain":` is the + # input `toolchain`. + v = level_view(yaml_decode_dq(line), flow_depth); flow_depth = lv_depth # A bare `toolchain:` that ended the previous line is empty # only when the next token closes the mapping or moves on # (`}`, `,`); anything else is its value, spread over lines, @@ -314,6 +325,7 @@ apply_to_file() { if (in_with) { if (plain(line) && ind <= with_indent) in_with = 0 else if (plain(line)) { + dl = yaml_decode_dq(line) # Only a DIRECT child of `with:` is an input: the contents of # another input'"'"'s block scalar (`components: |`) sit deeper. # The first must be a block mapping key: `with:` whose value @@ -322,28 +334,29 @@ apply_to_file() { # a block child cannot be added to, so the step is reported. if (child_indent < 0) { child_indent = ind - if (line !~ WITH_CHILD_KEY_RE) { with_bad = 1; unrec[++nunrec] = lnum[i] } + if (dl !~ WITH_CHILD_KEY_RE) { with_bad = 1; unrec[++nunrec] = lnum[i] } } # An empty `toolchain:` whose next plain line (blank and # comment lines between them notwithstanding) sits deeper is # a nested mapping, not an empty value: nothing this fixer # can repair. if (tc_await) { tc_await = 0; if (ind > child_indent) tc_nested = 1 } - if (ind == child_indent && line ~ TC_EMPTY_RE) { tc_empty_i = i; tc_await = 1 } - else if (ind == child_indent && line ~ TC_RE) has_tc = 1 + if (ind == child_indent && dl ~ TC_EMPTY_RE) { tc_empty_i = i; tc_await = 1 } + else if (ind == child_indent && dl ~ TC_RE) has_tc = 1 } } + dl = yaml_decode_dq(line) if (!in_with && !has_with && length(key_indent_of(line)) == skey) { - if (line ~ WITH_BLOCK_RE) { + if (dl ~ WITH_BLOCK_RE) { has_with = 1; with_i = i; with_indent = skey; in_with = 1 - } else if (line ~ WITH_FLOW_RE) { + } else if (dl ~ WITH_FLOW_RE) { has_with = 1; with_i = i; with_flow = 1 - v = level_view(line, 0); flow_depth = lv_depth + v = level_view(dl, 0); flow_depth = lv_depth if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_FLOW_RE) has_tc = 1 if (flow_depth > 0) in_flow = 1 - } else if (line ~ WITH_ANY_RE) { + } else if (dl ~ WITH_ANY_RE) { # `with: &anchor`, `with: *alias`, or any value shape this # parser does not read: a second `with:` would be worse than # none, so the step is reported, not rewritten. diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index e86e8829..3bc48aac 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -329,6 +329,18 @@ dead_ref_in_file() { # # written (lib/yaml-anchors.awk, yaml_decode_dq). decode_dq() { awk "$ANCHORS_AWK"'{ print yaml_decode_dq($0) }'; } +# Line of as YAML reads it: double-quoted escapes decoded, and a +# line ending in an escaped line break inside a double-quoted scalar +# (`uses: "hyperpolymath/standards/.github/workflows/rust-ci-\`) joined with +# the lines that continue it (lib/yaml-anchors.awk). +dq_logical_line() { # + awk -v ln="$2" "$ANCHORS_AWK"' + NR == ln { j = $0 } + NR > ln { j = yaml_dq_join(j, $0) } + NR >= ln && !yaml_dq_continued(j) { print yaml_decode_dq(j); done = 1; exit } + END { if (!done && j != "") print yaml_decode_dq(j) }' "$1" +} + # Print YAML context markers for workflow file $1, one per line that matters: # K line n is a `uses:` key of a job under `jobs:` — the one place a # reusable is called; @@ -545,7 +557,8 @@ while IFS= read -r -d '' wf; do done < <(mask_line "$(sed -n "${lineno}p" "$wf" | decode_dq)" | grep -oiE "${REUSABLE_REF_RE}${FALLBACK_REF_END}" | grep -oiE "$REUSABLE_REF_RE" || true) done # A double-quoted value may spell the reference with escapes (`uses: - # "\x68yperpolymath/..."`): GitHub runs the decoded call, but the rewrite + # "\x68yperpolymath/..."`, or a line break escaped and continued on the + # next line): GitHub runs the decoded call, but the rewrite # and the scan above read the bytes as written, find no pin, and would # report the file clean. Every line outside data and text that holds an # escape is read decoded; a known-dead pin found so at a `uses` key is @@ -554,7 +567,7 @@ while IFS= read -r -d '' wf; do while IFS= read -r numbered; do lineno="${numbered%%:*}" grep -qx "$lineno" <<<"$textual" && continue - decoded=$(printf '%s\n' "${numbered#*:}" | decode_dq) + decoded=$(dq_logical_line "$wf" "$lineno") [ "$decoded" != "${numbered#*:}" ] || continue while IFS= read -r ref; do [ -n "$ref" ] || continue @@ -696,7 +709,8 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then if (edge_val($0) == e) { q = "'"'"'"; i = index(l, q e q) if (!i) { q = "\""; i = index(l, q e q) } - if (i) moved = substr($0, 1, i + length(repo)) "@" new q + # Everything after the closing quote (a comment) stays. + if (i) moved = substr($0, 1, i + length(repo)) "@" new q substr($0, i + length(e) + 2) else { t = l; sub(/^ - /, "", t); sub(/[[:space:]]+(#.*)?$/, "", t) if (t == e) { i = 1; rest = $0; sub(/^ - [^[:space:]]+/, "", rest); spell = substr($0, 11, length(repo)); moved = " - " spell "@" new rest } @@ -731,6 +745,13 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # record and keeps its SHA. awk -v stale="$stale" -v new="$new" -v repo="$repo" -v still="$still" ' /^dependencies:[[:space:]]*$/ { sec = "d" } + # A top-level key (`workflows:` written after `dependencies:`) + # ends the section and the record in it: a kept record'"'"'s copy is + # written there, inside the section, never after what follows. + sec == "d" && /^[^[:space:]#]/ && !/^dependencies:[[:space:]]*$/ { + for (c = 1; c <= nc; c++) print copy[c] + nc = 0; sec = ""; inrec = 0 + } # The record is the one whose unquoted key IS repo@stale: a key that # merely contains it (`evilhyperpolymath/standards@`) is another # record, and so is a plain unquoted key (a hand-written record). diff --git a/scripts/lib/yaml-anchors.awk b/scripts/lib/yaml-anchors.awk index 78717f75..f10259c8 100644 --- a/scripts/lib/yaml-anchors.awk +++ b/scripts/lib/yaml-anchors.awk @@ -64,6 +64,25 @@ function yaml_decode_dq(s, out, i, len, c, q) { } return out } +# Does line s end in an escaped line break inside a double-quoted scalar +# (`uses: "dtolnay/rust-\`)? YAML then joins the next line to it, the break +# and that line's leading whitespace removed (yaml_dq_join), so the scalar +# is read whole only across both lines. A comment ends the line. +function yaml_dq_continued(s, i, len, c, q) { + i = 1; len = length(s); q = "" + while (i <= len) { + c = substr(s, i, 1) + if (q == "\"" && c == "\\") { if (i == len) return 1; i += 2; continue } + if (q != "") { if (c == q) { if (q == "'" && substr(s, i + 1, 1) == "'") { i += 2; continue }; q = "" } } + else if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) return 0 + else if (c == "\"" || c == "'") q = c + i++ + } + return 0 +} +# Line s, ending in an escaped line break (yaml_dq_continued), joined with +# the line t that continues its scalar. +function yaml_dq_join(s, t) { sub(/^[[:space:]]+/, "", t); return substr(s, 1, length(s) - 1) t } function anchor_defs(s, defs, n, i, len, c, q, name, v, seen, depth) { split("", defs); n = 0; i = 1; q = ""; len = length(s); seen = ""; depth = 0 while (i <= len) { diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index a3fd5e3d..016425ce 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3504,6 +3504,73 @@ while IFS= read -r row; do done < <(grep -E '^#[[:space:]]+scripts/fix-[a-z-]+\.sh[[:space:]]' "$fleet/.machine_readable/descriptiles/AGENTIC.a2ml") check "every rule AGENTIC assigns to a workflow-pin fixer is registered to it" "[ \$agentic_ok -eq 1 ]" +# ── Review cases (gitbot-fleet#615, round on 780bd7d) ─────────────────────── +# ls44: a self-SHA record whose ref is spelled with escapes is that record: +# YAML reads the SHA, the rewrite cannot reach the bytes, so it is reported +# (exit 1, untouched), never passed as no record. +mkdir -p "$fx/ls44/.github/workflows" +action_steps "$fx/ls44/.github/workflows" "foo/bar@$SHA_B" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s':\n ref: \"\\\\x62%s\"\n commit: 'sha1-%s'\n" "$SHA_B" "$SHA_B" "${SHA_B:1}" "$SHA_B" > "$fx/ls44/.github/workflows/actions.lock" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls44-tags.tsv" +cp "$fx/ls44/.github/workflows/actions.lock" "$fx/ls44-before.lock" +TAG_MAP="$fx/ls44-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls44" --offline +check "ls44 an escaped self-SHA ref is the record: UNRESOLVED, untouched, exit 1, never OK" \ + "[ \$rc -eq 1 ] && grep -q 'UNRESOLVED foo/bar@bbbbbbb: the record.s ref (line 7)' <<<\"\$out\" && cmp -s '$fx/ls44-before.lock' '$fx/ls44/.github/workflows/actions.lock'" + +# sp57: with `dependencies:` written before `workflows:`, a stale record a +# caller the table does not cover still needs is copied INSIDE the section; +# `workflows:` and its edges stay as they were, not swallowed by the copy. +mkdir -p "$fx/sp57/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp57/.github/workflows/ci.yml" +printf 'jobs:\n mirror:\n uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@%s\n' "$STALE_A" > "$fx/sp57/.github/workflows/other.yml" +printf "version: 'v0.0.3'\ndependencies:\n 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\nworkflows:\n '.github/workflows/ci.yml':\n - 'hyperpolymath/standards@%s'\n '.github/workflows/other.yml':\n - 'hyperpolymath/standards@%s'\n" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" > "$fx/sp57/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp57" +sp57_lock="$fx/sp57/.github/workflows/actions.lock" +check "sp57 a kept stale record is copied inside dependencies:, before workflows:, which keeps both its entries" \ + "[ \$rc -eq 3 ] && [ \$(grep -c '^workflows:\$' '$sp57_lock') -eq 1 ] && [ \$(grep -n \"^ 'hyperpolymath/standards@$GOOD':\$\" '$sp57_lock' | cut -d: -f1) -lt \$(grep -n '^workflows:\$' '$sp57_lock' | cut -d: -f1) ] && grep -A1 \"^ '.github/workflows/other.yml':\" '$sp57_lock' | grep -q \"@$STALE_A'\" && grep -A1 \"^ '.github/workflows/ci.yml':\" '$sp57_lock' | grep -q \"@$GOOD'\"" + +# sp58: a quoted workflow edge keeps its trailing comment when it moves. +mkdir -p "$fx/sp58/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp58/.github/workflows/ci.yml" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'hyperpolymath/standards@%s' # rationale\ndependencies:\n 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" > "$fx/sp58/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp58" +check "sp58 a quoted edge moved to the replacement keeps its trailing comment" \ + "[ \$rc -eq 3 ] && grep -qxF \" - 'hyperpolymath/standards@$GOOD' # rationale\" '$fx/sp58/.github/workflows/actions.lock'" + +# sp59 / tc41: a double-quoted `uses:` value continued past an escaped line +# break is the reference YAML joins: a dead call, or a SHA-pinned toolchain, +# spelled so is reported (exit 2, untouched), never passed as clean. +mkdir -p "$fx/sp59/.github/workflows" "$fx/tc41/.github/workflows" +printf 'jobs:\n rust:\n uses: "hyperpolymath/standards/.github/workflows/rust-ci-\\\n reusable.yml@%s"\n' "$STALE_A" > "$fx/sp59/.github/workflows/ci.yml" +cp "$fx/sp59/.github/workflows/ci.yml" "$fx/sp59-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp59" +check "sp59 a dead call continued past an escaped line break is UNSUPPORTED, exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:3: .*written with escapes' <<<\"\$out\" && cmp -s '$fx/sp59-before.yml' '$fx/sp59/.github/workflows/ci.yml'" +printf 'jobs:\n check:\n steps:\n - uses: "dtolnay/rust-\\\n toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772"\n - name: "a \\\n b"\n run: cargo test\n' > "$fx/tc41/.github/workflows/x.yml" +cp "$fx/tc41/.github/workflows/x.yml" "$fx/tc41-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc41" +check "tc41 a toolchain pin continued past an escaped line break is UNSUPPORTED (line 4 only), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 4\\.' <<<\"\$out\" && cmp -s '$fx/tc41-before.yml' '$fx/tc41/.github/workflows/x.yml'" + +# tc42: an input key spelled with escapes (`"tool\x63hain":`) is the input +# `toolchain`: the step is configured, in flow or block form, and no second +# toolchain key is written. +mkdir -p "$fx/tc42/.github/workflows" +cat > "$fx/tc42/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { "tool\x63hain": nightly } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + "tool\x63hain": nightly +YML +cp "$fx/tc42/.github/workflows/x.yml" "$fx/tc42-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc42" +check "tc42 an escaped toolchain key is the input: flow and block steps left as they are, exit 0" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc42-before.yml' '$fx/tc42/.github/workflows/x.yml'" + dq_out=$(printf '%s\n' 'uses: "\x64tolnay/x@1" # "\x41"' "name: 'a\\x41' \"b\\\"c\"" | awk "$(cat "$fleet/scripts/lib/yaml-anchors.awk")"'{ printf "%s|", yaml_decode_dq($0) }') check "yaml_decode_dq decodes double-quoted escapes only, an escaped quote to a byte no reference holds" \ "[ \"\$dq_out\" = \"\$(printf 'uses: \"dtolnay/x@1\" # \"\\\\x41\"|name: '\"'\"'a\\\\x41'\"'\"' \"b\\001c\"|')\" ]" From 897870440363a7ceaf0dca7fb011f22e5fb3b861 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:09:07 +0000 Subject: [PATCH 43/44] fix(fix-scripts): continued anchors and flow keys refused or joined; tagged lock refs read; [uses: steps and flow aliases reported; inline records move only their own fields - lib/yaml-anchors.awk: a quoted anchor value still open at its line's end reads as "\002", never as its partial text. The stale-pin fixer joins escaped line breaks before reading an anchor, so an aliased dead call held by a continued value is reported; the toolchain fixer counts an alias of such an anchor as a possible SHA-pinned toolchain. - fix-rust-toolchain-input.sh: a quoted key or value of a flow `with: {` left open at a line's end (escaped break or fold) refuses the step, once, instead of hiding the next line's keys and adding a second `toolchain`. `[` is a flow-entry boundary, so `steps: [uses: ...]` is reported; an alias given as a flow step's `uses` is weighed like a block step's. - fix-lock-self-sha-refs.sh: a ref carrying node properties (`ref: !!str ''`) is read past them, block or flow, and rewritten with them kept. `steps: [uses: ...]` is an action site. - fix-stale-reusable-pin.sh: the rename pass replaces the SHA only in a record's key and its ref/commit values; a nested `uses:` edge at the same SHA and comments stay as written. A flow job's `uses: *alias` is resolved like a block job's. - tests: sp60-sp62b, tc43-tc46, ls45, ls45f, ls46 (all fail on a5cd438). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 31 ++++--- scripts/fix-rust-toolchain-input.sh | 53 ++++++++--- scripts/fix-stale-reusable-pin.sh | 135 ++++++++++++++++++++++------ scripts/lib/yaml-anchors.awk | 8 +- scripts/tests/fix-workflow-pins.sh | 132 +++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 53 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 18180aaa..2972eaa1 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -132,7 +132,7 @@ uses_sites() { # -> lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A lines "Rowner/repo/.github/workflows/x.yml@ref" | "A= 2 && kind[d] == "{" && kname[d] == "" && kind[d - 1] == "[" && kname[d - 1] == "steps") return "A" + # So is an entry written straight into that `[` (`steps: [uses: + # ...]`): YAML reads it as a mapping of that one pair, a step. + if (d >= 1 && kind[d] == "[" && kname[d] == "steps") return "A" if (d >= 1 && kind[d] == "{" && kname[d] != "") { if (d >= 2 && kind[d - 1] == "{" && kname[d - 1] == "jobs") return "R" if (d == 1 && parent == "jobs") return "R" @@ -387,10 +390,11 @@ if ! awk -v sq="'" "$ANCHORS_AWK"' body = $0; sub(/^[^{]*\{/, "", body) if (body !~ /\}[[:space:]]*(#.*)?$/) { print NR "\tM\t" f_repo "\t" f_sha "\t"; next } # The `ref` entry: after `{` or `,`, its key quoted or not, its value - # quoted or plain up to `,` or `}`. + # quoted or plain up to `,` or `}`, past any node properties. r = "{" body if (match(r, "[{,][[:space:]]*[\"" sq "]?ref[\"" sq "]?[[:space:]]*:[[:space:]]*[^,}]*")) { r = substr(r, RSTART, RLENGTH); sub(/^[{,][[:space:]]*["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*/, "", r) + sub(/^([&!][^[:space:],}]*[[:space:]]+)+/, "", r) gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) if (tolower(r) == tolower(f_sha)) print NR "\tI\t" f_repo "\t" f_sha "\t" r } @@ -405,8 +409,10 @@ if ! awk -v sq="'" "$ANCHORS_AWK"' /^[^[:space:]]/ || /^ [^[:space:]]/ { inrec = 0 } inrec && /^ ["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]/ { # Quoted either way, or a plain scalar, in either case of hex: the - # same ref to GitHub. The key may be quoted too (`"ref": ...`). - r = $0; sub(/^ ["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*/, "", r); sub(/[[:space:]]+#.*$/, "", r); gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) + # same ref to GitHub. The key may be quoted too (`"ref": ...`), and + # the value may carry YAML node properties, a tag or an anchor + # (`ref: !!str '"'"''"'"'`): the scalar after them is the ref. + r = $0; sub(/^ ["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*/, "", r); sub(/^([&!][^[:space:]]*[[:space:]]+)+/, "", r); sub(/[[:space:]]+#.*$/, "", r); gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) if (tolower(r) == tolower(key_sha)) print NR "\tB\t" key_repo "\t" key_sha "\t" r } ' "$LOCK" > "$records"; then @@ -445,9 +451,9 @@ while IFS=$'\t' read -r lineno kind repo sha ref_raw; do if [ "$kind" = I ]; then # The `ref` entry inside the braces, its value as written (quoted or # plain) up to the `,` or `}` that ends it; nothing else on the line. - if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "[{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*[\"']"; then q="'"; fi + if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "[{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*[\"']"; then q="'"; fi before=$(sed -n "${lineno}p" "$tmp") - sed -i -E "${lineno}s~([{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*)([\"']?)$ref_raw\2([[:space:]]*[,}])~\1\2$q$tag$q\2\3~" "$tmp" + sed -i -E "${lineno}s~([{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*)([\"']?)$ref_raw\3([[:space:]]*[,}])~\1\3$q$tag$q\3\4~" "$tmp" if [ "$(sed -n "${lineno}p" "$tmp")" = "$before" ]; then echo "UNRESOLVED $repo@${sha:0:7}: the ref inside the record's flow mapping (line $lineno) is not written in a form this fixer rewrites; set it to $tag by hand (record left as is)" >&2 unresolved=$((unresolved + 1)); continue @@ -455,9 +461,10 @@ while IFS=$'\t' read -r lineno kind repo sha ref_raw; do echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)); continue fi - if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "^ [\"']?ref[\"']?[[:space:]]*:[[:space:]]*[\"']"; then q="'"; fi + if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "^ [\"']?ref[\"']?[[:space:]]*:[[:space:]]*([&!][^[:space:]]*[[:space:]]+)*[\"']"; then q="'"; fi before=$(sed -n "${lineno}p" "$tmp") - sed -i "${lineno}s|^\( ['\"]\{0,1\}ref['\"]\{0,1\}[[:space:]]*:[[:space:]]*\)\(['\"]\{0,1\}\)$ref_raw\2|\1\2$q$tag$q\2|" "$tmp" + # Any node properties before the value (`!!str`, an anchor) are kept. + sed -i -E "${lineno}s~^( [\"']?ref[\"']?[[:space:]]*:[[:space:]]*([&!][^[:space:]]*[[:space:]]+)*)([\"']?)$ref_raw\3~\1\3$q$tag$q\3~" "$tmp" if [ "$(sed -n "${lineno}p" "$tmp")" = "$before" ]; then echo "UNRESOLVED $repo@${sha:0:7}: the record's ref (line $lineno) is not written in a form this fixer rewrites (spelled with escapes?); set it to $tag by hand (record left as is)" >&2 unresolved=$((unresolved + 1)); continue diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 50ab6817..69337831 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -141,9 +141,13 @@ apply_to_file() { # on the same line (`channel: beta }, toolchain: nightly`) is read at # the mapping'"'"'s level like any other entry. The depth the line leaves # is left in lv_depth: the walk that reads the entries is the one that - # counts the braces, so the two can never disagree. + # counts the braces, so the two can never disagree. lv_open is set + # when a quoted scalar is still open at the line'"'"'s end — continued + # past an escaped line break (`"toolch\` then `ain": nightly`) or + # folded onto the next line: the next line then starts inside it, and + # no line-by-line view reads the keys around it. function level_view(s, depth, out, i, n, c, j, body) { - out = ""; n = length(s); i = 1 + out = ""; n = length(s); i = 1; lv_open = 0 while (i <= n) { c = substr(s, i, 1) if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break @@ -152,6 +156,7 @@ apply_to_file() { if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } else { while (j <= n && (substr(s, j, 1) != "'"'"'" || substr(s, j + 1, 1) == "'"'"'")) j += (substr(s, j, 1) == "'"'"'" ? 2 : 1) } body = substr(s, i + 1, j - i - 1) + if (j > n) lv_open = 1 if (depth == 1) { if (substr(s, j + 1) ~ /^[[:space:]]*:/) out = out c body c else out = out c (body == "" ? "" : "v") c @@ -196,7 +201,7 @@ apply_to_file() { if (c == "\"") { while (j <= n && substr(s, j, 1) != "\"") { if (substr(s, j, 1) == "\\") j++; j++ } } else { while (j <= n && (substr(s, j, 1) != "'"'"'" || substr(s, j + 1, 1) == "'"'"'")) j += (substr(s, j, 1) == "'"'"'" ? 2 : 1) } body = substr(s, i + 1, j - i - 1) - if (substr(s, j + 1) ~ /^[[:space:]]*:/ || out ~ /(^|[{,[:space:]])["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*$/) out = out c body c + if (substr(s, j + 1) ~ /^[[:space:]]*:/ || out ~ /(^|[{,[[:space:]])["'"'"']?uses["'"'"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*$/) out = out c body c else out = out c c i = j + 1; continue } @@ -208,6 +213,18 @@ apply_to_file() { # only pin the parser can be asked to place. The same text inside # another key'"'"'s string (`name: "fixture dtolnay/...@"`) is data. function uses_pin(s) { return sha_ref(s) && tolower(site_view(yaml_decode_dq(s))) ~ USES_SITE_RE } + # Does line s give a `uses` key, block or flow, an alias that may be + # the SHA-pinned toolchain reference — of an anchor that holds it, or + # of no anchor read so far? Every such alias on the line is weighed. + function bad_alias(s, v, m) { + v = site_view(yaml_decode_dq(s)) + while (match(tolower(v), USES_ALIAS_RE)) { + m = substr(v, RSTART, RLENGTH); sub(/^.*\*/, "", m) + if ((m in tc_anchor) || !(m in any_anchor)) return 1 + v = substr(v, RSTART + RLENGTH) + } + return 0 + } # Buffer line $0 of the current step as buf[n], with the verdict on # its `uses:` alias, if any, as the anchors read so far give it — # YAML resolves an alias to the latest anchor of that name BEFORE it, @@ -247,7 +264,7 @@ apply_to_file() { } # Emit the buffered step, patched if it is a SHA-pinned dtolnay step # whose `with:` names no toolchain. - function flush( i, j, jl, dl, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_empty_eol, tc_nested, tc_await, tc_flow_await, m, pre, post, delim, comment, v, w) { + function flush( i, j, jl, dl, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_empty_eol, tc_nested, tc_await, tc_flow_await, m, pre, post, delim, comment, v, w, lv_rep) { if (n == 0) return # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` — alone, or followed only by a comment @@ -297,7 +314,7 @@ apply_to_file() { } if (!uses_i) { for (i = 1; i <= n; i++) print buf[i]; n = 0; return } has_with = 0; with_bad = 0; with_i = 0; with_flow = 0; with_indent = -1; in_with = 0; in_flow = 0; child_indent = -1; has_tc = 0 - tc_empty_i = 0; tc_empty_flow = 0; tc_empty_eol = 0; tc_nested = 0; tc_await = 0; tc_flow_await = 0 + tc_empty_i = 0; tc_empty_flow = 0; tc_empty_eol = 0; tc_nested = 0; tc_await = 0; tc_flow_await = 0; lv_rep = 0 for (i = 1; i <= n; i++) { line = buf[i]; ind = indent_of(line) if (in_flow) { @@ -311,6 +328,10 @@ apply_to_file() { # Keys are read as YAML reads them: `"tool\x63hain":` is the # input `toolchain`. v = level_view(yaml_decode_dq(line), flow_depth); flow_depth = lv_depth + # A quoted key or value left open at this line'"'"'s end hides + # what the next line names: the inputs are not read here. + # The step is reported once, at the line the scalar opens on. + if (lv_open && !lv_rep) { lv_rep = 1; with_bad = 1; unrec[++nunrec] = lnum[i] } # A bare `toolchain:` that ended the previous line is empty # only when the next token closes the mapping or moves on # (`}`, `,`); anything else is its value, spread over lines, @@ -352,6 +373,7 @@ apply_to_file() { } else if (dl ~ WITH_FLOW_RE) { has_with = 1; with_i = i; with_flow = 1 v = level_view(dl, 0); flow_depth = lv_depth + if (lv_open) { lv_rep = 1; with_bad = 1; unrec[++nunrec] = lnum[i] } if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_FLOW_RE) has_tc = 1 @@ -419,10 +441,12 @@ apply_to_file() { USES_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*" Q "dtolnay/rust-toolchain@[0-9a-f]+" # The value may carry YAML node properties, an anchor or a tag # (`uses: &rust ...`, `uses: !!str ...`): still the step'"'"'s pin, but - # not one USES_RE places, so such a step is reported. - USES_SITE_RE = "(^|[-{,[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*" Q "dtolnay/rust-toolchain@[0-9a-f]+" + # not one USES_RE places, so such a step is reported. A flow entry + # starts after `{`, `,` or `[`: `steps: [uses: ...]` is a list of + # one step, a mapping written without its braces. + USES_SITE_RE = "(^|[-{,[[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*" Q "dtolnay/rust-toolchain@[0-9a-f]+" # A `uses:` whose value is an alias, `*name` (matched in lower case). - USES_ALIAS_RE = "(^|[-{,[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*\\*[a-z0-9_.-]+" + USES_ALIAS_RE = "(^|[-{,[[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*\\*[a-z0-9_.-]+" # A `uses:` key whose value is not on its line: a block scalar # (`uses: >-`) or nothing at all, the reference on the lines below. USES_OPEN_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]*)*([|>][-+0-9]*)?[[:space:]]*$" @@ -454,13 +478,16 @@ apply_to_file() { # defined: an alias of it is that reference. Anchors are read by # lib/yaml-anchors.awk (outside quoted scalars and comments) and # never from a block scalar'"'"'s content (`run: |` text); a later - # anchor of the same name replaces an earlier one. + # anchor of the same name replaces an earlier one. An anchor whose + # quoted value runs on past its line (`&rust "dtolnay/rust-\`) is + # not read from the one line, so its alias may be that reference + # too: it counts as one. if (anc_scalar >= 0 && (!plain($0) || ind > anc_scalar)) { } else { anc_scalar = -1 if (plain($0)) { anchor_defs($0, d) - for (a in d) { any_anchor[a] = 1; if (sha_ref(d[a])) tc_anchor[a] = 1; else delete tc_anchor[a] } + for (a in d) { any_anchor[a] = 1; if (sha_ref(d[a]) || index(d[a], "\002")) tc_anchor[a] = 1; else delete tc_anchor[a] } if ($0 ~ SCALAR_RE) anc_scalar = length(key_indent_of($0)) } } @@ -490,8 +517,10 @@ apply_to_file() { # on one line, a key of a flow-style step spread over lines) is a # step this parser cannot place. The pin inside some other value # (a workflow-level `env:` string naming the action) is data. - if (uses_pin($0) && (parent_is_steps(ind) || under_steps(ind))) unrec[++nunrec] = NR - } else if (uses_pin($0) && parent_is_steps(ind)) unrec[++nunrec] = NR + # So is an alias such a step gives its `uses` (`steps: [uses: + # *rust]`) of an anchor that may hold the pin. + if ((uses_pin($0) || bad_alias($0)) && (parent_is_steps(ind) || under_steps(ind))) unrec[++nunrec] = NR + } else if ((uses_pin($0) || bad_alias($0)) && parent_is_steps(ind)) unrec[++nunrec] = NR print } END { diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index 3bc48aac..d19303cc 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -88,7 +88,11 @@ ANCHORS_AWK=$(cat "$SCRIPT_DIR/lib/yaml-anchors.awk") # The value of anchor as an alias on line of sees it: the # last `&name` defined above that line (lib/yaml-anchors.awk reads anchors # outside quoted scalars and comments, and a block scalar's content is -# skipped as text), or "" when there is none. +# skipped as text), or "" when there is none. A line ending in an escaped +# line break inside a double-quoted scalar (`CALL: &call "a/b/.github/\`) is +# read joined with the lines that continue it, as YAML reads the value; a +# value still open at its line's end (folded into the next line) is not read, +# "" as well. anchor_value() { # awk -v want="$2" -v upto="$3" "$ANCHORS_AWK"' function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } @@ -99,10 +103,12 @@ anchor_value() { # if (blk >= 0 && (!plain || indent_of($0) > blk)) next blk = -1 if (!plain) next - if (anchor_defs($0, d) && (want in d)) got = d[want] - if ($0 ~ /:[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$/) { t = $0; sub(/[^[:space:]-].*$/, "", t); blk = length(t) } + s = $0 + while (yaml_dq_continued(s) && (getline t) > 0) s = yaml_dq_join(s, t) + if (anchor_defs(s, d) && (want in d)) got = d[want] + if (s ~ /:[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$/) { t = s; sub(/[^[:space:]-].*$/, "", t); blk = length(t) } } - END { print got }' "$1" + END { if (index(got, "\002")) got = ""; print got }' "$1" } pin_fixer_lock "$REPO_PATH" @@ -586,22 +592,32 @@ while IFS= read -r -d '' wf; do # `&call` after another item of a flow sequence begun on an earlier line # is not seen) may be that pin: in a file that holds a known-dead pin # anywhere, it is reported too, never passed as OK. - for lineno in $klines; do - alias=$(sed -n "${lineno}p" "$wf" | grep -oE "^[[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*\*[A-Za-z0-9_.-]+" | sed 's/.*\*//' || true) - [ -n "$alias" ] || continue - value=$(anchor_value "$wf" "$alias" "$lineno") - if [ -z "$value" ] && dead_ref_in_file "$wf"; then - echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` names an anchor this parser cannot read, and the file holds a known-dead pin it may hold. Fix by hand; the file was not changed." >&2 - skip=1; continue - fi - while IFS= read -r ref; do - [ -n "$ref" ] || continue - sha="${ref##*@}" - if lookup_replacement "${ref%@*}" "${sha,,}"; then - echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` runs $ref, a known-dead pin held by the anchor \`&$alias\`. Fix by hand; the file was not changed." >&2 - skip=1 + # A flow-style job (`rust: { uses: *call }`, `jobs: { rust: { uses: + # *call } }`) is no key the parser places, but runs its alias all the + # same: every `uses: *name` outside comments and quoted text, on a line + # that is neither data nor text, is read the same way. + alias_lines=$klines + while IFS= read -r numbered; do + lineno="${numbered%%:*}" + grep -qx "$lineno" <<<"$placed" || alias_lines="$alias_lines $lineno" + done < <(grep -n '[*]' "$wf" | grep -vE "^[0-9]+:[[:space:]]*#" || true) + for lineno in $alias_lines; do + while IFS= read -r alias; do + [ -n "$alias" ] || continue + value=$(anchor_value "$wf" "$alias" "$lineno") + if [ -z "$value" ] && dead_ref_in_file "$wf"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` names an anchor this parser cannot read, and the file holds a known-dead pin it may hold. Fix by hand; the file was not changed." >&2 + skip=1; continue fi - done < <(printf '%s\n' "$value" | grep -oiE "^${REUSABLE_REF_RE}\$" || true) + while IFS= read -r ref; do + [ -n "$ref" ] || continue + sha="${ref##*@}" + if lookup_replacement "${ref%@*}" "${sha,,}"; then + echo "UNSUPPORTED ${wf#"$REPO_PATH"/}:$lineno: \`uses: *$alias\` runs $ref, a known-dead pin held by the anchor \`&$alias\`. Fix by hand; the file was not changed." >&2 + skip=1 + fi + done < <(printf '%s\n' "$value" | grep -oiE "^${REUSABLE_REF_RE}\$" || true) + done < <(mask_line "$(sed -n "${lineno}p" "$wf")" | grep -oE "(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*\*[A-Za-z0-9_.-]+" | sed 's/.*\*//' || true) done if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() @@ -755,17 +771,82 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # The record is the one whose unquoted key IS repo@stale: a key that # merely contains it (`evilhyperpolymath/standards@`) is another # record, and so is a plain unquoted key (a hand-written record). - # The SHA is replaced wherever it stands on the line, in either - # case; the rest of the line keeps its spelling. + # The SHA is replaced in either case wherever it stands in the + # record'"'"'s own key, ref and commit (own_sub); the rest of the line + # keeps its spelling. function isub(s, old, new, l, o, i, out) { out = ""; l = tolower(s); o = tolower(old) while ((i = index(l, o)) > 0) { out = out substr(s, 1, i - 1) new; s = substr(s, i + length(old)); l = substr(l, i + length(old)) } return out s } - # The key is read up to its own colon, so a record written as a - # flow mapping on its key line (`'"'"'repo@sha'"'"': { ref: ..., commit: - # ... }`) is the record too: the whole line is then its key, ref and - # commit, and is moved as one. + # The length of the quoted scalar t starts with (`'"'"''"'"'` is a quote + # inside single quotes, `\` escapes inside double quotes); all of t + # when it does not close. + function qlen(t, q, i, n) { + q = substr(t, 1, 1); n = length(t); i = 2 + while (i <= n) { + if (q == "\"" && substr(t, i, 1) == "\\") { i += 2; continue } + if (substr(t, i, 1) == q) { if (q == "'"'"'" && substr(t, i + 1, 1) == "'"'"'") { i += 2; continue }; return i } + i++ + } + return n + } + # The length of the flow value t starts with: up to the `,`, `}` or + # `]` that ends it, or a comment, quoted text and nested brackets + # passed over. + function vlen(t, i, n, c, d) { + n = length(t); i = 1; d = 0 + while (i <= n) { + c = substr(t, i, 1) + if (c == "\"" || c == "'"'"'") { i += qlen(substr(t, i)); continue } + if (d == 0 && (c == "," || c == "}" || c == "]")) return i - 1 + if (c == "#" && i > 1 && substr(t, i - 1, 1) ~ /[[:space:]]/) return i - 1 + if (c == "{" || c == "[") d++ + else if (c == "}" || c == "]") d-- + i++ + } + return n + } + # The SHA replaced in the parts of record line s that are the + # record'"'"'s own: its key, read up to its own colon, and the values + # of its `ref` and `commit` fields — on their own lines, or as + # entries of a flow mapping written on the key line (`'"'"'repo@sha'"'"': + # { ref: ..., commit: ... }`). A nested `uses:` list in that mapping + # names other revisions, which this fixer does not recompute + # (`uses: ['"'"'other/action@'"'"']` at the same SHA is another + # repository'"'"'s edge), and a comment is text: both are kept as + # written. + function own_sub(s, k, q, j, out, rest, i, n, c, d) { + if (s !~ /^ [^[:space:]#]/) { + # A field line: its key and value, not a comment after them. + k = s; s = "" + while (k != "") { + c = substr(k, 1, 1) + if (c == "\"" || c == "'"'"'") { j = qlen(k); s = s substr(k, 1, j); k = substr(k, j + 1); continue } + if (c == "#" && s ~ /[[:space:]]$/) break + s = s c; k = substr(k, 2) + } + return isub(s, stale, new) k + } + k = substr(s, 5) + if (k ~ /^["'"'"']/) { q = substr(k, 1, 1); j = index(substr(k, 2), q) + 1 } + else { match(k, /:([[:space:]]|$)/); j = RSTART - 1 } + out = isub(substr(s, 1, 4 + j), stale, new); rest = substr(s, 5 + j) + n = length(rest); i = 1; d = 0 + while (i <= n) { + c = substr(rest, i, 1) + if (c == "\"" || c == "'"'"'") { j = qlen(substr(rest, i)); out = out substr(rest, i, j); i += j; continue } + if (c == "#" && (i == 1 || substr(rest, i - 1, 1) ~ /[[:space:]]/)) { out = out substr(rest, i); break } + if (c == "{" || c == "[") d++ + else if (c == "}" || c == "]") d-- + out = out c; i++ + if (d == 1 && (c == "{" || c == ",") && match(substr(rest, i), /^[[:space:]]*["'"'"']?(ref|commit)["'"'"']?[[:space:]]*:[[:space:]]*/)) { + out = out substr(rest, i, RLENGTH); i += RLENGTH + j = vlen(substr(rest, i)); out = out isub(substr(rest, i, j), stale, new); i += j + } + } + return out + } sec == "d" && /^ [^[:space:]#]/ { k = $0; sub(/^ /, "", k); sub(/[[:space:]]+#.*$/, "", k) if (k ~ /^["'"'"']/) { q = substr(k, 1, 1); k = substr(k, 2); k = substr(k, 1, index(k, q) - 1) } @@ -775,8 +856,8 @@ if [ -f "$LOCK" ] && [ -s "$edits" ]; then # The field keys may be quoted (`"ref": ...`, `'"'"'commit'"'"': ...`), as # a hand-written record may write them: the same fields. sec == "d" && inrec && (/^ [^[:space:]#]/ || /^ ["'"'"']?(ref|commit)["'"'"']?[[:space:]]*:/) { - if (still == 0) $0 = isub($0, stale, new) - else copy[++nc] = isub($0, stale, new) + if (still == 0) $0 = own_sub($0) + else copy[++nc] = own_sub($0) } sec == "d" && inrec && still != 0 && !(/^ [^[:space:]#]/ || /^ ["'"'"']?(ref|commit)["'"'"']?[[:space:]]*:/) { copy[++nc] = $0 } { print } diff --git a/scripts/lib/yaml-anchors.awk b/scripts/lib/yaml-anchors.awk index f10259c8..b3a45fcd 100644 --- a/scripts/lib/yaml-anchors.awk +++ b/scripts/lib/yaml-anchors.awk @@ -16,8 +16,11 @@ # read to its closing quote (`''` is a quote inside single quotes; inside # double quotes each `\` escape is decoded, yaml_unescape/1), a plain one up # to whitespace, `,`, `}`, `]` or a comment; "" when the anchor names a -# mapping or sequence that follows. The caller skips the content lines of -# block scalars (`run: |`), which are text throughout. +# mapping or sequence that follows; "\002", which no reference holds, when a +# quoted value runs past the line's end — continued past an escaped line +# break or folded into the next line, it is not the value YAML reads, and a +# caller that wants it joins the line first (yaml_dq_join). The caller skips +# the content lines of block scalars (`run: |`), which are text throughout. # # The character a double-quoted escape stands for, `t` being the text after # its backslash; esc_len is set to how much of `t` it spans. YAML resolves @@ -114,6 +117,7 @@ function anchor_defs(s, defs, n, i, len, c, q, name, v, seen, depth) { } v = v c; i++ } + if (i > len) v = "\002" q = ""; i++ } else { while (i <= len && substr(s, i, 1) !~ /[],}[:space:]]/) { v = v substr(s, i, 1); i++ } diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 016425ce..3a1f2e7e 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3571,6 +3571,138 @@ run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc42" check "tc42 an escaped toolchain key is the input: flow and block steps left as they are, exit 0" \ "[ \$rc -eq 0 ] && cmp -s '$fx/tc42-before.yml' '$fx/tc42/.github/workflows/x.yml'" +# ── Review cases (gitbot-fleet#615, round on a5cd438) ─────────────────────── +# sp60 / tc43: an anchor whose double-quoted value is continued past an +# escaped line break holds the value YAML joins. A dead call read so through +# `uses: *call` is reported; the toolchain fixer, which reads anchors a line +# at a time, counts an alias of such an anchor as possibly the pin. Both exit +# 2 with the file untouched, never pass the alias as a known other value. +mkdir -p "$fx/sp60/.github/workflows" "$fx/tc43/.github/workflows" +printf 'env:\n CALL: &call "hyperpolymath/standards/.github/workflows/rust-ci-\\\n reusable.yml@%s"\njobs:\n rust:\n uses: *call\n' "$STALE_A" > "$fx/sp60/.github/workflows/ci.yml" +cp "$fx/sp60/.github/workflows/ci.yml" "$fx/sp60-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp60" +check "sp60 an aliased dead call held by a continued anchor value is UNSUPPORTED, exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:6: .uses: \\*call. runs hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@$STALE_A' <<<\"\$out\" && cmp -s '$fx/sp60-before.yml' '$fx/sp60/.github/workflows/ci.yml'" +printf 'env:\n ACTION: &rust "dtolnay/rust-\\\n toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772"\njobs:\n check:\n steps:\n - uses: *rust\n' > "$fx/tc43/.github/workflows/x.yml" +cp "$fx/tc43/.github/workflows/x.yml" "$fx/tc43-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc43" +check "tc43 an alias of a continued anchor value is UNSUPPORTED (line 7), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 7\\.' <<<\"\$out\" && cmp -s '$fx/tc43-before.yml' '$fx/tc43/.github/workflows/x.yml'" + +# ls45: a self-SHA ref carrying YAML node properties (`ref: !!str ''`) +# is that SHA: the record gets its tag, block or flow, the tag kept. +mkdir -p "$fx/ls45/.github/workflows" "$fx/ls45f/.github/workflows" +action_steps "$fx/ls45/.github/workflows" "foo/bar@$SHA_B" +action_steps "$fx/ls45f/.github/workflows" "foo/bar@$SHA_B" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s':\n ref: !!str '%s'\n commit: 'sha1-%s'\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls45/.github/workflows/actions.lock" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s': { ref: !!str '%s', commit: 'sha1-%s' }\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls45f/.github/workflows/actions.lock" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls45-tags.tsv" +TAG_MAP="$fx/ls45-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls45" --offline +check "ls45 a tagged block self-SHA ref is the record: it gets its tag, the tag property kept, exit 0" \ + "[ \$rc -eq 0 ] && grep -qxF \" ref: !!str 'v1.0.0'\" '$fx/ls45/.github/workflows/actions.lock'" +TAG_MAP="$fx/ls45-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls45f" --offline +check "ls45f a tagged flow self-SHA ref is the record: it gets its tag in its braces, exit 0" \ + "[ \$rc -eq 0 ] && grep -qxF \" 'foo/bar@$SHA_B': { ref: !!str 'v1.0.0', commit: 'sha1-$SHA_B' }\" '$fx/ls45f/.github/workflows/actions.lock'" + +# tc44: a quoted key or value of a flow `with: {` left open at its line's +# end — continued past an escaped line break, or folded onto the next line — +# hides the keys the next line names: the step is reported (exit 2, +# untouched), never given a second `toolchain`. +mkdir -p "$fx/tc44/.github/workflows" "$fx/tc44b/.github/workflows" +cat > "$fx/tc44/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { "toolch\ + ain": nightly } +YML +cat > "$fx/tc44b/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { + components: "rustfmt, + clippy", toolchain: nightly } +YML +cp "$fx/tc44/.github/workflows/x.yml" "$fx/tc44-before.yml" +cp "$fx/tc44b/.github/workflows/x.yml" "$fx/tc44b-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc44" +check "tc44 a flow input key continued past an escaped line break is UNSUPPORTED (line 5), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 5\\.' <<<\"\$out\" && cmp -s '$fx/tc44-before.yml' '$fx/tc44/.github/workflows/x.yml'" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc44b" +check "tc44b a flow value folded onto the next line is UNSUPPORTED (line 6), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6\\.' <<<\"\$out\" && cmp -s '$fx/tc44b-before.yml' '$fx/tc44b/.github/workflows/x.yml'" + +# tc45 / ls46: `steps: [uses: ...]` is a list of one step, a mapping written +# without braces. The toolchain fixer reports such a SHA-pinned step (exit 2, +# untouched), on the key's line or the next; the self-SHA fixer reads it as +# the action site it is, and the record it proves gets its tag. +mkdir -p "$fx/tc45/.github/workflows" "$fx/ls46/.github/workflows" +cat > "$fx/tc45/.github/workflows/x.yml" <<'YML' +jobs: + a: + steps: [uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772] + b: + steps: + [uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772] +YML +cp "$fx/tc45/.github/workflows/x.yml" "$fx/tc45-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc45" +check "tc45 a step written straight into steps: [ ] is UNSUPPORTED (lines 3 and 6), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 3 6\\.' <<<\"\$out\" && cmp -s '$fx/tc45-before.yml' '$fx/tc45/.github/workflows/x.yml'" +printf 'jobs:\n build:\n steps: [uses: foo/bar@%s]\n' "$SHA_B" > "$fx/ls46/.github/workflows/ci.yml" +lock_records "$fx/ls46" "foo/bar@$SHA_B" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls46-tags.tsv" +TAG_MAP="$fx/ls46-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls46" --offline +check "ls46 steps: [uses: ...] proves the action: its self-SHA record gets its tag, exit 0" \ + "[ \$rc -eq 0 ] && grep -q \"^ ref: 'v1.0.0'\$\" '$fx/ls46/.github/workflows/actions.lock'" + +# tc46 / sp62: an alias given as the `uses` of a flow-style step or job — +# `steps: [uses: *rust]`, `steps: [{ uses: *rust }]`, a step of a `[` opened +# on the line above, `rust: { uses: *call }`, `jobs: { rust: { uses: *call } +# }` — runs the anchor's value like a block key's alias: an anchored +# SHA-pinned toolchain, or a known-dead call, is reported (exit 2, +# untouched), never passed as clean. +mkdir -p "$fx/tc46/.github/workflows" "$fx/sp62/.github/workflows" "$fx/sp62b/.github/workflows" +cat > "$fx/tc46/.github/workflows/x.yml" <<'YML' +env: { ACTION: &rust dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 } +jobs: + a: + steps: [uses: *rust] + b: + steps: [{ uses: *rust }] + c: + steps: [ + { uses: *rust } + ] +YML +cp "$fx/tc46/.github/workflows/x.yml" "$fx/tc46-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc46" +check "tc46 aliased flow steps of an anchored toolchain pin are UNSUPPORTED (lines 4 6 9), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 4 6 9\\.' <<<\"\$out\" && cmp -s '$fx/tc46-before.yml' '$fx/tc46/.github/workflows/x.yml'" +printf 'env: { CALL: &call %s@%s }\njobs:\n rust: { uses: *call }\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp62/.github/workflows/ci.yml" +printf 'env: { CALL: &call %s@%s }\njobs: { rust: { uses: *call } }\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp62b/.github/workflows/ci.yml" +cp "$fx/sp62/.github/workflows/ci.yml" "$fx/sp62-before.yml" +cp "$fx/sp62b/.github/workflows/ci.yml" "$fx/sp62b-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp62" +check "sp62 a flow job calling an alias of a dead pin is UNSUPPORTED, exit 2, untouched, never OK" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:3: .uses: \\*call. runs' <<<\"\$out\" && cmp -s '$fx/sp62-before.yml' '$fx/sp62/.github/workflows/ci.yml'" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp62b" +check "sp62b a job of a one-line jobs: { } calling an alias of a dead pin is UNSUPPORTED, exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:2: .uses: \\*call. runs' <<<\"\$out\" && cmp -s '$fx/sp62b-before.yml' '$fx/sp62b/.github/workflows/ci.yml'" + +# sp61: a stale record written as a flow mapping on its key line moves its +# own key, ref and commit; a nested `uses:` edge at the same SHA (another +# repository's revision, not recomputed here) and a comment stay as written. +mkdir -p "$fx/sp61/.github/workflows" +printf 'jobs:\n rust:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp61/.github/workflows/ci.yml" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'hyperpolymath/standards@%s'\ndependencies:\n 'hyperpolymath/standards@%s': { ref: '%s', commit: 'sha1-%s', uses: ['other/action@%s'] } # pinned at %s\n" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" "$STALE_A" > "$fx/sp61/.github/workflows/actions.lock" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp61" +check "sp61 a flow-mapping record moves its key, ref and commit; its nested edge and comment stay as written" \ + "[ \$rc -eq 3 ] && grep -qxF \" 'hyperpolymath/standards@$GOOD': { ref: '$GOOD', commit: 'sha1-$GOOD', uses: ['other/action@$STALE_A'] } # pinned at $STALE_A\" '$fx/sp61/.github/workflows/actions.lock'" + dq_out=$(printf '%s\n' 'uses: "\x64tolnay/x@1" # "\x41"' "name: 'a\\x41' \"b\\\"c\"" | awk "$(cat "$fleet/scripts/lib/yaml-anchors.awk")"'{ printf "%s|", yaml_decode_dq($0) }') check "yaml_decode_dq decodes double-quoted escapes only, an escaped quote to a byte no reference holds" \ "[ \"\$dq_out\" = \"\$(printf 'uses: \"dtolnay/x@1\" # \"\\\\x41\"|name: '\"'\"'a\\\\x41'\"'\"' \"b\\001c\"|')\" ]" From 3617feb56860816af3aa51a95b3585699c6610cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:36:35 +0000 Subject: [PATCH 44/44] fix(fix-scripts): anchor names read whole; toolchain values past node properties; aliased steps reported; flow lock refs read at their top level - lib/yaml-anchors.awk: an anchor name runs to whitespace or a flow indicator (`&rust/tool`), as YAML reads it; the toolchain and stale-pin alias matchers read `*name` the same way, so an alias is never taken for a shorter anchor's. - fix-rust-toolchain-input.sh: a toolchain value carrying node properties is read past them: null or empty (`&channel null`, `!!null null`, `!!str ""`) is reported, not filled (its aliases would change too); a value (`!!str null`) is the input. A step given as an alias (`- *rust`, `steps: [*rust]`) is reported when the file holds a SHA-pinned toolchain anywhere. - fix-lock-self-sha-refs.sh: a flow-mapping record's `ref` is its top-level entry, found quote- and depth-aware, and rewritten at its own column; a `ref:` inside a quoted string beside it is text. - tests: tc47-tc49b, sp63, ls47, ls47b (the six non-control cases fail on 8978704). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- scripts/fix-lock-self-sha-refs.sh | 64 +++++++++++++----- scripts/fix-rust-toolchain-input.sh | 63 +++++++++++++++--- scripts/fix-stale-reusable-pin.sh | 6 +- scripts/lib/yaml-anchors.awk | 6 +- scripts/tests/fix-workflow-pins.sh | 100 ++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 29 deletions(-) diff --git a/scripts/fix-lock-self-sha-refs.sh b/scripts/fix-lock-self-sha-refs.sh index 2972eaa1..89dcad8b 100755 --- a/scripts/fix-lock-self-sha-refs.sh +++ b/scripts/fix-lock-self-sha-refs.sh @@ -385,18 +385,42 @@ if ! awk -v sq="'" "$ANCHORS_AWK"' i = index(t, "@"); f_repo = substr(t, 1, i - 1); f_sha = substr(t, i + 1) return length(f_sha) == 40 } + # The `ref` entry at the top level of the flow mapping whose `{` is at + # column o of s: returns the column its scalar starts at (past any node + # properties; its opening quote when quoted) and sets rv to the scalar as + # written, or returns 0. Quoted scalars and nested brackets are walked + # over, so a `ref:` written inside a string (`note: "a, ref: "`) or + # a nested mapping is not the record'"'"'s field. The line is already + # decoded: no escapes are left in its double-quoted scalars. + function top_ref(s, o, n, i, c, d, q, j, t) { + n = length(s); d = 0; q = "" + for (i = o; i <= n; i++) { + c = substr(s, i, 1) + if (q != "") { + if (c == q) { if (q == sq && substr(s, i + 1, 1) == sq) { i++; continue }; q = "" } + continue + } + if (c == "\"" || c == sq) { q = c; continue } + if (c == "#" && substr(s, i - 1, 1) ~ /[[:space:]]/) return 0 + if (c == "{" || c == "[") d++ + else if (c == "}" || c == "]") { if (--d == 0) return 0 } + if (d == 1 && (c == "{" || c == ",") && match(substr(s, i + 1), /^[[:space:]]*["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*/)) { + j = i + 1 + RLENGTH; t = substr(s, j) + if (match(t, /^"[^"]*"/) || match(t, /^'"'"'([^'"'"']|'"'"''"'"')*'"'"'/) || match(t, /^[^,}[:space:]]+/)) { rv = substr(t, 1, RLENGTH); return j } + return 0 + } + } + return 0 + } flow_key($0) { inrec = 0 body = $0; sub(/^[^{]*\{/, "", body) if (body !~ /\}[[:space:]]*(#.*)?$/) { print NR "\tM\t" f_repo "\t" f_sha "\t"; next } - # The `ref` entry: after `{` or `,`, its key quoted or not, its value - # quoted or plain up to `,` or `}`, past any node properties. - r = "{" body - if (match(r, "[{,][[:space:]]*[\"" sq "]?ref[\"" sq "]?[[:space:]]*:[[:space:]]*[^,}]*")) { - r = substr(r, RSTART, RLENGTH); sub(/^[{,][[:space:]]*["'"'"']?ref["'"'"']?[[:space:]]*:[[:space:]]*/, "", r) - sub(/^([&!][^[:space:],}]*[[:space:]]+)+/, "", r) - gsub(/["'"'"']/, "", r); sub(/[[:space:]]+$/, "", r) - if (tolower(r) == tolower(f_sha)) print NR "\tI\t" f_repo "\t" f_sha "\t" r + # The `ref` entry of the record'"'"'s own braces, and the column of its + # SHA, where the rewrite below puts the tag. + if ((at = top_ref($0, index($0, "{"))) > 0) { + r = rv; gsub(/["'"'"']/, "", r) + if (tolower(r) == tolower(f_sha)) print NR "\tI\t" f_repo "\t" f_sha "\t" r "\t" (rv ~ /^["'"'"']/ ? at + 1 : at) } next } @@ -419,7 +443,7 @@ if ! awk -v sq="'" "$ANCHORS_AWK"' echo "fix-lock-self-sha-refs: could not parse $LOCK (awk failed); lock left unchanged" >&2 rm -f "$tmp" "$records" "$SITES"; exit 2 fi -while IFS=$'\t' read -r lineno kind repo sha ref_raw; do +while IFS=$'\t' read -r lineno kind repo sha ref_raw col; do sha_lc="${sha,,}" case "$(sha_ref_disposition "$repo" "$sha_lc")" in keep) continue ;; @@ -449,15 +473,25 @@ while IFS=$'\t' read -r lineno kind repo sha ref_raw; do # `null`) is written quoted, so the lock keeps a tag, not a boolean. q="" if [ "$kind" = I ]; then - # The `ref` entry inside the braces, its value as written (quoted or - # plain) up to the `,` or `}` that ends it; nothing else on the line. - if pin_fixer_yaml_nonstring "$tag" && ! sed -n "${lineno}p" "$tmp" | grep -qE "[{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*[\"']"; then q="'"; fi - before=$(sed -n "${lineno}p" "$tmp") - sed -i -E "${lineno}s~([{,][[:space:]]*[\"']?ref[\"']?[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*)([\"']?)$ref_raw\3([[:space:]]*[,}])~\1\3$q$tag$q\3\4~" "$tmp" - if [ "$(sed -n "${lineno}p" "$tmp")" = "$before" ]; then + # The SHA of the top-level `ref` entry, at the column the reader found + # it, as that line spells it; nothing else on the line. The reader saw + # the line decoded, so a line that spells anything before it with + # escapes no longer has the SHA there, and is reported. + nonstring=0 + if pin_fixer_yaml_nonstring "$tag"; then nonstring=1; fi + if ! awk -v ln="$lineno" -v col="$col" -v old="$ref_raw" -v new="$tag" -v nonstring="$nonstring" -v sq="'" ' + NR == ln { + if (substr($0, col, length(old)) != old) exit 3 + pre = substr($0, col - 1, 1) + if (nonstring && pre != "\"" && pre != sq) new = sq new sq + $0 = substr($0, 1, col - 1) new substr($0, col + length(old)) + } + { print }' "$tmp" > "$tmp.ref"; then + rm -f "$tmp.ref" echo "UNRESOLVED $repo@${sha:0:7}: the ref inside the record's flow mapping (line $lineno) is not written in a form this fixer rewrites; set it to $tag by hand (record left as is)" >&2 unresolved=$((unresolved + 1)); continue fi + mv "$tmp.ref" "$tmp" echo "FIXED $repo@${sha:0:7} ref: $sha -> $tag" fixed=$((fixed + 1)); continue fi diff --git a/scripts/fix-rust-toolchain-input.sh b/scripts/fix-rust-toolchain-input.sh index 69337831..2f876002 100755 --- a/scripts/fix-rust-toolchain-input.sh +++ b/scripts/fix-rust-toolchain-input.sh @@ -213,13 +213,31 @@ apply_to_file() { # only pin the parser can be asked to place. The same text inside # another key'"'"'s string (`name: "fixture dtolnay/...@"`) is data. function uses_pin(s) { return sha_ref(s) && tolower(site_view(yaml_decode_dq(s))) ~ USES_SITE_RE } + # A `toolchain` value that carries YAML node properties (`&channel + # null`, `!!str ""`), as `val` writes it from its first property: + # "set" when the scalar after them is one the action reads (`!!str + # null` is the string null), "bad" when it is null or empty. A "bad" + # value is reported, never filled in place: every alias of its anchor + # would change with it. + function after_key(t) { sub(/^[^:]*:[[:space:]]*/, "", t); return t } + function tc_props(val, tags) { + tags = "" + while (match(val, /^[&!][^[:space:],}]*/)) { + if (substr(val, 1, 1) == "!") tags = tags " " substr(val, 1, RLENGTH) + val = substr(val, RLENGTH + 1); sub(/^[[:space:]]+/, "", val) + } + sub(/(^|[[:space:]]+)#.*$/, "", val); sub(/[[:space:]]+$/, "", val) + if ((tags " ") ~ / !!null /) return "bad" + if ((tags " ") ~ / !!str /) return (val == "" || val == "\"\"" || val == "'"'"''"'"'") ? "bad" : "set" + return (val ~ /^(null|Null|NULL|~|""|'"'"''"'"')?$/) ? "bad" : "set" + } # Does line s give a `uses` key, block or flow, an alias that may be # the SHA-pinned toolchain reference — of an anchor that holds it, or # of no anchor read so far? Every such alias on the line is weighed. function bad_alias(s, v, m) { v = site_view(yaml_decode_dq(s)) while (match(tolower(v), USES_ALIAS_RE)) { - m = substr(v, RSTART, RLENGTH); sub(/^.*\*/, "", m) + m = substr(v, RSTART, RLENGTH); sub(/^[^*]*\*/, "", m) if ((m in tc_anchor) || !(m in any_anchor)) return 1 v = substr(v, RSTART + RLENGTH) } @@ -237,7 +255,7 @@ apply_to_file() { buf[++n] = $0; lnum[n] = NR; alias_state[n] = "" v = site_view(yaml_decode_dq($0)) if (match(tolower(v), USES_ALIAS_RE)) { - m = substr(v, RSTART, RLENGTH); sub(/^.*\*/, "", m) + m = substr(v, RSTART, RLENGTH); sub(/^[^*]*\*/, "", m) alias_state[n] = ((m in tc_anchor) || !(m in any_anchor)) ? "bad" : "ok" } } @@ -266,6 +284,10 @@ apply_to_file() { # whose `with:` names no toolchain. function flush( i, j, jl, dl, line, ind, uses_i, has_with, with_bad, with_i, with_flow, with_indent, in_with, in_flow, flow_depth, child_indent, has_tc, key_indent, skey, flow_item, tc_empty_i, tc_empty_flow, tc_empty_eol, tc_nested, tc_await, tc_flow_await, m, pre, post, delim, comment, v, w, lv_rep) { if (n == 0) return + # A step given as an alias (`- *rust`) is the mapping its anchor + # holds, which may be a SHA-pinned toolchain step: noted, and + # reported at the end when the file holds such a pin anywhere. + if (buf[1] ~ STEP_ALIAS_RE) alias_step[++nalias] = lnum[1] # The step'"'"'s key column: the opener'"'"'s key (`- uses:` -> the column of # `uses`), or for a bare `-` — alone, or followed only by a comment # (`- # install Rust`) — the indentation of the first plain line under it. @@ -337,7 +359,8 @@ apply_to_file() { # (`}`, `,`); anything else is its value, spread over lines, # and not this fixer'"'"'s to place. if (tc_flow_await) { w = code_only(line); sub(/^[[:space:]]*/, "", w); if (w != "") { tc_flow_await = 0; if (w !~ /^[,}]/) tc_nested = 1 } } - if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + if (match(v, TC_FLOW_PROP_RE)) { if (tc_props(after_key(substr(v, RSTART, RLENGTH))) == "set") has_tc = 1; else { with_bad = 1; unrec[++nunrec] = lnum[i] } } + else if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_RE || v ~ TC_FLOW_RE) has_tc = 1 if (flow_depth <= 0) in_flow = 0 @@ -362,7 +385,8 @@ apply_to_file() { # a nested mapping, not an empty value: nothing this fixer # can repair. if (tc_await) { tc_await = 0; if (ind > child_indent) tc_nested = 1 } - if (ind == child_indent && dl ~ TC_EMPTY_RE) { tc_empty_i = i; tc_await = 1 } + if (ind == child_indent && match(dl, TC_PROP_RE)) { if (tc_props(after_key(dl)) == "set") has_tc = 1; else { with_bad = 1; unrec[++nunrec] = lnum[i] } } + else if (ind == child_indent && dl ~ TC_EMPTY_RE) { tc_empty_i = i; tc_await = 1 } else if (ind == child_indent && dl ~ TC_RE) has_tc = 1 } } @@ -374,7 +398,8 @@ apply_to_file() { has_with = 1; with_i = i; with_flow = 1 v = level_view(dl, 0); flow_depth = lv_depth if (lv_open) { lv_rep = 1; with_bad = 1; unrec[++nunrec] = lnum[i] } - if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } + if (match(v, TC_FLOW_PROP_RE)) { if (tc_props(after_key(substr(v, RSTART, RLENGTH))) == "set") has_tc = 1; else { with_bad = 1; unrec[++nunrec] = lnum[i] } } + else if (v ~ TC_FLOW_EMPTY_RE) { tc_empty_i = i; tc_empty_flow = 1 } else if (v ~ TC_FLOW_EOL_RE) { tc_empty_i = i; tc_empty_eol = 1; if (v ~ TC_FLOW_BARE_EOL_RE) tc_flow_await = 1 } else if (v ~ TC_FLOW_RE) has_tc = 1 if (flow_depth > 0) in_flow = 1 @@ -445,8 +470,13 @@ apply_to_file() { # starts after `{`, `,` or `[`: `steps: [uses: ...]` is a list of # one step, a mapping written without its braces. USES_SITE_RE = "(^|[-{,[[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]+)*" Q "dtolnay/rust-toolchain@[0-9a-f]+" - # A `uses:` whose value is an alias, `*name` (matched in lower case). - USES_ALIAS_RE = "(^|[-{,[[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*\\*[a-z0-9_.-]+" + # A `uses:` whose value is an alias, `*name` (matched in lower case), + # the name read whole: up to whitespace or a flow indicator. + USES_ALIAS_RE = "(^|[-{,[[:space:]])" Q "uses" Q "[[:space:]]*:[[:space:]]*\\*[^],[{}[:space:]]+" + # A whole step given as an alias: a block item (`- *rust`), or an + # entry of a flow `steps:` sequence (`[*rust]`, `, *rust`). + STEP_ALIAS_RE = "^[[:space:]]*-[[:space:]]+\\*[^],[{}[:space:]]+[[:space:]]*(#.*)?$" + FLOW_ALIAS_RE = "(^|[[,])[[:space:]]*\\*[^],[{}[:space:]]+" # A `uses:` key whose value is not on its line: a block scalar # (`uses: >-`) or nothing at all, the reference on the lines below. USES_OPEN_RE = "^[[:space:]]*(-[[:space:]]+)?" Q "uses" Q "[[:space:]]*:[[:space:]]*([&!][^[:space:],}]*[[:space:]]*)*([|>][-+0-9]*)?[[:space:]]*$" @@ -456,6 +486,11 @@ apply_to_file() { WITH_CHILD_KEY_RE = "^[[:space:]]*" Q "[A-Za-z0-9_.-]+" Q "[[:space:]]*:([[:space:]]|$)" TC_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:]]" TC_FLOW_RE = "[{,][[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[^[:space:],}]" + # The key with a value that starts with node properties (an + # anchor or a tag): read by tc_props/1 rather than the patterns + # below, which see `&channel null` as a value. + TC_PROP_RE = "^[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[&!]" + TC_FLOW_PROP_RE = "(^|[{,])[[:space:]]*" Q "toolchain" Q "[[:space:]]*:[[:space:]]*[&!][^,}]*" # The key present with no value — YAML null in the spellings the core # schema admits unquoted (`toolchain:`, `null`, `Null`, `NULL`, `~`) # or the empty string (`""`, `'"'"''"'"'`): not an input the action can @@ -473,6 +508,9 @@ apply_to_file() { } { ind = indent_of($0) + # Any SHA-pinned toolchain reference anywhere in the file: an + # aliased step may be one. + if (sha_ref($0)) any_pin = 1 # Every anchor whose value is a SHA-pinned toolchain reference # (`ACTION: &rust dtolnay/rust-toolchain@`), wherever it is # defined: an alias of it is that reference. Anchors are read by @@ -520,13 +558,20 @@ apply_to_file() { # So is an alias such a step gives its `uses` (`steps: [uses: # *rust]`) of an anchor that may hold the pin. if ((uses_pin($0) || bad_alias($0)) && (parent_is_steps(ind) || under_steps(ind))) unrec[++nunrec] = NR - } else if ((uses_pin($0) || bad_alias($0)) && parent_is_steps(ind)) unrec[++nunrec] = NR + # A step written into `steps: [*rust]` as an alias, as `- *rust`. + if (keyat[ind] == "steps" && site_view(yaml_decode_dq($0)) ~ FLOW_ALIAS_RE) alias_step[++nalias] = NR + } else if (parent_is_steps(ind)) { + if (uses_pin($0) || bad_alias($0)) unrec[++nunrec] = NR + # A line of a `steps: [` sequence spread over lines. + if (site_view(yaml_decode_dq($0)) ~ FLOW_ALIAS_RE) alias_step[++nalias] = NR + } print } END { flush() + if (any_pin) for (j = 1; j <= nalias; j++) unrec[++nunrec] = alias_step[j] if (nunrec) { - printf "UNSUPPORTED %s: %d SHA-pinned dtolnay/rust-toolchain reference(s) the parser cannot place as a block-style step key (a flow-style `- { ... }` step, a `uses:` value continued below its key, or an alias of an anchored reference?), line(s):", FILENAME, nunrec > "/dev/stderr" + printf "UNSUPPORTED %s: %d SHA-pinned dtolnay/rust-toolchain reference(s) the parser cannot place as a block-style step key (a flow-style `- { ... }` step, a `uses:` value continued below its key, an alias of an anchored reference, or a step given as an alias?), line(s):", FILENAME, nunrec > "/dev/stderr" for (j = 1; j <= nunrec; j++) printf " %d", unrec[j] > "/dev/stderr" printf ". Fix by hand; the file was not changed.\n" > "/dev/stderr" exit 2 diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index d19303cc..d2c09247 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -94,9 +94,9 @@ ANCHORS_AWK=$(cat "$SCRIPT_DIR/lib/yaml-anchors.awk") # value still open at its line's end (folded into the next line) is not read, # "" as well. anchor_value() { # - awk -v want="$2" -v upto="$3" "$ANCHORS_AWK"' + WANT="$2" awk -v upto="$3" "$ANCHORS_AWK"' function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) } - BEGIN { blk = -1; got = "" } + BEGIN { blk = -1; got = ""; want = ENVIRON["WANT"] } NR >= upto { exit } { plain = ($0 !~ /^[[:space:]]*$/ && $0 !~ /^[[:space:]]*#/) @@ -617,7 +617,7 @@ while IFS= read -r -d '' wf; do skip=1 fi done < <(printf '%s\n' "$value" | grep -oiE "^${REUSABLE_REF_RE}\$" || true) - done < <(mask_line "$(sed -n "${lineno}p" "$wf")" | grep -oE "(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*\*[A-Za-z0-9_.-]+" | sed 's/.*\*//' || true) + done < <(mask_line "$(sed -n "${lineno}p" "$wf")" | grep -oE "(^|[-{,[:space:]])[\"']?uses[\"']?[[:space:]]*:[[:space:]]*\*[^],[{}[:space:]]+" | sed 's/^[^*]*\*//' || true) done if [ "$skip" -eq 1 ]; then unsupported=$((unsupported + 1)); continue; fi pending=() diff --git a/scripts/lib/yaml-anchors.awk b/scripts/lib/yaml-anchors.awk index b3a45fcd..8a409892 100644 --- a/scripts/lib/yaml-anchors.awk +++ b/scripts/lib/yaml-anchors.awk @@ -6,7 +6,9 @@ # mawk reads it as gawk does. # # anchor_defs(s, defs) clears defs and sets defs[name] = value for every -# `&name value` on line s, and returns how many there are. An anchor is +# `&name value` on line s, and returns how many there are. A name is every +# character up to whitespace or a flow indicator (`,[]{}`), as YAML reads +# one: `&rust/tool` names `rust/tool`, never `rust`. An anchor is # `&name` where a YAML node starts, outside any quoted scalar and before any # comment: at the line's start or after its list dashes, after `key: `, or # inside a flow collection after `[`, `{` or `,`, past any tag (`!!map &x`). @@ -102,7 +104,7 @@ function anchor_defs(s, defs, n, i, len, c, q, name, v, seen, depth) { if (c == "\"" || c == "'") { q = c; seen = seen "x"; i++; continue } if (c == "{" || c == "[") depth++ else if ((c == "}" || c == "]") && depth > 0) depth-- - if (c == "&" && anchor_at(seen, depth) && match(substr(s, i + 1), /^[A-Za-z0-9_.-]+/)) { + if (c == "&" && anchor_at(seen, depth) && match(substr(s, i + 1), /^[^],[{}[:space:]]+/)) { name = substr(s, i + 1, RLENGTH); i += RLENGTH + 1 while (i <= len && substr(s, i, 1) ~ /[[:space:]]/) i++ c = substr(s, i, 1); v = "" diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index 3a1f2e7e..d1aebd73 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -3703,6 +3703,106 @@ run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp61" check "sp61 a flow-mapping record moves its key, ref and commit; its nested edge and comment stay as written" \ "[ \$rc -eq 3 ] && grep -qxF \" 'hyperpolymath/standards@$GOOD': { ref: '$GOOD', commit: 'sha1-$GOOD', uses: ['other/action@$STALE_A'] } # pinned at $STALE_A\" '$fx/sp61/.github/workflows/actions.lock'" +# ── Review cases (gitbot-fleet#615, round on 8978704) ─────────────────────── +# tc47 / sp63: an anchor name is every character up to whitespace or a flow +# indicator: `&rust/tool` names `rust/tool`, and `*rust/tool` is its alias, +# never `*rust`. The aliased SHA-pinned toolchain, or dead call, is reported +# (exit 2, untouched), never passed as an alias of some other value. +mkdir -p "$fx/tc47/.github/workflows" "$fx/sp63/.github/workflows" +printf 'env:\n ACTION: &rust/tool dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772\njobs:\n check:\n steps:\n - uses: *rust/tool\n' > "$fx/tc47/.github/workflows/x.yml" +cp "$fx/tc47/.github/workflows/x.yml" "$fx/tc47-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc47" +check "tc47 an alias read to its whole name is the anchored toolchain pin: UNSUPPORTED (line 6), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6\\.' <<<\"\$out\" && cmp -s '$fx/tc47-before.yml' '$fx/tc47/.github/workflows/x.yml'" +printf 'env:\n CALL: &call/x %s@%s\njobs:\n rust:\n uses: *call/x\n' "$RC_REUSABLE" "$STALE_A" > "$fx/sp63/.github/workflows/ci.yml" +cp "$fx/sp63/.github/workflows/ci.yml" "$fx/sp63-before.yml" +run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp63" +check "sp63 an alias read to its whole name runs the anchored dead call: UNSUPPORTED, exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED .github/workflows/ci.yml:5: .uses: \\*call/x. runs' <<<\"\$out\" && cmp -s '$fx/sp63-before.yml' '$fx/sp63/.github/workflows/ci.yml'" + +# tc48: a toolchain value carrying node properties is read past them. Null +# or empty after them (`&channel null`, `!!null null`, `!!str ""`) is no +# input, and is reported rather than filled in place (every alias of the +# anchor would change with it): exit 2, untouched. A value after them is +# the input (`!!str null` is the string null): left as it is, exit 0. +mkdir -p "$fx/tc48/.github/workflows" "$fx/tc48b/.github/workflows" +cat > "$fx/tc48/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: &channel null + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: !!null null } + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: !!str "" +YML +cat > "$fx/tc48b/.github/workflows/x.yml" <<'YML' +jobs: + check: + steps: + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: !!str null + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: { toolchain: &channel nightly } +YML +cp "$fx/tc48/.github/workflows/x.yml" "$fx/tc48-before.yml" +cp "$fx/tc48b/.github/workflows/x.yml" "$fx/tc48b-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc48" +check "tc48 a null or empty toolchain behind node properties is UNSUPPORTED (lines 6 8 11), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 6 8 11\\.' <<<\"\$out\" && cmp -s '$fx/tc48-before.yml' '$fx/tc48/.github/workflows/x.yml'" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc48b" +check "tc48b a toolchain value behind node properties is the input: left as it is, exit 0" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc48b-before.yml' '$fx/tc48b/.github/workflows/x.yml'" + +# tc49: a step given as an alias (`- *rust`, `steps: [*rust]`) is the +# mapping its anchor holds, which may be the SHA-pinned toolchain step: in a +# file that holds such a pin it is reported (exit 2, untouched). Control: +# with no pinned toolchain anywhere, an aliased step is nothing to repair. +mkdir -p "$fx/tc49/.github/workflows" "$fx/tc49b/.github/workflows" +cat > "$fx/tc49/.github/workflows/x.yml" <<'YML' +jobs: + check: + strategy: + matrix: + include: + - step: &rust { uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 } + steps: + - *rust + b: + steps: [*rust] +YML +printf 'x: &chk { uses: actions/checkout@v4 }\njobs:\n b:\n steps: [*chk]\n c:\n steps:\n - *chk\n' > "$fx/tc49b/.github/workflows/x.yml" +cp "$fx/tc49/.github/workflows/x.yml" "$fx/tc49-before.yml" +cp "$fx/tc49b/.github/workflows/x.yml" "$fx/tc49b-before.yml" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc49" +check "tc49 aliased steps in a file with a pinned toolchain are UNSUPPORTED (lines 8 10), exit 2, untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*line(s): 8 10\\.' <<<\"\$out\" && cmp -s '$fx/tc49-before.yml' '$fx/tc49/.github/workflows/x.yml'" +run_fix bash "$fleet/scripts/fix-rust-toolchain-input.sh" "$fx/tc49b" +check "tc49b aliased steps with no pinned toolchain in the file are left as they are, exit 0" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/tc49b-before.yml' '$fx/tc49b/.github/workflows/x.yml'" + +# ls47: the `ref` of a flow-mapping record is its top-level entry: a +# `ref:` written inside a quoted string beside it is text. The record's own +# self-SHA ref gets its tag and the string keeps its text; a record whose +# own ref is a tag is left as it is. +mkdir -p "$fx/ls47/.github/workflows" "$fx/ls47b/.github/workflows" +action_steps "$fx/ls47/.github/workflows" "foo/bar@$SHA_B" +action_steps "$fx/ls47b/.github/workflows" "foo/bar@$SHA_B" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s': { note: \"hello, ref: %s, fixture\", ref: '%s', commit: 'sha1-%s' }\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls47/.github/workflows/actions.lock" +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/ci.yml':\n - 'foo/bar@%s'\ndependencies:\n 'foo/bar@%s': { note: \"hello, ref: %s, fixture\", ref: 'v1', commit: 'sha1-%s' }\n" "$SHA_B" "$SHA_B" "$SHA_B" "$SHA_B" > "$fx/ls47b/.github/workflows/actions.lock" +cp "$fx/ls47b/.github/workflows/actions.lock" "$fx/ls47b-before.lock" +printf 'foo/bar\t%s\tv1.0.0\n' "$SHA_B" > "$fx/ls47-tags.tsv" +TAG_MAP="$fx/ls47-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls47" --offline +check "ls47 the record's top-level ref gets its tag; a ref: inside its quoted note stays as written, exit 0" \ + "[ \$rc -eq 0 ] && grep -qxF \" 'foo/bar@$SHA_B': { note: \\\"hello, ref: $SHA_B, fixture\\\", ref: 'v1.0.0', commit: 'sha1-$SHA_B' }\" '$fx/ls47/.github/workflows/actions.lock'" +TAG_MAP="$fx/ls47-tags.tsv" run_fix bash "$fleet/scripts/fix-lock-self-sha-refs.sh" "$fx/ls47b" --offline +check "ls47b a record whose own ref is a tag is no self-SHA record: its quoted note is not rewritten, exit 0" \ + "[ \$rc -eq 0 ] && cmp -s '$fx/ls47b-before.lock' '$fx/ls47b/.github/workflows/actions.lock'" + dq_out=$(printf '%s\n' 'uses: "\x64tolnay/x@1" # "\x41"' "name: 'a\\x41' \"b\\\"c\"" | awk "$(cat "$fleet/scripts/lib/yaml-anchors.awk")"'{ printf "%s|", yaml_decode_dq($0) }') check "yaml_decode_dq decodes double-quoted escapes only, an escaped quote to a byte no reference holds" \ "[ \"\$dq_out\" = \"\$(printf 'uses: \"dtolnay/x@1\" # \"\\\\x41\"|name: '\"'\"'a\\\\x41'\"'\"' \"b\\001c\"|')\" ]"