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..e29c20f1 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,157 @@ 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) + + # 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]++' + )" - # 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 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)" + # 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)" + + 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 +720,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 +763,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..b2223bc8 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,201 @@ 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 + +# 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" + [[ $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 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