From b7f175541ee24fd731507e8ee9441d9a95375b0c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:23:11 -0400 Subject: [PATCH 1/2] feat(claude-lanes): repin only the ci-workflows paths a release changed repin-callers.sh apply now moves a ci-workflows pin only when the target release changed what it runs: the reusable workflow file, anything under the composite action directory, or a path either reaches through a commit-relative `$/` action or `./.github/workflows/` nested workflow. It decides from one cached compare per distinct pinned SHA. It fails closed and moves every pin at that SHA when the file list is at the 300-file cap, or when a non-test, non-README, non-docs/ file that no pinned path reaches changed. apply --root runs the same tool against any repository's tracked .github/ callers, so consumer repins stop being hand edits. The standards lane's invocation is unchanged. The lockstep plans only pins apply moved and no longer rewrites callers itself, so an unchanged reusable pin needs no contract copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DpzEJAE4EMndcWQXiKSZYn --- .../maintenance-repin-ci-workflows.yml | 8 +- components/claude-lanes/repin-callers.sh | 538 ++++++++++++++---- components/claude-lanes/repin-callers.test.sh | 218 ++++++- .../claude-lanes/repin-policy-lockstep.mjs | 63 +- .../repin-policy-lockstep.test.mjs | 65 +-- components/managed-files-guard/README.md | 15 +- 6 files changed, 676 insertions(+), 231 deletions(-) diff --git a/.github/workflows/maintenance-repin-ci-workflows.yml b/.github/workflows/maintenance-repin-ci-workflows.yml index 167fcb0b..e59b7393 100644 --- a/.github/workflows/maintenance-repin-ci-workflows.yml +++ b/.github/workflows/maintenance-repin-ci-workflows.yml @@ -99,6 +99,9 @@ jobs: id: repin if: steps.release.outputs.resolved == 'true' env: + # apply compares each pinned SHA to the release and reads the + # sources a pin reaches, so it calls gh. + GH_TOKEN: ${{ github.token }} TAG: ${{ steps.release.outputs.tag }} SHA: ${{ steps.release.outputs.sha }} # Still required by apply's argv (malformed dates fail closed). @@ -216,9 +219,10 @@ jobs: ## Summary - Re-pins every `melodic-software/ci-workflows` reference under + Re-pins each `melodic-software/ci-workflows` reference under `components/claude-lanes/` and `components/claude-lanes-hosted/`, - the `managed-files-guard` caller components, and this repository's own sync-family callers from + the `managed-files-guard` caller components, and this repository's own sync-family callers + whose pinned path the release changed, from `${{ steps.repin.outputs.old-tags }}` to `${{ steps.release.outputs.tag }}` (`${{ steps.release.outputs.sha }}`), the newest ci-workflows diff --git a/components/claude-lanes/repin-callers.sh b/components/claude-lanes/repin-callers.sh index 6a822c59..3f6305aa 100755 --- a/components/claude-lanes/repin-callers.sh +++ b/components/claude-lanes/repin-callers.sh @@ -6,14 +6,23 @@ # # Two subcommands, matching the workflow's two steps: # -# repin-callers.sh resolve newest release -> tag, SHA, date -# repin-callers.sh apply rewrite the caller pins +# repin-callers.sh resolve newest release -> tag, SHA, date +# repin-callers.sh apply [--root ] rewrite the pins the release changed +# +# apply moves a pin only when the release changed the path it pins (see +# repin::decide); every other pin stays byte-identical. Without --root it +# rewrites this repository's enumerated lane callers. With --root it rewrites +# every git-tracked .github/ YAML file of the repository at that pins +# ci-workflows, which is how a consumer checkout repins with this same tool. # # Both report through GITHUB_OUTPUT (`key=value`, the Actions convention), so # a test sets that variable to a scratch file and reads the result the same # way the workflow does. Progress and diagnostics go to stdout/stderr as # Actions workflow commands; every failure path exits non-zero. set -euo pipefail +# A failing function inside `$(...)` must fail the assignment that called it, +# so planning errors cannot be read as an empty answer. +shopt -s inherit_errexit # The directory holding the lane callers, relative to the repository root. # ENUMERATED, never discovered: a repository-wide search for ci-workflows pins @@ -44,25 +53,35 @@ readonly -a EXTRA_CALLER_FILES=( # Any `uses:` reference to a ci-workflows reusable workflow or composite action # pinned by 40-character commit SHA. readonly PIN_RE='uses: melodic-software/ci-workflows/[^@[:space:]]+@[0-9a-fA-F]{40}' -# The pin-comment convention's fallback form (`# `), -# which a pin to an as-yet-unreleased commit carries. The date is provenance -# for humans; apply decides "ahead of the release" from GitHub compare -# ancestry against the release SHA, not from that date. -readonly FALLBACK_PIN_RE="${PIN_RE}[[:space:]]+# [0-9a-f]{7,40} [0-9]{4}-[0-9]{2}-[0-9]{2}" readonly DATE_RE='^[0-9]{4}-[0-9]{2}-[0-9]{2}$' readonly UPSTREAM_REPO='melodic-software/ci-workflows' # Old -> new upstream paths. Its `repo: ci-workflows` entries with a path # `to` are the only renames apply follows; it keeps no copy of its own. -readonly RENAME_MAP='components/github-actions-conventions/rename-map.json' +# Resolved beside this script, so a run against another repository root +# reads the same map. +RENAME_MAP="$(cd "$(dirname "${BASH_SOURCE[0]}")/../github-actions-conventions" && pwd)/rename-map.json" +readonly RENAME_MAP readonly UPSTREAM_PATH_RE='^[A-Za-z0-9._/-]+$' +# The two `uses:` forms that run upstream code from the pinning commit itself: +# a step-level `$/` action and a job-level `./.github/workflows/` +# nested workflow, the commit-relative forms runner-policy diffs +# (components/runner-policy/runner-policy.mjs). The optional character admits +# a quoted value. +readonly COMMIT_RELATIVE_RE='uses:[[:space:]]*.?(\$/[A-Za-z0-9_./-]+|\./\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml)' +# GitHub's compare response lists at most 300 files +# (https://docs.github.com/en/rest/commits/commits#compare-two-commits); a list +# that long may be cut short, so it cannot prove any path unchanged. +readonly COMPARE_FILE_CAP=300 usage() { cat >&2 <<'USAGE' usage: - repin-callers.sh resolve resolve the newest release to tag, SHA, and commit date - repin-callers.sh apply re-pin the callers to tag/SHA; is that commit's YYYY-MM-DD + repin-callers.sh resolve resolve the newest release to tag, SHA, and commit date + repin-callers.sh apply [--root ] re-pin the pins the release changed to tag/SHA; + is that commit's YYYY-MM-DD; --root repins + the .github/ callers of the repository at -Requires GITHUB_OUTPUT to name a writable file; `resolve` also requires gh. +Requires GITHUB_OUTPUT to name a writable file, and gh. USAGE } @@ -186,58 +205,245 @@ repin::resolve() { } >> "$GITHUB_OUTPUT" } -# repin::compare_status +# repin::compare # -# Prints GitHub's compare `status` of ... on the -# upstream repo (https://docs.github.com/en/rest/commits/commits#compare-two-commits): -# ahead — pin has commits the release does not; rewriting would downgrade -# diverged — neither is an ancestor of the other; same downgrade risk -# behind — pin is an ancestor of the release; the release contains it -# identical — pin *is* the release -# A failed lookup is fatal: guessing "not ahead" would rewrite, which is the -# downgrade this fence exists to prevent. -repin::compare_status() { - local release_sha="$1" pin_sha="$2" status err - err="$(mktemp)" - # shellcheck disable=SC2064 - trap "rm -f '$err'" RETURN - if ! status="$(gh api "repos/${UPSTREAM_REPO}/compare/${release_sha}...${pin_sha}" \ - --jq .status 2>"$err")"; then - echo "::error::Could not compare ${release_sha}...${pin_sha} on ${UPSTREAM_REPO}." >&2 - cat "$err" >&2 - return 1 +# Prints GitHub's compare `status` of ... +# (https://docs.github.com/en/rest/commits/commits#compare-two-commits), read +# from the release's side: +# ahead — the release contains the pin; `files` is what it changed since +# identical — the pin is the release +# behind — the pin has commits the release does not; moving would downgrade +# diverged — neither contains the other; same downgrade risk +# One call per distinct current SHA: the response is cached in REPIN_CACHE for +# the rest of the run, where repin::changed_files reads its file list. A failed +# lookup is fatal: guessing would either downgrade a pin or skip a change. +repin::compare() { + local current="$1" release="$2" cached="${REPIN_CACHE}/compare-$1.json" status + if [[ ! -f "$cached" ]]; then + if ! gh api "repos/${UPSTREAM_REPO}/compare/${current}...${release}" \ + > "${cached}.part" 2> "${cached}.err"; then + echo "::error::Could not compare ${current}...${release} on ${UPSTREAM_REPO}." >&2 + cat "${cached}.err" >&2 + return 1 + fi + mv "${cached}.part" "$cached" fi - printf '%s\n' "$status" + status="$(jq -r '.status' "$cached")" || return 1 + case "$status" in + ahead | identical | behind | diverged) printf '%s\n' "$status" ;; + *) + echo "::error::compare ${current}...${release} returned '${status}', not a documented status." >&2 + return 1 + ;; + esac } -# repin::ahead_of +# repin::changed_files # -# Prints the SHA of a fallback-form pin in that is not an ancestor of -# (compare status `ahead` or `diverged`), or nothing. Tag-form -# pins never match: a release is the newest of its line by construction of -# `resolve`. Day-level pin-comment dates are not consulted — same-day pins -# and cross-branch timestamps cannot prove containment. -repin::ahead_of() { - local file="$1" release_sha="$2" pin_line pin_sha status - while IFS= read -r pin_line; do - [[ -n "$pin_line" ]] || continue - pin_sha="$(sed -nE 's/.*@([0-9a-fA-F]{40}).*/\1/p' <<<"$pin_line" | head -n1)" - [[ -n "$pin_sha" ]] || continue - # Not `|| return 1`: that form is SC2310 (set -e suppressed in ||). - # compare_status already failed-loud; with set -e the assignment exits. - status="$(repin::compare_status "$release_sha" "$pin_sha")" - case "$status" in - ahead | diverged) - echo "$pin_sha" +# Prints every path the cached compare of touched, a renamed file +# under both its names, or the single line `*` when the list cannot be trusted +# as complete: absent, or at the documented cap. +repin::changed_files() { + jq -r --argjson cap "$COMPARE_FILE_CAP" \ + 'if (.files | type) != "array" or (.files | length) >= $cap then "*" + else .files[] | .filename, (.previous_filename // empty) end' \ + "${REPIN_CACHE}/compare-$1.json" +} + +# repin::source +# +# Prints the upstream file at , or nothing when the path does not exist +# there: a path the release removed or renamed is in its compare file list, so +# that list, not this read, moves its pin. Any failure but a 404 is fatal. +repin::source() { + local path="$1" sha="$2" err="${REPIN_CACHE}/source.err" + if gh api -H 'Accept: application/vnd.github.raw' \ + "repos/${UPSTREAM_REPO}/contents/${path}?ref=${sha}" 2> "$err"; then + return 0 + fi + if grep -q 'HTTP 404' "$err"; then + return 0 + fi + echo "::error::Could not read ${path} at ${sha} on ${UPSTREAM_REPO}." >&2 + cat "$err" >&2 + return 1 +} + +# repin::refs_in +# +# Prints the upstream paths runs through a commit-relative `uses:` +# (COMMIT_RELATIVE_RE), read from its source at the release, a composite +# through its action.yml or action.yaml. Cached per path: several pins can +# reach one composite. +repin::refs_in() { + local path="$1" sha="$2" cached text + cached="${REPIN_CACHE}/refs-${path//\//__}" + if [[ ! -f "$cached" ]]; then + if [[ "$path" =~ \.ya?ml$ ]]; then + text="$(repin::source "$path" "$sha")" + else + text="$(repin::source "${path}/action.yml" "$sha")" + if [[ -z "$text" ]]; then + text="$(repin::source "${path}/action.yaml" "$sha")" + fi + fi + { grep -oE "$COMMIT_RELATIVE_RE" <<< "$text" || true; } \ + | sed -E 's|^uses:[[:space:]]*.?[$.]/||; s|/+$||' | sort -u > "$cached" + fi + cat "$cached" +} + +# repin::reach +# +# Prints and every path it reaches through commit-relative references, +# transitively. A pin runs all of them from its own commit, so a change to any +# of them changes what the pin runs. +repin::reach() { + local next refs ref seen=$'\n'"$1"$'\n' + local -a queue=("$1") + while [[ "${#queue[@]}" -gt 0 ]]; do + next="${queue[0]}" + queue=("${queue[@]:1}") + refs="$(repin::refs_in "$next" "$2")" + while IFS= read -r ref; do + [[ -n "$ref" && "$seen" != *$'\n'"${ref}"$'\n'* ]] || continue + seen+="${ref}"$'\n' + queue+=("$ref") + done <<< "$refs" + done + printf '%s' "${seen#$'\n'}" +} + +# repin::touches +# +# True when a changed file is one of the reached paths or lies beneath one: a +# reusable workflow is one file, a composite action its whole directory. +repin::touches() { + local file path + while IFS= read -r file; do + [[ -n "$file" ]] || continue + while IFS= read -r path; do + [[ -n "$path" ]] || continue + if [[ "$file" == "$path" || "$file" == "$path"/* ]]; then return 0 - ;; - behind | identical) ;; - *) - echo "::error::compare ${release_sha}...${pin_sha} returned '${status}', not a documented status." >&2 - return 1 - ;; + fi + done <<< "$2" + done <<< "$1" + return 1 +} + +# repin::inert +# +# True for a file no pin executes: a test (*.test.*), a README.md, or docs/. +repin::inert() { + local base="${1##*/}" + [[ "$base" == *.test.* || "$base" == README.md || "$1" == docs/* ]] +} + +# repin::decide +# +# Prints `move` when the release changed what the pin @ runs, +# `keep` when it did not, `ahead` when is fenced (not contained by the +# release), and `current` when the pin already is the release. Fails closed to +# `move` when the compare's file list is incomplete, or when it changed a file +# that is not inert and that no pinned path reaches (a shared script, a schema): +# nothing then proves which pins depend on it. +repin::decide() { + local path="$1" sha="$2" release="$3" fenced="$4" pinned="$5" + local status files reach file other covered + if [[ "$sha" == "$release" ]]; then + echo current + return 0 + fi + if grep -qxF -- "$sha" <<< "$fenced"; then + echo ahead + return 0 + fi + status="$(repin::compare "$sha" "$release")" + if [[ "$status" == identical ]]; then + echo current + return 0 + fi + files="$(repin::changed_files "$sha")" + if [[ -z "$files" ]]; then + echo keep + return 0 + fi + if [[ "$files" == '*' ]]; then + echo move + return 0 + fi + reach="$(repin::reach "$path" "$release")" + # shellcheck disable=SC2310 # a pure predicate: nothing in it needs errexit + if repin::touches "$files" "$reach"; then + echo move + return 0 + fi + while IFS= read -r file; do + # shellcheck disable=SC2310 # a pure predicate: nothing in it needs errexit + if [[ -z "$file" ]] || repin::inert "$file"; then + continue + fi + covered=false + while IFS= read -r other; do + reach="$(repin::reach "$other" "$release")" + # shellcheck disable=SC2310 # a pure predicate: nothing in it needs errexit + if repin::touches "$file" "$reach"; then + covered=true + break + fi + done <<< "$pinned" + if [[ "$covered" == false ]]; then + echo move + return 0 + fi + done <<< "$files" + echo keep +} + +# repin::refs ... +# +# Prints one `filepathshacomment` line per pin occurrence, the +# SHA lowercased and the comment `vX.Y.Z` (the tag form), `fallback` (the +# pin-comment convention's `# ` form, which a pin to +# an as-yet-unreleased commit carries), or empty. +repin::refs() { + local file match path sha comment + for file in "$@"; do + while IFS= read -r match; do + path="${match#uses: melodic-software/ci-workflows/}" + path="${path%%@*}" + sha="${match#*@}" + sha="${sha:0:40}" + comment='' + if [[ "$match" =~ \#\ (v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + comment="${BASH_REMATCH[1]}" + elif [[ "$match" =~ \#\ [0-9a-f]{7,40}\ [0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + comment=fallback + fi + printf '%s\t%s\t%s\t%s\n' "$file" "$path" "${sha,,}" "$comment" + done < <(grep -oE "${PIN_RE}([[:space:]]+# [^[:space:]]+( [0-9]{4}-[0-9]{2}-[0-9]{2})?)?" "$file" || true) + done +} + +# repin::fenced +# +# Prints each SHA a fallback-form pin names that the release does not contain +# (compare `behind` or `diverged`). Every pin at such a SHA is left as is: +# moving it would go backwards. Tag-form pins are not fenced, since a release +# is the newest of its line by construction of `resolve`. Day-level pin-comment +# dates are not consulted: same-day pins and cross-branch timestamps cannot +# prove containment. +repin::fenced() { + local sha status + while IFS= read -r sha; do + [[ -n "$sha" && "$sha" != "$2" ]] || continue + status="$(repin::compare "$sha" "$2")" + case "$status" in + behind | diverged) echo "$sha" ;; + *) ;; esac - done < <(grep -hE "$FALLBACK_PIN_RE" "$file" || true) + done < <(awk -F'\t' '$4 == "fallback" { print $3 }' <<< "$1" | sort -u) } # repin::path_state @@ -262,9 +468,9 @@ repin::path_state() { fi } -# repin::path_map ... +# repin::path_map ... # -# Prints `fromto` for each upstream path pinned in ... that +# Prints `fromto` for each upstream ... that # RENAME_MAP renames and that carries only under its new name. A # release cut before the rename still has the old path, so it maps nothing. # A pinned path absent under both names is fatal: no rewrite could be right. @@ -279,7 +485,7 @@ repin::path_map() { # failure below returns explicitly. entries="$(jq -r '.[] | select(.repo == "ci-workflows" and (.to | startswith(".github/"))) | [.from, .to] | @tsv' "$RENAME_MAP")" || return 1 - pinned="$(grep -hoE "$PIN_RE" "$@" | sed -E 's|^uses: [^/]+/[^/]+/||; s|@.*||' | sort -u)" + pinned="$(printf '%s\n' "$@" | sort -u)" while IFS=$'\t' read -r from to; do if [[ -z "$from" ]] || ! grep -qxF -- "$from" <<<"$pinned"; then continue @@ -306,33 +512,29 @@ repin::path_map() { done <<<"$entries" } -# repin::apply +# repin::targets [] # -# Emits changed=false and exits 0 when the callers already carry /; -# emits changed=true plus old-tags and version-note otherwise, leaving the -# rewritten files in the working tree for the caller to commit. A file whose -# fallback-form pin is not an ancestor of is ahead of the release and -# is left untouched (see repin::ahead_of); it is reported, never rewritten. -repin::apply() { - local tag="$1" sha="$2" release_date="$3" - local root expected rewritten old_sha old_tags new_major old_major old_tag note ahead_sha ahead_list delim - local path_map from to renamed - local -a targets rewrite ahead excludes sed_args - - if [[ ! "$release_date" =~ $DATE_RE ]]; then - echo "::error::Release date '${release_date}' is not YYYY-MM-DD." >&2 - return 2 - fi - - root="$(git rev-parse --show-toplevel)" - cd "$root" - - targets=("$LANE_DIR"/*.yml) +# Prints the caller files apply may rewrite, one per line, relative to the +# repository root apply has changed into. Without : LANE_DIR's callers +# plus EXTRA_CALLER_FILES, enumerated, never discovered. With : every +# git-tracked .github/ YAML file that pins ci-workflows, which in a consumer +# checkout is the whole caller set. +repin::targets() { local file extra_present=0 extra_missing=0 + local -a found + if [[ "$#" -eq 1 ]]; then + git ls-files -- '.github/*.yml' '.github/*.yaml' | while IFS= read -r file; do + if grep -qE "$PIN_RE" -- "$file"; then + printf '%s\n' "$file" + fi + done + return 0 + fi + found=("$LANE_DIR"/*.yml) for file in "${EXTRA_CALLER_FILES[@]}"; do if [[ -f "$file" ]]; then extra_present=1 - targets+=("$file") + found+=("$file") else extra_missing=1 fi @@ -348,77 +550,146 @@ repin::apply() { done return 1 fi - for file in "${targets[@]}"; do + for file in "${found[@]}"; do if [[ ! -f "$file" ]]; then echo "::error::Expected enumerated caller '${file}' does not exist." >&2 return 1 fi done + printf '%s\n' "${found[@]}" +} + +# repin::apply [] +# +# Moves each pin the release changed (repin::decide) to with a `# ` +# comment and leaves every other pin byte-identical. Emits changed=false and +# exits 0 when no pin moves; emits changed=true plus old-sha, old-tags and +# version-note otherwise, leaving the rewritten files in the working tree for +# the caller to commit. A fallback-form pin the release does not contain is +# ahead of it and is reported, never rewritten (repin::fenced). +repin::apply() { + local tag="$1" sha="$2" release_date="$3" dir="${4:-}" + local root found refs fenced pinned decided moved file path pin_sha decision + local expected before after old_sha old_tags new_major old_major old_tag note ahead_list kept_list delim + local path_map from to renamed moved_paths + local -a targets ahead excludes sed_args + + if [[ ! "$release_date" =~ $DATE_RE ]]; then + echo "::error::Release date '${release_date}' is not YYYY-MM-DD." >&2 + return 2 + fi + + if [[ -n "$dir" ]]; then + root="$(git -C "$dir" rev-parse --show-toplevel)" + else + root="$(git rev-parse --show-toplevel)" + fi + cd "$root" + + if [[ -n "$dir" ]]; then + found="$(repin::targets "$dir")" + else + found="$(repin::targets)" + fi + if [[ -z "$found" ]]; then + echo "::error::No file under ${root}/.github pins ${UPSTREAM_REPO}." >&2 + return 1 + fi + mapfile -t targets <<< "$found" + + refs="$(repin::refs "${targets[@]}")" + if [[ -z "$refs" ]]; then + echo "::error::The caller files carry no ${UPSTREAM_REPO} pin to re-pin." >&2 + return 1 + fi + fenced="$(repin::fenced "$refs" "$sha")" + pinned="$(cut -f2 <<< "$refs" | sort -u)" + + # One decision per distinct path@sha; every occurrence of it shares it. + decided='' + while IFS=$'\t' read -r path pin_sha; do + if [[ ! "$path" =~ $UPSTREAM_PATH_RE ]]; then + echo "::error::Pinned path '${path}' is not a plain repository path." >&2 + return 1 + fi + decision="$(repin::decide "$path" "$pin_sha" "$sha" "$fenced" "$pinned")" + decided+="${path}"$'\t'"${pin_sha}"$'\t'"${decision}"$'\n' + done < <(cut -f2,3 <<< "$refs" | sort -u) + + moved="$(awk -F'\t' '$3 == "move" { print $1 "\t" $2 }' <<< "$decided")" - # Split the enumerated set into files this release may advance and files - # already ahead of it. The split is per file: every enumerated caller pins - # one ci-workflows revision, so a file is either behind the release or not. - rewrite=() ahead=() - for file in "${targets[@]}"; do - ahead_sha="$(repin::ahead_of "$file" "$sha")" - if [[ -n "$ahead_sha" ]]; then - echo "::notice::${file} pins ${ahead_sha}, not an ancestor of ${tag} (${sha}); left as is." + while IFS=$'\t' read -r file pin_sha; do + [[ -n "$file" ]] || continue + echo "::notice::${file} pins ${pin_sha}, not an ancestor of ${tag} (${sha}); left as is." + if [[ " ${ahead[*]} " != *" ${file} "* ]]; then ahead+=("$file") + fi + done < <(awk -F'\t' 'NR == FNR { if ($0 != "") fenced[$0] = 1; next } ($3 in fenced) { print $1 "\t" $3 }' \ + <(printf '%s\n' "$fenced") <(printf '%s\n' "$refs") | awk '!seen[$0]++') + + kept_list='' + while IFS=$'\t' read -r path pin_sha; do + [[ -n "$path" ]] || continue + echo "::notice::${tag} changes nothing ${path}@${pin_sha:0:7} runs; that pin stays." + kept_list+="\`${path}\`, " + done < <(awk -F'\t' '$3 == "keep" { print $1 "\t" $2 }' <<< "$decided") + + if [[ -z "$moved" ]]; then + if ! awk -F'\t' '$3 != "ahead" && $3 != "" { found = 1 } END { exit !found }' <<< "$decided"; then + echo "::notice::Every enumerated caller is ahead of ${tag}; nothing to propose." else - rewrite+=("$file") + echo "::notice::No pin needs ${tag}; nothing to propose." fi - done - if [[ "${#rewrite[@]}" -eq 0 ]]; then - echo "::notice::Every enumerated caller is ahead of ${tag}; nothing to propose." echo 'changed=false' >> "$GITHUB_OUTPUT" return 0 fi - # grep exits 1 on no match, which pipefail would turn into a failed - # assignment under errexit; `|| true` keeps the count (0) and drops the exit. - expected="$(grep -hoE "$PIN_RE" "${rewrite[@]}" | wc -l || true)" - # Unique old SHAs across every rewritten file — do not collapse to the - # first pin. The workflow passes this unique-set to lockstep, which accepts - # one SHA or a comma-separated list and still reads each caller file. - old_sha="$(grep -hoE "$PIN_RE" "${rewrite[@]}" \ - | grep -oE '[0-9a-fA-F]{40}$' | tr '[:upper:]' '[:lower:]' | sort -u | paste -sd, - || true)" - old_tags="$(grep -hoE "${PIN_RE}[[:space:]]+# v[0-9]+\.[0-9]+\.[0-9]+" "${rewrite[@]}" \ - | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+$' | sort -u | paste -sd, - || true)" - - path_map="$(repin::path_map "$sha" "${rewrite[@]}")" - sed_args=() + # Count every moved occurrence, then require exactly that many new + # `@ # ` pins after the rewrite. + expected="$(awk -F'\t' 'NR == FNR { if ($0 != "") m[$0] = 1; next } (($2 "\t" $3) in m)' \ + <(printf '%s\n' "$moved") <(printf '%s\n' "$refs") | wc -l)" + before="$(grep -hoE "uses: ${UPSTREAM_REPO}/[^@[:space:]]+@${sha}[[:space:]]+# ${tag}\$" "${targets[@]}" | wc -l || true)" + old_sha="$(cut -f2 <<< "$moved" | sort -u | paste -sd, -)" + old_tags="$(awk -F'\t' 'NR == FNR { if ($0 != "") m[$0] = 1; next } (($2 "\t" $3) in m) && $4 ~ /^v/ { print $4 }' \ + <(printf '%s\n' "$moved") <(printf '%s\n' "$refs") | sort -u | paste -sd, - || true)" + + moved_paths="$(cut -f1 <<< "$moved" | sort -u)" + # shellcheck disable=SC2086 # one argument per moved path, none with whitespace + path_map="$(repin::path_map "$sha" $moved_paths)" renamed='' while IFS=$'\t' read -r from to; do [[ -n "$from" ]] || continue echo "::notice::${tag} carries ${from} as ${to}; the pins follow the rename." - sed_args+=(-e "s|(uses: melodic-software/ci-workflows/)${from//./\\.}@|\1${to}@|") renamed+="\`${from}\` to \`${to}\`, " - done <<<"$path_map" - sed_args+=(-e "s|(uses: melodic-software/ci-workflows/[^@[:space:]]+)@[0-9a-fA-F]{40}.*|\1@${sha} # ${tag}|") - sed -i -E "${sed_args[@]}" "${rewrite[@]}" - - rewritten="$(grep -hoE "${PIN_RE}[[:space:]]+# ${tag}\$" "${rewrite[@]}" | wc -l || true)" - if [[ "$expected" -eq 0 || "$expected" -ne "$rewritten" ]]; then - echo "::error::Expected ${expected} ci-workflows pins to carry '@${sha} # ${tag}'; ${rewritten} do." >&2 + done <<< "$path_map" + sed_args=() + while IFS=$'\t' read -r path pin_sha; do + to="$(awk -F'\t' -v p="$path" '$1 == p { print $2 }' <<< "$path_map")" + sed_args+=(-e "s|(uses: ${UPSTREAM_REPO}/)${path//./\\.}@${pin_sha}([^0-9a-fA-F].*)?\$|\1${to:-$path}@${sha} # ${tag}|I") + done <<< "$moved" + sed -i -E "${sed_args[@]}" "${targets[@]}" + + after="$(grep -hoE "uses: ${UPSTREAM_REPO}/[^@[:space:]]+@${sha}[[:space:]]+# ${tag}\$" "${targets[@]}" | wc -l || true)" + if [[ "$expected" -eq 0 || "$((after - before))" -ne "$expected" ]]; then + echo "::error::Expected ${expected} ci-workflows pins to newly carry '@${sha} # ${tag}'; $((after - before)) do." >&2 return 1 fi - # Runtime companion to the enumerated set: prove the rewrite reached - # nothing else, whatever the regex did. Extra callers live outside - # LANE_DIR; they are allowlisted, not a hard-fail. - excludes=(":(exclude)$LANE_DIR/") - for file in "${EXTRA_CALLER_FILES[@]}"; do + # Runtime companion to the target set: prove the rewrite reached nothing + # else, whatever the regex did. + excludes=() + for file in "${targets[@]}"; do excludes+=(":(exclude)$file") done if ! git diff --quiet -- . "${excludes[@]}"; then - echo "::error::The re-pin modified files outside the enumerated caller set:" >&2 + echo "::error::The re-pin modified files outside the caller set:" >&2 git diff --name-only -- . "${excludes[@]}" >&2 return 1 fi - if git diff --quiet -- "${rewrite[@]}"; then - echo "::notice::Enumerated callers already pin ${tag}; nothing to propose." + if git diff --quiet -- "${targets[@]}"; then + echo "::notice::The callers already pin ${tag}; nothing to propose." echo 'changed=false' >> "$GITHUB_OUTPUT" return 0 fi @@ -438,6 +709,15 @@ repin::apply() { else note="Same major version as the pin it replaces (\`${old_tags}\` to \`${tag}\`)." fi + # shellcheck disable=SC2016 # markdown code spans, not shell expansions + note+=" + +Moved because ${tag} changed what they run: $(cut -f1 <<< "$moved" | sort -u | sed 's/.*/`&`/' | paste -sd, - | sed 's/,/, /g')." + if [[ -n "$kept_list" ]]; then + note+=" + +Left where they are because ${tag} changes nothing they run: ${kept_list%, }." + fi if [[ -n "$renamed" ]]; then note+=" @@ -472,9 +752,17 @@ main() { repin::resolve "$2" ;; apply) - [[ $# -eq 4 ]] || { usage; exit 2; } + if [[ $# -eq 6 && "$5" == '--root' && -n "$6" ]]; then + set -- "$1" "$2" "$3" "$4" "$6" + elif [[ $# -ne 4 ]]; then + usage + exit 2 + fi require_output_file - repin::apply "$2" "$3" "$4" + REPIN_CACHE="$(mktemp -d)" + # shellcheck disable=SC2064 # expand now: the path must survive the function + trap "rm -rf -- '${REPIN_CACHE}'" EXIT + repin::apply "${@:2}" ;; *) usage diff --git a/components/claude-lanes/repin-callers.test.sh b/components/claude-lanes/repin-callers.test.sh index 58af7bdd..37aa1c64 100755 --- a/components/claude-lanes/repin-callers.test.sh +++ b/components/claude-lanes/repin-callers.test.sh @@ -59,12 +59,21 @@ not_found() { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } # `--paginate`, which the release-list read passes ahead of the path. shift path='' +silent=false +skip_value=false for arg in "$@"; do + if [[ "$skip_value" == true ]]; then + skip_value=false + continue + fi case "$arg" in + -H) skip_value=true ;; + --silent) silent=true ;; -*) continue ;; *) path="$arg"; break ;; esac done +[[ "$*" == *--silent* ]] && silent=true case "$path" in */releases) # The published-release LIST — the subject's source of truth. Answers 200 @@ -96,14 +105,31 @@ case "$path" in printf '%s\n' "${STUB_COMMIT_DATE-2026-01-15T09:30:00Z}" ;; */compare/*) - # apply's ancestry fence: GitHub compare status of release...pin. - # Tests set STUB_COMPARE_STATUS to a documented status (ahead / behind / - # identical / diverged) or STUB_COMPARE_STATUS=fail to force a lookup error. + # apply's one compare per pinned SHA: pin...release, whose status is the + # ancestry fence and whose files decide which pins move. + # STUB_COMPARE_STATUS names the PIN's relation to the release, the way + # compare release...pin reports it (ahead: the pin has commits the release + # lacks; behind: the release contains the pin), and the stub answers in + # the pin...release direction the subject asks in. Unset means contained. + # STUB_COMPARE_STATUS=fail forces a lookup error. STUB_COMPARE_FILES lists, + # space-separated, the files the release changed; unset is one shared + # non-test script, which moves every pin, the behavior before selective + # repins that the older cases assume. STUB_CALL_LOG, when set, records + # each compare route. + [[ -z "${STUB_CALL_LOG:-}" ]] || printf '%s\n' "$path" >> "$STUB_CALL_LOG" if [[ "${STUB_COMPARE_STATUS:-}" == 'fail' ]]; then echo 'gh: Not Found (HTTP 404)' >&2 exit 1 fi - printf '%s\n' "${STUB_COMPARE_STATUS:?stub gh: STUB_COMPARE_STATUS unset}" + case "${STUB_COMPARE_STATUS:-behind}" in + ahead) status=behind ;; + behind) status=ahead ;; + *) status="$STUB_COMPARE_STATUS" ;; + esac + files="${STUB_COMPARE_FILES-.github/scripts/shared.cjs}" + # shellcheck disable=SC2086 # one jq argument per space-separated file + jq -n --arg status "$status" '{status: $status, files: [$ARGS.positional[] | {filename: .}]}' \ + --args $files ;; */contents/*) # apply's renamed-path probe. STUB_PRESENT_PATHS lists, space-separated, @@ -119,6 +145,12 @@ case "$path" in if [[ -n "${STUB_PRESENT_PATHS+x}" && " ${STUB_PRESENT_PATHS} " != *" ${probed} "* ]]; then not_found fi + # A raw read of a source apply follows for commit-relative references. + # STUB_SOURCES_DIR holds upstream files by path; a path it lacks reads as + # an empty file, one that references nothing. + if [[ "$silent" == false && -n "${STUB_SOURCES_DIR:-}" && -f "${STUB_SOURCES_DIR}/${probed}" ]]; then + cat "${STUB_SOURCES_DIR}/${probed}" + fi ;; repos/*/*) # Bare `repos//` — the repository-readable probe. Reached only @@ -800,4 +832,182 @@ rc=0; out="$(GITHUB_OUTPUT="$scratch/out-usage" bash "$script" apply v1.0.0 "$ne assert_nonzero 'apply without the release date is a usage error' "$rc" assert_contains 'apply usage names the date argument' "$out" '' +# ------------------------------------------- apply: only the paths a release changed + +# consumer_repo : a consumer-shaped checkout (claude-code-plugins pins +# these four at one SHA) whose callers live under .github/ alone, plus a +# pin-shaped line outside .github/ that --root must never reach. +consumer_repo() { + local dir="$1" + make_repo "$dir" + git -C "$dir" config core.autocrlf false + mkdir -p "$dir/.github/workflows" "$dir/.github/actions/lane" "$dir/docs" + cat > "$dir/.github/workflows/intake-triage.yml" < "$dir/.github/workflows/pr-review-cli.yml" < "$dir/.github/actions/lane/action.yml" < "$dir/docs/example.yml" + git -C "$dir" add -A + git -C "$dir" -c commit.gpgsign=false -c core.hooksPath= commit -qm 'consumer callers' +} + +# run_apply_root : apply v0.65.13 against through --root, +# from a working directory that is not , as a consumer runs it. +run_apply_root() { + : > "$2" + (cd "$scratch" && PATH="$stub_bin:$PATH" GITHUB_OUTPUT="$2" \ + bash "$script" apply v0.65.13 "$new_sha" 2026-10-24 --root "$1" 2>&1) +} + +moved_line() { printf 'uses: melodic-software/ci-workflows/%s@%s # v0.65.13' "$1" "$new_sha"; } +kept_line() { printf 'uses: melodic-software/ci-workflows/%s@%s # v0.65.12' "$1" "$old_sha"; } + +# The v0.65.12...v0.65.13 compare changed exactly these two files. Only the +# reusable whose file changed moves; the test file is inert, and every other +# pin stays byte-identical. One distinct pinned SHA costs one compare call. +repo="$scratch/repo-consumer-cli" +consumer_repo "$repo" +export STUB_COMPARE_FILES='.github/workflows/pr-review-cli.yml .github/scripts/pr-review-cli.test.cjs' +export STUB_CALL_LOG="$scratch/calls-consumer-cli" +: > "$STUB_CALL_LOG" +out_file="$scratch/out-consumer-cli" +rc=0; out="$(run_apply_root "$repo" "$out_file")" || rc=$? +assert_exit 'selective: a one-workflow release exits 0' 0 "$rc" +assert_contains 'selective: a one-workflow release reports a change' "$(cat "$out_file")" 'changed=true' +assert_contains 'selective: the changed reusable moves' \ + "$(cat "$repo/.github/workflows/pr-review-cli.yml")" "$(moved_line .github/workflows/pr-review-cli.yml)" +assert_eq 'selective: only the changed reusable caller is modified' \ + '.github/workflows/pr-review-cli.yml' "$(git -C "$repo" diff --name-only)" +assert_contains 'selective: the unchanged reusable keeps its pin' \ + "$(cat "$repo/.github/workflows/intake-triage.yml")" "$(kept_line .github/workflows/intake-triage.yml)" +assert_contains 'selective: an unchanged composite keeps its pin' \ + "$(cat "$repo/.github/actions/lane/action.yml")" "$(kept_line .github/actions/run-claude-cli)" +assert_eq 'selective: one distinct pinned SHA costs one compare call' '1' \ + "$(wc -l < "$STUB_CALL_LOG" | tr -d ' ')" +assert_contains 'selective: the compare runs from the pin to the release' "$(cat "$STUB_CALL_LOG")" \ + "compare/${old_sha}...${new_sha}" +assert_contains 'selective: old-sha names the moved pin SHA' "$(cat "$out_file")" "old-sha=${old_sha}" +# shellcheck disable=SC2016 # markdown code spans in the expected note +assert_contains 'selective: the note names what stays' "$(cat "$out_file")" \ + '`.github/workflows/intake-triage.yml`' + +# A shared non-test script no pinned path accounts for could feed any pin, so +# the run fails closed to today's behavior: every pin moves. +repo="$scratch/repo-consumer-shared" +consumer_repo "$repo" +export STUB_COMPARE_FILES='.github/workflows/pr-review-cli.yml .github/scripts/lane-outcome.cjs' +out_file="$scratch/out-consumer-shared" +rc=0; out="$(run_apply_root "$repo" "$out_file")" || rc=$? +assert_exit 'selective: a shared-script release exits 0' 0 "$rc" +assert_eq 'selective: a shared-script change moves all four pins' '4' \ + "$(grep -rhoF -- "@${new_sha} # v0.65.13" "$repo/.github" | wc -l | tr -d ' ')" +assert_not_contains 'selective: a shared-script change leaves no old pin under .github' \ + "$(cat "$repo"/.github/workflows/*.yml "$repo/.github/actions/lane/action.yml")" "$old_sha" +assert_contains '--root never touches a pin outside .github' \ + "$(cat "$repo/docs/example.yml")" "@${old_sha} # v0.65.12" + +# A composite counts as changed when anything under its directory changed, +# a test file included; with no reusable reaching it, only it moves. +repo="$scratch/repo-consumer-composite" +consumer_repo "$repo" +export STUB_COMPARE_FILES='.github/actions/run-claude-cli/index.test.mjs' +out_file="$scratch/out-consumer-composite" +rc=0; out="$(run_apply_root "$repo" "$out_file")" || rc=$? +assert_exit 'selective: a composite-only release exits 0' 0 "$rc" +assert_contains 'selective: the changed composite moves' \ + "$(cat "$repo/.github/actions/lane/action.yml")" "$(moved_line .github/actions/run-claude-cli)" +assert_contains 'selective: its sibling composite on the same file stays' \ + "$(cat "$repo/.github/actions/lane/action.yml")" "$(kept_line .github/actions/report-lane-outcome)" +assert_eq 'selective: a composite change modifies only its caller file' \ + '.github/actions/lane/action.yml' "$(git -C "$repo" diff --name-only)" + +# A reusable runs `$/` composites and `./` nested workflows from its own +# commit, so a change to one it reaches changes what the reusable runs. +repo="$scratch/repo-consumer-reach" +consumer_repo "$repo" +sources="$scratch/sources-reach" +mkdir -p "$sources/.github/workflows" "$sources/.github/actions/run-claude-cli" +printf 'jobs:\n t:\n uses: ./.github/workflows/triage-core.yml\n' \ + > "$sources/.github/workflows/intake-triage.yml" +printf 'jobs:\n c:\n steps:\n - uses: "$/.github/actions/run-claude-cli"\n' \ + > "$sources/.github/workflows/triage-core.yml" +export STUB_SOURCES_DIR="$sources" STUB_COMPARE_FILES='.github/actions/run-claude-cli/action.yml' +out_file="$scratch/out-consumer-reach" +rc=0; out="$(run_apply_root "$repo" "$out_file")" || rc=$? +assert_exit 'selective: a reached composite change exits 0' 0 "$rc" +assert_contains 'selective: a reusable reaching the composite through a nested workflow moves' \ + "$(cat "$repo/.github/workflows/intake-triage.yml")" "$(moved_line .github/workflows/intake-triage.yml)" +assert_contains 'selective: a reusable that reaches nothing changed stays' \ + "$(cat "$repo/.github/workflows/pr-review-cli.yml")" "$(kept_line .github/workflows/pr-review-cli.yml)" +unset STUB_SOURCES_DIR + +# Tests, READMEs and docs/ change nothing a pin runs: no pin moves, nothing +# is proposed, and the tree stays clean. +repo="$scratch/repo-consumer-inert" +consumer_repo "$repo" +export STUB_COMPARE_FILES='.github/scripts/pr-review-cli.test.cjs README.md docs/lanes.md' +out_file="$scratch/out-consumer-inert" +rc=0; out="$(run_apply_root "$repo" "$out_file")" || rc=$? +assert_exit 'selective: an inert-only release exits 0' 0 "$rc" +assert_contains 'selective: an inert-only release reports changed=false' "$(cat "$out_file")" 'changed=false' +assert_silent 'selective: an inert-only release leaves the tree clean' "$(git -C "$repo" status --porcelain)" + +# Paired with the case above: the same inert files at the compare's 300-file +# cap may hide a real change, so every pin moves. +repo="$scratch/repo-consumer-cap" +consumer_repo "$repo" +STUB_COMPARE_FILES="$(for i in $(seq 1 300); do printf 'docs/page-%s.md ' "$i"; done)" +export STUB_COMPARE_FILES +out_file="$scratch/out-consumer-cap" +rc=0; out="$(run_apply_root "$repo" "$out_file")" || rc=$? +assert_exit 'selective: a capped file list exits 0' 0 "$rc" +assert_eq 'selective: a capped file list moves all four pins' '4' \ + "$(grep -rhoF -- "@${new_sha} # v0.65.13" "$repo/.github" | wc -l | tr -d ' ')" + +# Two distinct pinned SHAs cost exactly two compare calls, however many +# pins share them. +repo="$scratch/repo-consumer-two-shas" +consumer_repo "$repo" +sed -i "s/${old_sha} # v0.65.12/${other_sha} # v0.65.11/" "$repo/.github/workflows/intake-triage.yml" +git -C "$repo" -c commit.gpgsign=false -c core.hooksPath= commit -qam 'second pinned SHA' +export STUB_COMPARE_FILES='.github/workflows/pr-review-cli.yml' +: > "$STUB_CALL_LOG" +rc=0; out="$(run_apply_root "$repo" "$scratch/out-consumer-two-shas")" || rc=$? +assert_exit 'selective: two pinned SHAs exit 0' 0 "$rc" +assert_eq 'selective: two distinct pinned SHAs cost two compare calls' '2' \ + "$(sort -u "$STUB_CALL_LOG" | wc -l | tr -d ' ')" +assert_eq 'selective: no compare repeats' "$(wc -l < "$STUB_CALL_LOG" | tr -d ' ')" \ + "$(sort -u "$STUB_CALL_LOG" | wc -l | tr -d ' ')" +unset STUB_CALL_LOG + +# The standards lane's own invocation (no --root) moves only the lane +# caller whose reusable changed. +repo="$scratch/repo-lane-selective" +lane_repo "$repo" "$old_sha" 'v0.9.1' +export STUB_COMPARE_FILES='.github/workflows/claude-review.yml' +out_file="$scratch/out-lane-selective" +rc=0; out="$(run_apply "$repo" "$out_file" 'v0.9.2' "$new_sha")" || rc=$? +assert_exit 'selective: the enumerated lane set exits 0' 0 "$rc" +assert_eq 'selective: only the changed lane caller is modified' \ + 'components/claude-lanes/claude-review.yml' "$(git -C "$repo" diff --name-only)" +unset STUB_COMPARE_FILES + +# --root needs a directory. +rc=0; out="$(GITHUB_OUTPUT="$scratch/out-usage" bash "$script" apply v1.0.0 "$new_sha" 2026-10-24 --root 2>&1)" || rc=$? +assert_nonzero 'apply with --root and no directory is a usage error' "$rc" + [[ $FAILED -eq 0 ]] || exit 1 diff --git a/components/claude-lanes/repin-policy-lockstep.mjs b/components/claude-lanes/repin-policy-lockstep.mjs index 4dd1ddf2..b3b38c99 100755 --- a/components/claude-lanes/repin-policy-lockstep.mjs +++ b/components/claude-lanes/repin-policy-lockstep.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node /** - * Runner-policy lockstep for claude-lanes re-pins. When every pinned - * reusable workflow's security surface is unchanged between the old and new - * ci-workflows SHAs, copy-forward its approvedReusableWorkflowContracts - * entry and refresh the repo-local caller pin. Otherwise write nothing and - * emit the human checklist. schemaVersion 4 has no selector key; this + * Runner-policy lockstep for claude-lanes re-pins. When every reusable + * workflow pin that repin-callers.sh apply moved has an unchanged security + * surface between its old and new ci-workflows SHAs, copy-forward its + * approvedReusableWorkflowContracts entry. A pin apply left in place keeps + * the contract it already has and needs no copy. Otherwise write nothing and + * emit the human checklist. apply is the only caller rewriter. schemaVersion 4 has no selector key; this * script does not read or write one. * * Invoked from .github/workflows/maintenance-repin-ci-workflows.yml after repin-callers.sh @@ -175,7 +176,7 @@ export function manualPolicyNote(reasons, tag, unchangedPaths = []) { export function appliedPolicyNote(tag) { return ( "Runner-policy lockstep applied automatically: every pinned reusable workflow's security surface is unchanged, " + - "so each matching `approvedReusableWorkflowContracts` entry in `components/runner-policy/policy.json` was copy-forwarded and the repo-local caller pins were rewritten. " + + "so each matching `approvedReusableWorkflowContracts` entry in `components/runner-policy/policy.json` was copy-forwarded. " + "**Operator:** before merging, add the rollout record in `components/runner-policy/README.md` and " + `\`${constantNameForTag(tag)}\` plus a verbatim copy-forward assertion in ` + "`components/runner-policy/runner-policy.test.mjs` " + @@ -183,17 +184,12 @@ export function appliedPolicyNote(tag) { ); } -function rewritePinLine(line, newSha, tag) { - return line.replace( - /(@[0-9a-fA-F]{40})(?:\s+# v[0-9]+\.[0-9]+\.[0-9]+)?/u, - `@${newSha} # ${tag}`, - ); -} - /** * Pairs each pin in a caller's committed text with the same line after - * repin-callers.sh apply rewrote it in place. The rewritten line names the - * path the new SHA's contract is keyed under, renamed or not. + * repin-callers.sh apply rewrote it in place, and returns the pairs apply + * moved. The rewritten line names the path the new SHA's contract is keyed + * under, renamed or not. A pin apply left on its SHA (the release changed + * nothing it runs) is not returned: it keeps its existing contract. */ export function pairCallerPins(headText, worktreeText) { const after = worktreeText.split("\n"); @@ -205,6 +201,7 @@ export function pairCallerPins(headText, worktreeText) { if (!rewritten) { throw new Error(`line ${index + 1} no longer carries a ci-workflows pin after apply`); } + if (rewritten[2].toLowerCase() === before[2].toLowerCase()) return; pairs.push({ oldWorkflowPath: before[1], oldSha: before[2].toLowerCase(), @@ -238,35 +235,6 @@ async function readCallerPins(callerFiles) { return found; } -export async function rewriteCallerFiles(newSha, tag, root = ROOT) { - const uniqueFiles = [...new Set(REPIN_TARGETS.flatMap((target) => target.callerFiles))]; - let anyChanged = false; - for (const rel of uniqueFiles) { - const abs = path.join(root, rel); - let text; - try { - text = await readFile(abs, "utf8"); - } catch (error) { - if (error && error.code === "ENOENT") continue; - throw error; - } - const lines = text.split("\n"); - let changed = false; - const next = lines.map((line) => { - if (!line.includes("melodic-software/ci-workflows/")) return line; - const updated = rewritePinLine(line, newSha, tag); - if (updated !== line) changed = true; - return updated; - }); - if (!changed) continue; - // split preserved any trailing newline as an empty element, so trim - // trailing newlines before re-adding exactly one final newline. - await writeFile(abs, `${next.join("\n").replace(/\n+$/u, "")}\n`); - anyChanged = true; - } - return anyChanged; -} - export function renamedPathReason(pin) { return ( `${pin.oldWorkflowPath} is renamed to ${pin.newWorkflowPath} (from ${pin.oldSha.slice(0, 7)}); ` + @@ -401,7 +369,7 @@ async function main() { }); }); - await appendOutput(outputFile, await settleLockstep({ reasons, copyForwards }, newSha, tag)); + await appendOutput(outputFile, await settleLockstep({ reasons, copyForwards }, tag)); } /** @@ -409,7 +377,7 @@ async function main() { * the callers stay as they are and only the human checklist is returned. * Returns the GITHUB_OUTPUT pairs. */ -export async function settleLockstep({ reasons, copyForwards }, newSha, tag, root = ROOT) { +export async function settleLockstep({ reasons, copyForwards }, tag, root = ROOT) { if (reasons.length > 0) { const unchangedPaths = [...new Set(copyForwards.map((item) => item.workflowPath))]; emitNotice(`::warning::${reasons.join("; ")} — policy lockstep deferred to a human.`); @@ -420,8 +388,7 @@ export async function settleLockstep({ reasons, copyForwards }, newSha, tag, roo } const policyChanged = await updatePolicyJson(copyForwards, root); - const callerChanged = await rewriteCallerFiles(newSha, tag, root); - emitNotice(`Policy lockstep applied (policy=${policyChanged}, caller=${callerChanged}).`); + emitNotice(`Policy lockstep applied (policy=${policyChanged}).`); return [ ["lockstep", "applied"], ["policy-note", appliedPolicyNote(tag)], diff --git a/components/claude-lanes/repin-policy-lockstep.test.mjs b/components/claude-lanes/repin-policy-lockstep.test.mjs index 2eb19f49..cd31f416 100644 --- a/components/claude-lanes/repin-policy-lockstep.test.mjs +++ b/components/claude-lanes/repin-policy-lockstep.test.mjs @@ -13,7 +13,6 @@ import { manualPolicyNote, pairCallerPins, planLockstep, - rewriteCallerFiles, settleLockstep, } from "./repin-policy-lockstep.mjs"; @@ -48,27 +47,6 @@ test("parseLockstepArgs rejects a missing argument as usage", () => { assert.deepEqual(parseLockstepArgs([oldA, next]), { error: "usage" }); }); -test("rewriteCallerFiles keeps exactly one trailing newline on a rewritten caller", async () => { - const root = await mkdtemp(path.join(tmpdir(), "repin-lockstep-")); - temporaryRoots.push(root); - const rel = "components/claude-lanes/claude-review.yml"; - const abs = path.join(root, rel); - await mkdir(path.dirname(abs), { recursive: true }); - const source = - "jobs:\n" + - " review:\n" + - ` uses: melodic-software/ci-workflows/.github/workflows/claude-review.yml@${oldA} # v0.16.0\n`; - await writeFile(abs, source); - - const changed = await rewriteCallerFiles(next, "v0.17.0", root); - - assert.equal(changed, true); - const rewritten = await readFile(abs, "utf8"); - assert.ok(rewritten.includes(`@${next} # v0.17.0`), "pin line carries the new SHA and tag"); - assert.ok(rewritten.endsWith("\n"), "rewritten caller ends with a final newline"); - assert.ok(!rewritten.endsWith("\n\n"), "rewritten caller does not gain a duplicate newline"); -}); - // The selector target is gone; the assertion guards against putting that pin back. test("the shipped lane callers pin no selector, so the repin lane copies no selector contract", async () => { for (const name of ["claude-review.yml", "claude-security-review.yml"]) { @@ -80,27 +58,6 @@ test("the shipped lane callers pin no selector, so the repin lane copies no sele } }); -test("rewriteCallerFiles repins a selector-less caller", async () => { - const root = await mkdtemp(path.join(tmpdir(), "repin-lockstep-")); - temporaryRoots.push(root); - const rel = "components/claude-lanes/claude-security-review.yml"; - const abs = path.join(root, rel); - await mkdir(path.dirname(abs), { recursive: true }); - await writeFile( - abs, - "jobs:\n" + - " security-review:\n" + - ` uses: melodic-software/ci-workflows/.github/workflows/claude-security-review.yml@${oldA} # v0.16.0\n` + - " with:\n" + - " runner: melodic-review-ubuntu-24.04-x64\n", - ); - - assert.equal(await rewriteCallerFiles(next, "v0.17.0", root), true); - const rewritten = await readFile(abs, "utf8"); - assert.ok(rewritten.includes(`@${next} # v0.17.0`)); - assert.ok(rewritten.includes("runner: melodic-review-ubuntu-24.04-x64")); -}); - const CURRENT_REVIEW_SHA = "91d06c94d733e5daa507e0afaa06a140bb46d337"; const FRESH_SHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const REVIEW_WORKFLOW = "melodic-software/ci-workflows/.github/workflows/claude-review.yml"; @@ -231,6 +188,16 @@ test("pairCallerPins keeps the path for a release cut before the rename", () => ]); }); +test("pairCallerPins returns only the pins apply moved, never one it left on its SHA", () => { + const syncLine = ` uses: ${SYNC_WORKFLOW}@${oldB} # v0.33.0\n`; + const before = `${callerText(REVIEW_WORKFLOW, oldA, "v0.33.0")}${syncLine}`; + const after = `${callerText(REVIEW_WORKFLOW, next, "v0.34.0")}${syncLine}`; + assert.deepEqual(pairCallerPins(before, after), [ + { oldWorkflowPath: REVIEW_WORKFLOW, oldSha: oldA, newWorkflowPath: REVIEW_WORKFLOW }, + ]); + assert.deepEqual(pairCallerPins(before, before), [], "a caller apply left alone plans nothing"); +}); + test("pairCallerPins refuses a pin line that apply did not leave as a pin", () => { assert.throws( () => pairCallerPins(callerText(REVIEW_WORKFLOW, oldA, "v0.33.0"), "jobs:\n review:\n"), @@ -333,7 +300,6 @@ test("settleLockstep writes no policy.json when any pin was declined", async () const { file, root, bytes } = await policyRoot(); const output = await settleLockstep( { reasons: ["x"], copyForwards: [reviewCopyForward] }, - FRESH_SHA, "v0.34.0", root, ); @@ -345,7 +311,6 @@ test("settleLockstep writes the copy-forward when nothing was declined", async ( const { file, root } = await policyRoot(); const output = await settleLockstep( { reasons: [], copyForwards: [reviewCopyForward] }, - FRESH_SHA, "v0.34.0", root, ); @@ -354,6 +319,16 @@ test("settleLockstep writes the copy-forward when nothing was declined", async ( assert.deepEqual(output[0], ["lockstep", "applied"]); }); +test("settleLockstep leaves a pin apply did not move on its SHA", async () => { + const { root } = await policyRoot(); + const rel = ".github/workflows/maintenance-sync-standards.yml"; + const caller = `jobs:\n sync:\n uses: ${SYNC_WORKFLOW}@${oldB} # v0.33.0\n`; + await mkdir(path.join(root, path.dirname(rel)), { recursive: true }); + await writeFile(path.join(root, rel), caller); + await settleLockstep({ reasons: [], copyForwards: [reviewCopyForward] }, "v0.34.0", root); + assert.equal(await readFile(path.join(root, rel), "utf8"), caller); +}); + test("a selector copy-forward throws and adds no selector key", async () => { const onDisk = await policyWithHistory(); const policy = structuredClone(onDisk); diff --git a/components/managed-files-guard/README.md b/components/managed-files-guard/README.md index 1982016d..0ff65a16 100644 --- a/components/managed-files-guard/README.md +++ b/components/managed-files-guard/README.md @@ -170,8 +170,8 @@ the guard executes); do not pin it in this hop. The action pin rides the existing `maintenance-repin-ci-workflows` cascade (`.github/workflows/maintenance-repin-ci-workflows.yml`, daily), which resolves the -newest full-SemVer ci-workflows release and rewrites every enumerated caller -to its SHA with a `# vX.Y.Z` comment. Both files are enumerated in +newest full-SemVer ci-workflows release and rewrites each enumerated caller +pin whose path that release changed to its SHA with a `# vX.Y.Z` comment. Both files are enumerated in `components/claude-lanes/repin-callers.sh`'s `EXTRA_CALLER_FILES`, and the workflow's `add-paths` covers the whole `components/managed-files-guard` directory, so the same reviewed pull request that re-pins the @@ -181,9 +181,10 @@ Two properties of that ride are deliberate: - **A pin ahead of the release is left alone.** `apply` asks GitHub's compare API whether the pinned SHA is an ancestor of the release SHA - ([compare two commits](https://docs.github.com/en/rest/commits/commits#compare-two-commits)). - Status `ahead` or `diverged` leaves the file untouched; `behind` or - `identical` lets the rewrite proceed. Day-level pin-comment dates are not + ([compare two commits](https://docs.github.com/en/rest/commits/commits#compare-two-commits)), + comparing the pin to the release. Status `behind` or `diverged` leaves the + pin untouched; `ahead` lets the rewrite proceed when the release changed + the composite. Day-level pin-comment dates are not consulted: a pin landed later on the same UTC day as the release, or on another line of history, cannot be proven contained by a `YYYY-MM-DD` string. Without that fence the very next scheduled run would have proposed @@ -191,8 +192,8 @@ Two properties of that ride are deliberate: (2026-08-30) back to v0.17.2 (2026-08-21), behind the fail-open fix. The pin advances to the tag form on the first release that contains it, and the file now sits on that tag form, so the - fence has released it and it re-pins with the lane callers on every later - release. A failed compare is a hard failure, not a rewrite. + fence has released it and it re-pins on every later release that changes + the composite. A failed compare is a hard failure, not a rewrite. - **Not in `repin-policy-lockstep.mjs`'s `REPIN_TARGETS`.** Every `kind` that table expresses and that still has a contract to copy (`lane`, `reusable`) copies a `components/runner-policy/policy.json` contract forward from the old From ca1b758816ff56630f92c3abef16d665f6e64c88 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:57:30 -0400 Subject: [PATCH 2/2] fix(claude-lanes): converge lane pins per path and relabel fallback pins on the release The runner-policy convergence test now requires every caller of one upstream path to share a revision, since a selective repin can leave the code-review and security-review callers on different ones. apply gives a fallback-form pin already on the release SHA the tag form, as the full rewrite did, and hands lockstep the release SHA when a run only relabels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DpzEJAE4EMndcWQXiKSZYn --- components/claude-lanes/repin-callers.sh | 21 +++++++--- components/claude-lanes/repin-callers.test.sh | 19 +++++++++ .../runner-policy/runner-policy.test.mjs | 39 ++++++++++--------- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/components/claude-lanes/repin-callers.sh b/components/claude-lanes/repin-callers.sh index 3f6305aa..e29c20f1 100755 --- a/components/claude-lanes/repin-callers.sh +++ b/components/claude-lanes/repin-callers.sh @@ -616,7 +616,14 @@ repin::apply() { decided+="${path}"$'\t'"${pin_sha}"$'\t'"${decision}"$'\n' done < <(cut -f2,3 <<< "$refs" | sort -u) - moved="$(awk -F'\t' '$3 == "move" { print $1 "\t" $2 }' <<< "$decided")" + # A pin already on the release still takes the tag form when its comment + # is not `# `, as a fallback-form pin to the release commit is. + moved="$( + { + awk -F'\t' '$3 == "move" { print $1 "\t" $2 }' <<< "$decided" + awk -F'\t' -v s="$sha" -v t="$tag" '$3 == s && $4 != t { print $2 "\t" $3 }' <<< "$refs" + } | awk '$0 != "" && !seen[$0]++' + )" ahead=() while IFS=$'\t' read -r file pin_sha; do @@ -645,12 +652,16 @@ repin::apply() { return 0 fi - # Count every moved occurrence, then require exactly that many new - # `@ # ` pins after the rewrite. - expected="$(awk -F'\t' 'NR == FNR { if ($0 != "") m[$0] = 1; next } (($2 "\t" $3) in m)' \ + # Count every moved occurrence not already `@ # `, then require + # exactly that many new such pins after the rewrite. + expected="$(awk -F'\t' -v s="$sha" -v t="$tag" 'NR == FNR { if ($0 != "") m[$0] = 1; next } + (($2 "\t" $3) in m) && !($3 == s && $4 == t)' \ <(printf '%s\n' "$moved") <(printf '%s\n' "$refs") | wc -l)" before="$(grep -hoE "uses: ${UPSTREAM_REPO}/[^@[:space:]]+@${sha}[[:space:]]+# ${tag}\$" "${targets[@]}" | wc -l || true)" - old_sha="$(cut -f2 <<< "$moved" | sort -u | paste -sd, -)" + # A run that only relabels pins already on the release hands lockstep the + # release SHA itself, which it treats as a no-op. + old_sha="$(cut -f2 <<< "$moved" | { grep -vxF -- "$sha" || true; } | sort -u | paste -sd, -)" + old_sha="${old_sha:-$sha}" old_tags="$(awk -F'\t' 'NR == FNR { if ($0 != "") m[$0] = 1; next } (($2 "\t" $3) in m) && $4 ~ /^v/ { print $4 }' \ <(printf '%s\n' "$moved") <(printf '%s\n' "$refs") | sort -u | paste -sd, - || true)" diff --git a/components/claude-lanes/repin-callers.test.sh b/components/claude-lanes/repin-callers.test.sh index 37aa1c64..b2223bc8 100755 --- a/components/claude-lanes/repin-callers.test.sh +++ b/components/claude-lanes/repin-callers.test.sh @@ -1006,6 +1006,25 @@ assert_eq 'selective: only the changed lane caller is modified' \ 'components/claude-lanes/claude-review.yml' "$(git -C "$repo" diff --name-only)" unset STUB_COMPARE_FILES +# A fallback-form pin to the very commit the release tags is already on the +# release SHA; it still takes the tag form, and nothing else moves. +repo="$scratch/repo-lane-relabel" +lane_repo "$repo" "$old_sha" 'v0.9.1' +sed -i -E "s|claude-review.yml@${old_sha} # v0\.9\.1|claude-review.yml@${new_sha} # a1b2c3d 2026-08-21|" \ + "$repo/components/claude-lanes/claude-review.yml" +git -C "$repo" -c commit.gpgsign=false -c core.hooksPath= commit -qam 'fallback pin to the release commit' +export STUB_COMPARE_FILES='' +out_file="$scratch/out-lane-relabel" +rc=0; out="$(run_apply "$repo" "$out_file" 'v0.9.2' "$new_sha")" || rc=$? +assert_exit 'relabel: a fallback pin on the release SHA exits 0' 0 "$rc" +assert_contains 'relabel: the fallback pin on the release SHA takes the tag form' \ + "$(cat "$repo/components/claude-lanes/claude-review.yml")" "claude-review.yml@${new_sha} # v0.9.2" +assert_eq 'relabel: only that caller is modified' \ + 'components/claude-lanes/claude-review.yml' "$(git -C "$repo" diff --name-only)" +assert_eq 'relabel: a relabel-only run hands lockstep the release SHA, its no-op' \ + "old-sha=${new_sha}" "$(grep '^old-sha=' "$out_file")" +unset STUB_COMPARE_FILES + # --root needs a directory. rc=0; out="$(GITHUB_OUTPUT="$scratch/out-usage" bash "$script" apply v1.0.0 "$new_sha" 2026-10-24 --root 2>&1)" || rc=$? assert_nonzero 'apply with --root and no directory is a usage error' "$rc" diff --git a/components/runner-policy/runner-policy.test.mjs b/components/runner-policy/runner-policy.test.mjs index c9153b38..f17a027c 100644 --- a/components/runner-policy/runner-policy.test.mjs +++ b/components/runner-policy/runner-policy.test.mjs @@ -10276,30 +10276,33 @@ test("the intake-triage and pr-review-cli contracts at the v0.65.13 tag copy the }); // Convergence as a property, never as a hardcoded SHA. The daily -// claude-lanes-repin lane rewrites these components on every ci-workflows -// release, and a test naming one revision would go red by construction on -// each of its pull requests while nothing was actually wrong. Assert instead -// that the lane callers agree with each other on one revision, which is what -// convergence means; whether that revision has a reviewed contract is already -// proved by the registration tests above that audit these same bytes against -// the real policy.json. -test("the claude lane caller components all pin one ci-workflows revision", async () => { - const pins = new Set(); +// claude-lanes-repin lane rewrites these components on ci-workflows releases, +// and a test naming one revision would go red by construction on each of its +// pull requests while nothing was actually wrong. The lane moves a pin only +// when the release changed its path, so the code-review and security-review +// callers may sit on different revisions; what convergence means is that +// every caller of one upstream path agrees on its revision. Whether that +// revision has a reviewed contract is already proved by the registration tests +// above that audit these same bytes against the real policy.json. +test("the claude lane caller components pin each ci-workflows path at one revision", async () => { + const pinsByPath = new Map(); for (const { source, body } of [ ...(await claudeLaneCallerComponents()), ...(await claudeLaneCallerComponents(HOSTED_LANE_DIR)), ]) { - const found = [ - ...body.matchAll(/melodic-software\/ci-workflows\/[^@\s]+@([0-9a-f]{40})/gu), - ].map(([, sha]) => sha); + const found = [...body.matchAll(/melodic-software\/ci-workflows\/([^@\s]+)@([0-9a-f]{40})/gu)]; assert.ok(found.length > 0, `${source} carries no ci-workflows pin`); - for (const sha of found) pins.add(sha); + for (const [, workflowPath, sha] of found) { + pinsByPath.set(workflowPath, (pinsByPath.get(workflowPath) ?? new Set()).add(sha)); + } + } + for (const [workflowPath, shas] of pinsByPath) { + assert.equal( + shas.size, + 1, + `the claude lane caller components disagree on their ${workflowPath} pin: ${[...shas].join(", ")}`, + ); } - assert.equal( - pins.size, - 1, - `the claude lane caller components disagree on their ci-workflows pin: ${[...pins].join(", ")}`, - ); }); // The managed-files-guard caller is the hosted-only counterpart: a fixed