diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08f5a75..14eb931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -583,6 +583,11 @@ jobs: # in the one job that has the pinned engine installed. - name: Test claude lane caller components run: bash harness/shell/run-tests.sh components/claude-lanes/claude-lanes.test.sh + # Same shape, same reason: the managed-files-guard caller is workflow + # bytes that live outside .github/workflows until a sync materializes + # them, so the consumer entrypoint runs over them here too. + - name: Test the managed-files-guard caller component + run: bash harness/shell/run-tests.sh components/managed-files-guard/managed-files-guard.test.sh # Behavioral coverage for the scheduled re-pin job's logic. It needs no # yq and no actionlint — it lives in this job because this is where the # rest of the claude-lanes coverage already runs, keeping one lane @@ -607,13 +612,14 @@ jobs: # # `.` audits this repo's own workflows only: zizmor discovers workflow # definitions by their `.github/workflows` location, so the workflow BYTES - # under components/claude-lanes/ — which become consumers' - # .github/workflows/ files — are invisible to a repo-root scan. They are - # named explicitly so the lane audits what it ships. + # under components/claude-lanes/ and components/managed-files-guard/ — + # which become consumers' .github/workflows/ files — are invisible to a + # repo-root scan. They are named explicitly so the lane audits what it + # ships. uses: melodic-software/ci-workflows/.github/workflows/zizmor.yml@31a5b76c4a0b663023dc1c944e2bcfc01d6f6c46 # 31a5b76 2026-07-21 with: runner: ubuntu-24.04 - paths: . components/claude-lanes/claude-review.yml components/claude-lanes/claude-security-review.yml + paths: . components/claude-lanes/claude-review.yml components/claude-lanes/claude-security-review.yml components/managed-files-guard/managed-files-guard.yml fail-on-severity: high osv-scanner: @@ -946,12 +952,13 @@ jobs: # escape the scan — the driver's `[[ -f "$file" ]] || continue` guard # already tolerates a glob with no current matches). # - # components/claude-lanes/ holds workflow BYTES that ship to consumers' - # .github/workflows/. Every workflow-shaped lane in this repo discovers - # files by that location, so the caller components are invisible to all - # of them; scanning them here keeps their pins under the same convention - # the files they become are held to. - run: bash components/pin-comment-convention/scan-workflow-files.sh .github/workflows/*.yml .github/workflows/*.yaml components/claude-lanes/*.yml + # components/claude-lanes/ and components/managed-files-guard/ hold + # workflow BYTES that ship to consumers' .github/workflows/. Every + # workflow-shaped lane in this repo discovers files by that location, + # so the caller components are invisible to all of them; scanning them + # here keeps their pins under the same convention the files they + # become are held to. + run: bash components/pin-comment-convention/scan-workflow-files.sh .github/workflows/*.yml .github/workflows/*.yaml components/claude-lanes/*.yml components/managed-files-guard/*.yml - name: Pin-comment-convention contract test run: bash harness/shell/run-tests.sh components/pin-comment-convention/pin-comment-convention.test.sh diff --git a/.github/workflows/claude-lanes-repin.yml b/.github/workflows/claude-lanes-repin.yml index 99033a6..42cb416 100644 --- a/.github/workflows/claude-lanes-repin.yml +++ b/.github/workflows/claude-lanes-repin.yml @@ -97,7 +97,11 @@ jobs: env: TAG: ${{ steps.release.outputs.tag }} SHA: ${{ steps.release.outputs.sha }} - run: bash components/claude-lanes/repin-callers.sh apply "$TAG" "$SHA" + # Still required by apply's argv (malformed dates fail closed). + # Ahead-of-release is decided by GitHub compare ancestry of SHA + # against the fallback pin, not by this date. + DATE: ${{ steps.release.outputs.date }} + run: bash components/claude-lanes/repin-callers.sh apply "$TAG" "$SHA" "$DATE" # repin-policy-lockstep.mjs imports components/runner-policy/runner-policy.mjs, # which imports `ajv` and `yaml` from that component's own package.json. @@ -193,6 +197,7 @@ jobs: base: ${{ steps.default-branch.outputs.name }} add-paths: | components/claude-lanes + components/managed-files-guard components/runner-policy/policy.json components/runner-policy/runner-policy.test.mjs .github/workflows/claude-review.yml @@ -207,7 +212,9 @@ jobs: ## Summary Re-pins every `melodic-software/ci-workflows` reference under - `components/claude-lanes/` from `${{ steps.repin.outputs.old-tags }}` + `components/claude-lanes/`, the `managed-files-guard` caller + component, and this repository's own sync-family callers from + `${{ steps.repin.outputs.old-tags }}` to `${{ steps.release.outputs.tag }}` (`${{ steps.release.outputs.sha }}`), the newest ci-workflows release. Opened by `.github/workflows/claude-lanes-repin.yml`. @@ -218,11 +225,11 @@ jobs: ${{ steps.policy-lockstep.outputs.policy-note }} - Merging this cascades: `sync.yml` re-materializes the lane callers + Merging this cascades: `sync.yml` re-materializes the callers into every target `distribution/sync-manifest.yml` marks - `claude-review-caller` / `claude-security-review-caller` managed - for, as a reviewed pull request per target. This pull request is - never auto-merged. + `claude-review-caller` / `claude-security-review-caller` / + `managed-files-guard-caller` managed for, as a reviewed pull + request per target. This pull request is never auto-merged. ## Test plan diff --git a/components/claude-lanes/repin-callers.sh b/components/claude-lanes/repin-callers.sh index 7c7ba8a..308792d 100755 --- a/components/claude-lanes/repin-callers.sh +++ b/components/claude-lanes/repin-callers.sh @@ -6,8 +6,8 @@ # # Two subcommands, matching the workflow's two steps: # -# repin-callers.sh resolve newest release -> tag + commit SHA -# repin-callers.sh apply rewrite the caller pins +# repin-callers.sh resolve newest release -> tag, SHA, date +# repin-callers.sh apply rewrite the caller pins # # 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 @@ -22,24 +22,36 @@ set -euo pipefail # asserts against them. readonly LANE_DIR='components/claude-lanes' -# Sync-family and repo-local callers live outside LANE_DIR. They may already -# pin a different SHA than the lane components; apply reads each file's own -# pin rather than assuming one fleet-wide old SHA. +# Sync-family and repo-local callers, plus the one other sync-managed caller +# component, live outside LANE_DIR. They may already pin a different SHA than +# the lane components; apply reads each file's own pin rather than assuming +# one fleet-wide old SHA. The managed-files-guard caller pins a composite +# ACTION rather than a reusable workflow: it rides this cascade for the pin +# rewrite alone and has no runner-policy contract for the lockstep half to +# copy forward (components/managed-files-guard/README.md). readonly -a EXTRA_CALLER_FILES=( '.github/workflows/claude-review.yml' '.github/workflows/sync.yml' '.github/workflows/standards-sync-stuck-automerge-alert.yml' + 'components/managed-files-guard/managed-files-guard.yml' ) # 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' usage() { cat >&2 <<'USAGE' usage: - repin-callers.sh resolve resolve the newest release to tag + SHA - repin-callers.sh apply re-pin the lane callers to tag/SHA + 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 Requires GITHUB_OUTPUT to name a writable file; `resolve` also requires gh. USAGE @@ -76,7 +88,7 @@ require_output_file() { # dependence on commit chronology. repin::resolve() { local upstream="$1" - local err releases tag ref object_type object_sha sha + local err releases tag ref object_type object_sha sha date err="$(mktemp)" # shellcheck disable=SC2064 # expand $err now: the trap must survive it going out of scope @@ -144,23 +156,97 @@ repin::resolve() { return 1 fi - echo "Resolved ${tag} (${object_type} object) to ${sha}." + # The release commit's own date, in the same YYYY-MM-DD shape a fallback-form + # pin comment records for the commit IT names, so apply can compare the two. + # Committer date, not author date: it is when the commit entered history, + # which is the ordering a "does this release contain that commit" question + # approximates. + date="$(gh api "repos/${upstream}/commits/${sha}" --jq '.commit.committer.date')" + date="${date:0:10}" + if [[ ! "$date" =~ $DATE_RE ]]; then + echo "::error::Commit ${sha} reported committer date '${date}', which is not YYYY-MM-DD." >&2 + return 1 + fi + + echo "Resolved ${tag} (${object_type} object) to ${sha} (${date})." { echo 'resolved=true' echo "tag=${tag}" echo "sha=${sha}" + echo "date=${date}" } >> "$GITHUB_OUTPUT" } -# repin::apply +# repin::compare_status +# +# 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 + fi + printf '%s\n' "$status" +} + +# repin::ahead_of +# +# 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" + return 0 + ;; + behind | identical) ;; + *) + echo "::error::compare ${release_sha}...${pin_sha} returned '${status}', not a documented status." >&2 + return 1 + ;; + esac + done < <(grep -hE "$FALLBACK_PIN_RE" "$file" || true) +} + +# repin::apply # # 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. +# 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" - local root expected rewritten old_sha old_tags new_major old_major old_tag note delim - local -a targets excludes + 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 -a targets rewrite ahead excludes + + 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" @@ -193,21 +279,41 @@ repin::apply() { fi done + # 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." + ahead+=("$file") + else + rewrite+=("$file") + 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" "${targets[@]}" | wc -l || true)" - # Unique old SHAs across every enumerated file — do not collapse to the + 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 '@[0-9a-fA-F]{40}' "${targets[@]}" \ - | tr '[:upper:]' '[:lower:]' | tr -d '@' | sort -u | paste -sd, - || true)" - old_tags="$(grep -hoE "${PIN_RE}[[:space:]]+# v[0-9]+\.[0-9]+\.[0-9]+" "${targets[@]}" \ + 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)" sed -i -E "s|(uses: melodic-software/ci-workflows/[^@[:space:]]+)@[0-9a-fA-F]{40}.*|\1@${sha} # ${tag}|" \ - "${targets[@]}" + "${rewrite[@]}" - rewritten="$(grep -hoE "${PIN_RE}[[:space:]]+# ${tag}\$" "${targets[@]}" | wc -l || true)" + 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 return 1 @@ -226,7 +332,7 @@ repin::apply() { return 1 fi - if git diff --quiet -- "${targets[@]}"; then + if git diff --quiet -- "${rewrite[@]}"; then echo "::notice::Enumerated callers already pin ${tag}; nothing to propose." echo 'changed=false' >> "$GITHUB_OUTPUT" return 0 @@ -247,6 +353,12 @@ repin::apply() { else note="Same major version as the pin it replaces (\`${old_tags}\` to \`${tag}\`)." fi + if [[ "${#ahead[@]}" -gt 0 ]]; then + ahead_list="$(printf "\`%s\`, " "${ahead[@]}")" + note+=" + +Left untouched because their pinned commit is not an ancestor of ${tag} (${sha}): ${ahead_list%, }. Those pins advance on the first release that contains them." + fi # A random heredoc delimiter, per GitHub's own multi-line output guidance: # a fixed one could in principle be reproduced inside the value and end the @@ -270,9 +382,9 @@ main() { repin::resolve "$2" ;; apply) - [[ $# -eq 3 ]] || { usage; exit 2; } + [[ $# -eq 4 ]] || { usage; exit 2; } require_output_file - repin::apply "$2" "$3" + repin::apply "$2" "$3" "$4" ;; *) usage diff --git a/components/claude-lanes/repin-callers.test.sh b/components/claude-lanes/repin-callers.test.sh index d8bb3bf..aa1cff2 100755 --- a/components/claude-lanes/repin-callers.test.sh +++ b/components/claude-lanes/repin-callers.test.sh @@ -88,6 +88,22 @@ case "$path" in */git/tags/*) printf '%s\n' "${STUB_TAG_OBJECT_SHA:?stub gh: STUB_TAG_OBJECT_SHA unset}" ;; + */commits/*) + # The resolved commit's metadata; the subject reads its committer date. + # Defaults so the resolve cases that predate the date output need no + # extra setup; the date cases set it explicitly. + 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. + 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}" + ;; repos/*/*) # Bare `repos//` — the repository-readable probe. Reached only # after the more specific patterns above have not matched. @@ -126,6 +142,26 @@ assert_contains 'resolve: lightweight tag reports the commit object' "$out" 'com assert_contains 'resolve: lightweight tag emits resolved=true' "$(cat "$out_file")" 'resolved=true' assert_contains 'resolve: lightweight tag emits the tag' "$(cat "$out_file")" 'tag=v0.9.1' assert_contains 'resolve: lightweight tag emits the commit SHA' "$(cat "$out_file")" "sha=$old_sha" +assert_contains 'resolve: lightweight tag emits the commit date as YYYY-MM-DD' "$(cat "$out_file")" 'date=2026-01-15' + +# The date is the release COMMIT's committer date, truncated to the day, in +# the shape a fallback-form pin comment records — apply compares the two. +out_file="$scratch/out-date" +export STUB_COMMIT_DATE='2026-08-21T08:17:24Z' +rc=0; out="$(run_resolve "$out_file")" || rc=$? +assert_exit 'resolve: commit date exits 0' 0 "$rc" +assert_contains 'resolve: commit date is truncated to the day' "$(cat "$out_file")" 'date=2026-08-21' +assert_not_contains 'resolve: commit date carries no time component' "$(cat "$out_file")" 'T08:17' + +# An unparsable date is upstream data the ahead-of-release comparison cannot +# consume; it must fail loudly rather than emit a date apply then misreads. +out_file="$scratch/out-bad-date" +export STUB_COMMIT_DATE='not-a-date' +rc=0; out="$(run_resolve "$out_file")" || rc=$? +assert_nonzero 'resolve: an unparsable commit date fails' "$rc" +assert_contains 'resolve: an unparsable commit date raises an error' "$out" '::error::' +assert_not_contains 'resolve: an unparsable commit date never emits resolved=true' "$(cat "$out_file")" 'resolved=true' +unset STUB_COMMIT_DATE # Annotated tag — the ref points at a tag object that must be dereferenced # again. The emitted SHA must be the tag object's target, never the tag object. @@ -286,11 +322,17 @@ YAML git -C "$dir" -c commit.gpgsign=false -c core.hooksPath= commit -qm 'lane callers' } -# run_apply +# run_apply [release-date] +# +# The release date only matters to fallback-form pins; the tag-form fixtures +# below take a fixed default so their cases stay about the rewrite itself. run_apply() { - local dir="$1" outfile="$2" tag="$3" sha="$4" + local dir="$1" outfile="$2" tag="$3" sha="$4" release_date="${5:-2026-08-21}" : > "$outfile" - (cd "$dir" && GITHUB_OUTPUT="$outfile" bash "$script" apply "$tag" "$sha" 2>&1) + # Stub stays on PATH so a fallback-form pin's compare lookup never hits + # the network; tag-form apply cases do not call gh and ignore it. + (cd "$dir" && PATH="$stub_bin:$PATH" GITHUB_OUTPUT="$outfile" \ + bash "$script" apply "$tag" "$sha" "$release_date" 2>&1) } # ------------------------------------------- apply: rewrite scope + major jump @@ -359,29 +401,190 @@ jobs: review: uses: melodic-software/ci-workflows/.github/workflows/claude-review.yml@${old_sha} # v0.9.1 YAML +# The guard caller pins a composite ACTION at a step, in the convention's +# fallback comment form, dated before the release under test — so this +# release may advance it, and the rewrite must land the tag form. +guard_sha='c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6' +guard_caller='components/managed-files-guard/managed-files-guard.yml' +mkdir -p "$repo/components/managed-files-guard" +cat > "$repo/$guard_caller" < "$repo/.github/workflows/$extra" < "$repo/$guard_caller" < "$repo/.github/workflows/$extra" +done +printf 'jobs:\n g:\n steps:\n - uses: melodic-software/ci-workflows/.github/actions/managed-files-guard@%s # c3d4e5f 2026-08-21\n' \ + "$guard_sha" > "$repo/$guard_caller" +git -C "$repo" add -A +git -C "$repo" -c commit.gpgsign=false -c core.hooksPath= commit -qm 'guard pinned the day of the release, not contained' +export STUB_COMPARE_STATUS=ahead +out_file="$scratch/out-apply-same-day-ahead" +rc=0; out="$(run_apply "$repo" "$out_file" 'v0.9.2' "$new_sha" '2026-08-21')" || rc=$? +assert_exit 'apply: a same-day ahead pin exits 0' 0 "$rc" +assert_contains 'apply: a same-day ahead pin is left as is' "$out" 'left as is' +assert_contains 'apply: a same-day ahead pin keeps its SHA' "$(cat "$repo/$guard_caller")" "@${guard_sha} # c3d4e5f 2026-08-21" + +# When compare says the release contains the pin (`behind`), same-day is +# irrelevant and the pin advances to the tag form. +repo="$scratch/repo-same-day-behind" +lane_repo "$repo" "$old_sha" 'v0.9.1' +mkdir -p "$repo/.github/workflows" "$repo/components/managed-files-guard" +for extra in sync.yml standards-sync-stuck-automerge-alert.yml claude-review.yml; do + printf 'jobs:\n job:\n uses: melodic-software/ci-workflows/.github/workflows/%s@%s # v0.9.1\n' \ + "$extra" "$old_sha" > "$repo/.github/workflows/$extra" +done +printf 'jobs:\n g:\n steps:\n - uses: melodic-software/ci-workflows/.github/actions/managed-files-guard@%s # c3d4e5f 2026-08-21\n' \ + "$guard_sha" > "$repo/$guard_caller" +git -C "$repo" add -A +git -C "$repo" -c commit.gpgsign=false -c core.hooksPath= commit -qm 'guard pinned the day of the release, contained' +export STUB_COMPARE_STATUS=behind +out_file="$scratch/out-apply-same-day-behind" +rc=0; out="$(run_apply "$repo" "$out_file" 'v0.9.2' "$new_sha" '2026-08-21')" || rc=$? +assert_exit 'apply: a same-day contained pin exits 0' 0 "$rc" +assert_not_contains 'apply: a same-day contained pin is not left as is' "$out" 'left as is' +assert_contains 'apply: a same-day contained pin advances to the tag form' \ + "$(cat "$repo/$guard_caller")" "@${new_sha} # v0.9.2" + +# A failed compare must not rewrite: guessing "not ahead" is the downgrade. +repo="$scratch/repo-compare-fail" +lane_repo "$repo" "$old_sha" 'v0.9.1' +mkdir -p "$repo/.github/workflows" "$repo/components/managed-files-guard" +for extra in sync.yml standards-sync-stuck-automerge-alert.yml claude-review.yml; do + printf 'jobs:\n job:\n uses: melodic-software/ci-workflows/.github/workflows/%s@%s # v0.9.1\n' \ + "$extra" "$old_sha" > "$repo/.github/workflows/$extra" +done +printf 'jobs:\n g:\n steps:\n - uses: melodic-software/ci-workflows/.github/actions/managed-files-guard@%s # c3d4e5f 2026-08-21\n' \ + "$guard_sha" > "$repo/$guard_caller" +git -C "$repo" add -A +git -C "$repo" -c commit.gpgsign=false -c core.hooksPath= commit -qm 'compare lookup fails' +export STUB_COMPARE_STATUS=fail +out_file="$scratch/out-apply-compare-fail" +rc=0; out="$(run_apply "$repo" "$out_file" 'v0.9.2' "$new_sha" '2026-08-21')" || rc=$? +assert_nonzero 'apply: a failed compare is a hard failure' "$rc" +assert_contains 'apply: a failed compare names the lookup' "$out" 'Could not compare' +assert_contains 'apply: a failed compare keeps the fallback pin' "$(cat "$repo/$guard_caller")" "@${guard_sha} # c3d4e5f 2026-08-21" +unset STUB_COMPARE_STATUS + +# Every enumerated caller ahead of the release is a clean no-op, not a +# failure and not a proposal: the tree stays clean for a later step. +export STUB_COMPARE_STATUS=ahead +repo="$scratch/repo-all-ahead" +lane_repo "$repo" "$old_sha" 'v0.9.1' +sed -i -E "s|# v0\.9\.1\$|# c136b27 2026-09-09|" "$repo"/components/claude-lanes/*.yml +git -C "$repo" add -A +git -C "$repo" -c commit.gpgsign=false -c core.hooksPath= commit -qm 'every lane pinned ahead' +out_file="$scratch/out-apply-all-ahead" +rc=0; out="$(run_apply "$repo" "$out_file" 'v0.9.2' "$new_sha" '2026-08-21')" || rc=$? +assert_exit 'apply: every caller ahead exits 0' 0 "$rc" +assert_contains 'apply: every caller ahead reports changed=false' "$(cat "$out_file")" 'changed=false' +assert_contains 'apply: every caller ahead is a notice' "$out" 'ahead of v0.9.2; nothing to propose' +assert_silent 'apply: every caller ahead leaves the tree clean' "$(git -C "$repo" status --porcelain)" +unset STUB_COMPARE_STATUS + +# The date is the comparison's whole basis, so a missing or malformed one is +# refused before any file is touched. +repo="$scratch/repo-bad-date" +lane_repo "$repo" "$old_sha" 'v0.9.1' +rc=0; out="$(run_apply "$repo" "$scratch/out-bad-date" 'v0.9.2' "$new_sha" '21/08/2026')" || rc=$? +assert_nonzero 'apply: a malformed release date is refused' "$rc" +assert_contains 'apply: a malformed release date is named' "$out" '21/08/2026' +assert_silent 'apply: a malformed release date touches nothing' "$(git -C "$repo" status --porcelain)" # A production-shaped repo that drops one extra must not silently skip it. repo="$scratch/repo-partial-extras" @@ -508,5 +711,8 @@ rc=0; out="$(GITHUB_OUTPUT="$scratch/out-usage" bash "$script" frobnicate 2>&1)" assert_nonzero 'an unknown subcommand is a usage error' "$rc" rc=0; out="$(GITHUB_OUTPUT="$scratch/out-usage" bash "$script" apply v1.0.0 2>&1)" || rc=$? assert_nonzero 'apply with a missing argument is a usage error' "$rc" +rc=0; out="$(GITHUB_OUTPUT="$scratch/out-usage" bash "$script" apply v1.0.0 "$new_sha" 2>&1)" || rc=$? +assert_nonzero 'apply without the release date is a usage error' "$rc" +assert_contains 'apply usage names the date argument' "$out" '' [[ $FAILED -eq 0 ]] || exit 1 diff --git a/components/managed-files-guard/README.md b/components/managed-files-guard/README.md new file mode 100644 index 0000000..f7afc31 --- /dev/null +++ b/components/managed-files-guard/README.md @@ -0,0 +1,187 @@ +# managed-files-guard caller + +Sync-managed caller for the `managed-files-guard` composite action in +`melodic-software/ci-workflows` +(`.github/actions/managed-files-guard`, ci-workflows#208). The action reads +this repository's `distribution/sync-manifest.yml` at a given ref, resolves +the calling repository's managed destination paths, and fails the pull +request when its diff touches one of them. That failing check is the signal +ADR-0007 assigned to a downstream hand-edit of a managed file +(`docs/adr/0007-keep-the-managed-file-seam-binary.md`): the seam stays +binary, the fix path is always a standards change, and the guard is what +makes a hand-edit visible before the next sync silently reverts it. + +`managed-files-guard.yml` is the component's one file. The +`managed-files-guard-caller` manifest component materializes it at +`.github/workflows/managed-files-guard.yml` in every target that manages it. +It is the second recorded exception to the rule that workflow callers stay +consumer-owned (`distribution/README.md`), for the same reason as the Claude +review-lane callers: a fleet-wide guard is only a signal if every target runs +the same caller at the same pin, and the sync is the one mechanism that holds +that. + +## Rollout: advisory first, hosted-only first + +**Advisory soak.** The check is not aggregated into any target's `ci-status` +and is not a required context anywhere. It runs on every pull request, +reports, and blocks nothing. This follows the action's own contract +("advisory-first: wire into ci-status only after a clean soak") and the +enforcement-rollout steps in `docs/component-lifecycle.md`: observe against +the live consumers, classify every finding, then promote per target once the +baseline is understood. Promotion is a per-target decision, taken in that +target's own `ci-status` wiring after its soak is clean; nothing in this +component promotes. The soak and the promotion decisions are operator-owned +after the admitting pull request (standards#496); that issue is the record +until each target's promotion lands. + +**Hosted-only first hop.** The caller runs on `ubuntu-24.04` directly, with +no `select-runner` indirection. `runner-policy` admits that shape on a public +repository and on a private repository not enrolled for local CI routing, so +one file serves both without a per-visibility variant. This hop is therefore +managed for exactly the hosted-only-eligible targets: + +- `melodic-software/.github`, `agent-plugins`, `ci-runner`, + `claude-code-plugins`, `codex-plugins`, `cursor-plugins` (public), and + `claude-code-proxy` (private, not enrolled for selector routing). + +`claude-code-plugins` is the one consumer here that also executes the +`runner-policy` gate; `components/runner-policy/runner-policy.test.mjs` +asserts this caller audits clean under a public hosted-only inventory so +the sync cannot red that target's `ci-status`. + +**Excluded this hop: the selector-routed targets.** `dotfiles`, +`github-iac`, `medley`, and `provisioning` are private targets enrolled for +local CI routing (each manages `runner-policy` and a selector-routed Claude +lane caller). There, `runner-policy` requires every independently scheduled +read-only job to route through the governed selector, and a fixed +`runs-on: ubuntu-24.04` job fails the gate. A public-safe hosted caller +cannot be made selector-routed without becoming a different file, so those +four take a **selector-routed sibling component** in a second hop. Removal +trigger: that sibling landing; this component's target list does not grow to +include them. The contract test holds the boundary mechanically: no target +may manage this caller and a `components/claude-lanes/`-sourced caller at +once. + +**Locally owned by `ci-workflows`.** ci-workflows hosts the action and +already runs the guard as a job of its own `ci.yml`, through a `./` action +reference that resolves to the commit under test. A synced caller pinned to +an older commit would run the guard twice per pull request, once at each +revision, so ci-workflows carries the component `locally-owned`. Removal +trigger: ci-workflows retiring its in-repo job in favor of the synced caller. + +`standards` is the manifest source, not a target, and carries no caller. + +## The two pins + +**The action pin** is a full 40-character commit SHA of ci-workflows `main`, +under the `pin-comment-convention` (`components/pin-comment-convention/`). +The admitted pin is `3b2f4eab5b4bb58a150e400613350ede37742ee8` +(2026-08-30, ci-workflows#530), the commit that closed the guard's fail-open +on an unreadable diff: before it, an unfetched or bogus ref produced an empty +change list and the guard passed precisely when it could not see the diff. +No release carried that commit when this component was admitted (the newest, +v0.17.2, is 2026-08-21), so the comment takes the convention's fallback form +`# 3b2f4ea 2026-08-30` rather than a release tag. + +**`standards-ref: main`** for the soak, per the action's input contract +("Pin to a full SHA in callers once soak completes"). The guard must read the +manifest that is live for the calling repository: a fixed standards SHA would +stop tracking target-roster and component changes the moment it landed, and +every sync after it would move destinations the guard no longer knew about. +`standards-ref` is a workflow input naming a ref of a different repository, +not a `uses:` reference, so the ci-workflows pin-comment convention does not +apply to it. Moving it to a SHA is a post-soak decision that then needs its +own advance path (the sync engine's `dest-paths` contract at that SHA is what +the guard executes); do not pin it in this hop. + +## Pin-advance path + +The action pin rides the existing `claude-lanes-repin` cascade +(`.github/workflows/claude-lanes-repin.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. This file is enumerated in +`components/claude-lanes/repin-callers.sh`'s `EXTRA_CALLER_FILES` and in the +workflow's `add-paths`, so the same reviewed pull request that re-pins the +lane callers re-pins the guard and the sync fans it out. + +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 + 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 + moving this file from `3b2f4ea` (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; until then the re-pin pull request names the + file in its version note and moves the lane callers only. A failed + compare is a hard failure, not a rewrite. +- **Not in `repin-policy-lockstep.mjs`'s `REPIN_TARGETS`.** Every `kind` that + table expresses (`selector`, `lane`, `reusable`) copies a + `components/runner-policy/policy.json` contract forward from the old SHA to + the new one and diffs the reusable workflow's `workflow_call` surface. + Composite actions are not SHA-allowlisted by runner-policy + (`components/runner-policy/README.md`), so there is no contract to copy and + no `workflow_call` surface to diff; an entry would either invent a kind the + policy does not have or force the lockstep to `manual` on every release. + The guard caller therefore takes the pin rewrite only, which is the whole + of what it needs. runner-policy governs the caller as an ordinary hosted + job (fixed approved label, read-only token, full-SHA action pin), not + through `approvedReusableWorkflowContracts`; a bogus reusable-workflow + contract for a composite action would be exactly the misuse that README + warns against. + +## Ownership and operation + +- **Owner:** the standards repository maintainers (the manifest and the + Claude lane callers share the same owner); the ci-workflows maintainers own + the action itself. +- **Acceptance during soak:** across the seven live consumers, every finding + is either a real downstream hand-edit of a managed destination (the check + is doing its job) or a classified defect in the action or manifest; the + `standards-sync` label / `melodic-standards-sync[bot]` actor exemption + keeps sync pull requests green. A false red on a sync pull request, or a + green on a hand-edit, is a defect to fix upstream before any promotion. +- **Rollback:** move the component to `locally-owned` for a target (the + next sync stops writing it; the target deletes its copy in its own pull + request), or remove the component from the manifest to withdraw it + fleet-wide. Nothing else references the file. +- **Failure behavior:** the action fails closed on an unreadable diff or an + unresolvable manifest, no-ops when the repository is not a manifest target, + and needs only `contents: read` (standards is public; the checkout of it + uses the job's ambient token with `persist-credentials: false`). +- **The `actions/checkout` pin.** Unlike the Claude lane callers, this file + carries a third-party action pin, and two consequences follow. In this + repository, Dependabot's `github-actions` ecosystem scans + `.github/workflows/` only, so the component's checkout pin never moves on + its own; the contract test asserts it equals the sibling workflows' pin, so + a Dependabot bump of those workflows fails the `actionlint` lane until the + component follows in the same change. That is the checkout advance path, + and it is deliberate lockstep, not friction to remove. In a consumer, its + own Dependabot will propose bumping `actions/checkout` inside the managed + file; that pull request is precisely the hand-edit the guard reports, and + the next sync reverts it. Expect that finding class during the soak, and + classify it as structural rather than as a downstream defect. Before + promotion, resolve it one of two ways: the action absorbs the consumer + checkout (so the caller carries no third-party pin at all), or the fleet + Dependabot posture for managed callers is settled in `github-iac`. Neither + belongs to this hop. + +## Verification + +`managed-files-guard.test.sh` asserts, against the parsed YAML: the +`pull_request` trigger; `contents: read` as the whole grant; the canonical +`concurrency-policy` block and nothing else in it; one job on the literal +approved hosted label with a 10-minute timeout and no selector or reusable +call; a full-history, credential-free checkout pinned like the sibling +workflows; the action pinned by full SHA with a comment the +`pin-comment-convention` library accepts; and `standards-ref: main`. It then +checks the manifest wiring (destination path, hosted-only targets only, +ci-workflows `locally-owned`, every target accounted for) and materializes +each managing target through the real engine, asserting byte-identity at the +destination and, when `actionlint` is on PATH, a clean lint there. +`components/claude-lanes/repin-callers.test.sh` covers the cascade half, +including the ahead-of-release fence. diff --git a/components/managed-files-guard/managed-files-guard.test.sh b/components/managed-files-guard/managed-files-guard.test.sh new file mode 100755 index 0000000..1cb30f5 --- /dev/null +++ b/components/managed-files-guard/managed-files-guard.test.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Contract tests for the managed-files-guard caller component: the workflow +# bytes the `managed-files-guard-caller` manifest component ships to every +# hosted-only-eligible target's .github/workflows/managed-files-guard.yml. +# +# Three layers, in order: +# 1. Shape — the parsed YAML carries exactly the locked design: pull_request +# trigger, read-only token, the canonical concurrency block, one hosted +# job with no selector, a full-history checkout, and the composite action +# pinned by full SHA under the pin-comment convention with +# `standards-ref: main` for the soak. +# 2. Manifest wiring — the component maps to the locked destination, is +# managed only for hosted-only-eligible targets (never alongside a +# selector-routed lane caller), is `locally-owned` by ci-workflows, and +# accounts for every target one way or another. +# 3. Materialization — the engine writes the bytes where the manifest says, +# byte-identical, and (when actionlint is present) they lint clean there. +# +# yq v4 parses the YAML so the assertions are about the tree, not about text +# a comment could imitate; the pin-comment check reuses the convention's own +# library rather than re-deriving its grammar. +set -uo pipefail +root="$(git rev-parse --show-toplevel)" +# shellcheck source=harness/shell/lib.sh +source "$root/harness/shell/lib.sh" + +cd "$root" || exit 1 + +command -v yq >/dev/null 2>&1 || skip_suite 'Mike Farah yq v4 is not installed' +[[ "$(yq --version 2>/dev/null)" =~ version[[:space:]]+v?4\. ]] || + skip_suite 'Mike Farah yq v4 is required' + +component='managed-files-guard-caller' +source='components/managed-files-guard/managed-files-guard.yml' +destination='.github/workflows/managed-files-guard.yml' +manifest='distribution/sync-manifest.yml' +actionlint_config='.github/actionlint.yaml' +action_path='melodic-software/ci-workflows/.github/actions/managed-files-guard' +# shellcheck disable=SC2016 # a GitHub Actions expression, compared literally +canonical_group='${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}' + +# shellcheck source=components/pin-comment-convention/pin-comment-patterns.sh +source "$root/components/pin-comment-convention/pin-comment-patterns.sh" + +scratch="$(mktemp -d)" +trap 'rm -rf -- "$scratch"' EXIT + +assert_file_exists 'the caller component exists' "$source" + +# q — evaluate a yq expression against the caller, raw output. +q() { yq -r "$1" "$source"; } + +# ------------------------------------------------------------------ 1. shape + +assert_contains 'header marks the file sync-managed' "$(head -n 12 "$source")" 'SYNC-MANAGED FILE' +assert_contains 'header names the standards source path' "$(head -n 12 "$source")" "$source" +assert_contains 'header names the manifest component' "$(head -n 12 "$source")" "$component" + +assert_eq 'workflow name is managed-files-guard' 'managed-files-guard' "$(q '.name')" +assert_eq 'triggers on pull_request only' 'pull_request' "$(q '.on | keys | join(",")')" +assert_eq 'workflow token is contents: read and nothing else' 'contents=read' \ + "$(q '.permissions | to_entries | map(.key + "=" + .value) | join(",")')" + +assert_eq 'concurrency group is the canonical concurrency-policy expression' \ + "$canonical_group" "$(q '.concurrency.group')" +assert_eq 'concurrency cancels in-progress runs' 'true' "$(q '.concurrency["cancel-in-progress"]')" +assert_eq 'concurrency block carries exactly the two canonical keys' 'cancel-in-progress,group' \ + "$(q '.concurrency | keys | sort | join(",")')" + +assert_eq 'exactly one job' '1' "$(q '.jobs | length')" +assert_eq 'the job is named managed-files-guard' 'managed-files-guard' "$(q '.jobs | keys | .[0]')" +assert_eq 'the job runs on the approved hosted label directly' 'ubuntu-24.04' \ + "$(q '.jobs["managed-files-guard"]["runs-on"]')" +assert_eq 'the job has a 10-minute timeout' '10' "$(q '.jobs["managed-files-guard"]["timeout-minutes"]')" +assert_eq 'no job calls a reusable workflow' '0' "$(q '[.jobs[] | select(has("uses"))] | length')" +# Tree positions only: the header prose legitimately NAMES the selector while +# explaining why the file does not use it. +assert_eq 'no uses: anywhere references select-runner' '0' \ + "$(q '[.jobs[] | .uses, .steps[].uses] | map(select(. != null and test("select-runner"))) | length')" +assert_eq 'runs-on is a literal, not an expression' '0' \ + "$(q '[.jobs[]["runs-on"] | select(test("\\$\\{\\{"))] | length')" +assert_eq 'no job-level permissions block (the workflow grant is the whole grant)' '0' \ + "$(q '[.jobs[] | select(has("permissions"))] | length')" + +steps="$(q '.jobs["managed-files-guard"].steps | length')" +assert_eq 'two steps: checkout, then the guard' '2' "$steps" + +checkout_uses="$(q '.jobs["managed-files-guard"].steps[0].uses')" +sibling_checkout="$(yq -r '.jobs.repin.steps[] | select(.uses | test("^actions/checkout@")) | .uses' \ + .github/workflows/claude-lanes-repin.yml)" +assert_contains 'first step is actions/checkout' "$checkout_uses" 'actions/checkout@' +assert_eq 'checkout is pinned to the same SHA as the sibling workflows' "$sibling_checkout" "$checkout_uses" +if [[ "$checkout_uses" =~ @[0-9a-f]{40}$ ]]; then + pass 'checkout pin is a full 40-character SHA' +else + fail 'checkout pin is a full 40-character SHA' "got $checkout_uses" +fi +assert_eq 'checkout does not persist credentials' 'false' \ + "$(q '.jobs["managed-files-guard"].steps[0].with["persist-credentials"]')" +assert_eq 'checkout fetches full history so the guard can diff base...head' '0' \ + "$(q '.jobs["managed-files-guard"].steps[0].with["fetch-depth"]')" + +guard_uses="$(q '.jobs["managed-files-guard"].steps[1].uses')" +assert_contains 'second step calls the ci-workflows managed-files-guard action' "$guard_uses" "${action_path}@" +guard_sha="${guard_uses##*@}" +if [[ "$guard_sha" =~ ^[0-9a-f]{40}$ ]]; then + pass 'guard action pin is a full lowercase 40-character SHA' +else + fail 'guard action pin is a full lowercase 40-character SHA' "got $guard_sha" +fi +assert_eq 'guard action pin line is unique in the file' '1' \ + "$(grep -c "uses: ${action_path}@" "$source")" +assert_eq 'standards-ref is main for the soak' 'main' \ + "$(q '.jobs["managed-files-guard"].steps[1].with["standards-ref"]')" +assert_eq 'the guard step passes standards-ref and nothing else' 'standards-ref' \ + "$(q '.jobs["managed-files-guard"].steps[1].with | keys | join(",")')" + +# The pin comment: exactly one of the convention's two forms, checked by the +# convention's own library. A bare SHA, prose, or a fallback short-sha that +# does not prefix the pin all fail here. +rc=0 +out="$(pcc::scan_text "$(<"$source")")" || rc=$? +assert_exit 'the ci-workflows pin carries a convention-conforming comment' 0 "$rc" +assert_silent 'the pin-comment scan reports no violation' "$out" + +# ------------------------------------------------------- 2. manifest wiring + +assert_eq 'manifest maps the component source to the locked destination' "$destination" \ + "$(yq -r ".components.\"$component\".files.\"$source\"" "$manifest")" +assert_eq 'the component ships exactly one file' '1' \ + "$(yq -r ".components.\"$component\".files | length" "$manifest")" + +# componentsource for every claude-lanes-sourced (selector-routed) caller. +# shellcheck disable=SC2016 # yq expression; $c is a yq variable, not shell +mapfile -t selector_components < <( + yq -r '.components | to_entries[] | .key as $c | .value.files | keys[] | $c + "\t" + .' "$manifest" | + grep -F $'\tcomponents/claude-lanes/' | cut -f1 | sort -u +) +assert_nonzero 'manifest carries at least one selector-routed lane caller to exclude against' \ + "${#selector_components[@]}" +declare -A is_selector_component=() +for c in "${selector_components[@]}"; do is_selector_component["$c"]=1; done + +mapfile -t all_targets < <(yq -r '.targets | keys[]' "$manifest") +managed_targets=() +locally_owned_targets=() +unaccounted=() +for target in "${all_targets[@]}"; do + mapfile -t managed < <(yq -r ".targets.\"$target\".managed // [] | .[]" "$manifest") + mapfile -t owned < <(yq -r ".targets.\"$target\".\"locally-owned\" // [] | .[]" "$manifest") + has_guard=0 + owns_guard=0 + routes_selector=0 + for c in "${managed[@]}"; do + [[ "$c" == "$component" ]] && has_guard=1 + [[ -n "${is_selector_component[$c]-}" ]] && routes_selector=1 + done + for c in "${owned[@]}"; do + [[ "$c" == "$component" ]] && owns_guard=1 + done + if [[ "$has_guard" -eq 1 ]]; then + managed_targets+=("$target") + # A target that manages a selector-routed lane caller is a private repo + # enrolled for local routing, where a fixed hosted job fails + # runner-policy — the second hop's sibling serves it, not this file. + assert_eq "$target manages the hosted-only caller and no selector-routed lane caller" '0' "$routes_selector" + elif [[ "$owns_guard" -eq 1 ]]; then + locally_owned_targets+=("$target") + elif [[ "$routes_selector" -eq 1 ]]; then + : # deferred to the selector-routed sibling; accounted for + else + unaccounted+=("$target") + fi +done + +assert_nonzero 'at least one target manages the caller' "${#managed_targets[@]}" +assert_eq 'ci-workflows owns the guard locally (it runs the action from its own tree)' \ + 'melodic-software/ci-workflows' "$(printf '%s\n' "${locally_owned_targets[@]}" | paste -sd, -)" +assert_eq 'every sync target is covered: managed, locally-owned, or deferred to the selector-routed hop' \ + '' "$(printf '%s\n' "${unaccounted[@]-}" | paste -sd, -)" + +# ------------------------------------------------------ 3. materialization + +have_actionlint=0 +if command -v actionlint >/dev/null 2>&1; then + have_actionlint=1 +else + skip_case 'actionlint not installed; the materialized callers are not linted' +fi + +consumer_checkout() { + local target="$1" dir="$2" + make_repo "$dir" + git -C "$dir" remote add origin "https://github.com/$target.git" +} + +for target in "${managed_targets[@]}"; do + consumer="$scratch/${target##*/}" + consumer_checkout "$target" "$consumer" + bash distribution/sync-manifest.sh apply --target "$target" --target-root "$consumer" >/dev/null + assert_exit "$target materializes" 0 "$?" + assert_file_exists "$target receives $destination" "$consumer/$destination" + if cmp -s "$source" "$consumer/$destination"; then + pass "$target receives the component bytes unchanged" + else + fail "$target receives the component bytes unchanged" "destination differs from $source" + fi + if [[ "$have_actionlint" -eq 1 ]]; then + [[ -f "$consumer/$actionlint_config" ]] || cp "$actionlint_config" "$consumer/$actionlint_config" + out="$(cd "$consumer" && actionlint -no-color 2>&1)" + rc=$? + assert_exit "$target lints clean after sync" 0 "$rc" + assert_silent "$target sync emits no findings" "$out" + fi +done + +# A target that owns the guard locally must receive nothing at the destination. +for target in "${locally_owned_targets[@]}"; do + consumer="$scratch/${target##*/}" + consumer_checkout "$target" "$consumer" + bash distribution/sync-manifest.sh apply --target "$target" --target-root "$consumer" >/dev/null + assert_exit "$target materializes" 0 "$?" + assert_file_absent "$target (locally-owned) receives no caller" "$consumer/$destination" +done + +[[ $FAILED -eq 0 ]] || exit 1 diff --git a/components/managed-files-guard/managed-files-guard.yml b/components/managed-files-guard/managed-files-guard.yml new file mode 100644 index 0000000..1ca836b --- /dev/null +++ b/components/managed-files-guard/managed-files-guard.yml @@ -0,0 +1,70 @@ +name: managed-files-guard + +# SYNC-MANAGED FILE — DO NOT EDIT IN THE CONSUMING REPOSITORY. +# Source of truth: melodic-software/standards, +# components/managed-files-guard/managed-files-guard.yml (the +# `managed-files-guard-caller` component in distribution/sync-manifest.yml). +# Downstream edits are overwritten by the next sync PR; change the component +# upstream, or move the component to `locally-owned` in the manifest to +# customize. +# +# Thin caller for the org's managed-files-guard composite action +# (melodic-software/ci-workflows, ci-workflows#208). The action resolves this +# repository's sync-manifest-managed destination paths from the standards ref +# it is given and fails the pull request when the diff hand-edits one of them +# (ADR-0007: a managed file is byte-exact with its standards source; the fix +# path is a standards change, never a downstream edit). Pull requests opened +# by the sync itself — labelled `standards-sync`, or authored by +# `melodic-standards-sync[bot]` — are exempt inside the action. +# +# ADVISORY during soak (standards#496): this check is NOT aggregated into +# `ci-status` and is not a required context. Promotion is a per-target +# decision taken after a clean soak, recorded in the component README. +# +# Hosted-only shape: `runs-on` is the approved hosted label directly, with no +# select-runner indirection, so the same bytes serve public and un-enrolled +# private targets. A private target enrolled for local CI routing needs a +# selector-routed sibling instead (see the component README) and is not a +# consumer of this file. + +on: + pull_request: + +permissions: + contents: read + +# Canonical block from the standards `concurrency-policy` component: a newer +# push to the same pull request cancels the in-flight guard run; the +# `run_id` fallback keeps non-PR runs unique and never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + managed-files-guard: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The guard diffs base...head; a shallow checkout has no base to + # diff against, and the action fails closed on a diff it cannot see. + fetch-depth: 0 + - name: Guard managed files + # Pinned to the merged ci-workflows main commit that closes the + # guard's fail-open on an unreadable diff (ci-workflows#530). No + # release carries it yet, so the pin-comment takes the convention's + # fallback form; the claude-lanes-repin cascade advances it to the + # first release that does (components/managed-files-guard/README.md). + uses: melodic-software/ci-workflows/.github/actions/managed-files-guard@3b2f4eab5b4bb58a150e400613350ede37742ee8 # 3b2f4ea 2026-08-30 + with: + # `main` for the soak, per the action's own input contract ("Pin to + # a full SHA in callers once soak completes"): the guard must read + # the manifest that is live for this repository, and a fixed + # standards SHA would stop tracking target roster and component + # changes the moment it landed. `standards-ref` is a workflow input + # naming a ref of a different repository, so the ci-workflows + # pin-comment convention does not apply to it. + standards-ref: main diff --git a/components/runner-policy/runner-policy.test.mjs b/components/runner-policy/runner-policy.test.mjs index ea0fb97..b105ec4 100644 --- a/components/runner-policy/runner-policy.test.mjs +++ b/components/runner-policy/runner-policy.test.mjs @@ -9034,3 +9034,54 @@ test("a selector-routed claude lane caller is rejected outright on a public cons ); } }); + +// The managed-files-guard caller is the hosted-only counterpart: a fixed +// approved hosted label, a read-only token, and a full-SHA composite-action +// step (composite actions are not SHA-allowlisted, so no contract entry is +// involved). It is managed for public targets and for private targets NOT +// enrolled for local routing, and one of its managed targets +// (claude-code-plugins) executes this gate, so the shipped bytes must audit +// clean under exactly those inventories — and must NOT be admitted to an +// enrolled private consumer, which is why those targets take a +// selector-routed sibling instead (components/managed-files-guard/README.md). +async function managedFilesGuardCaller() { + const manifest = parse( + await readFile(new URL("../../distribution/sync-manifest.yml", import.meta.url), "utf8"), + ); + const component = "managed-files-guard-caller"; + const sources = Object.keys(manifest.components[component]?.files ?? {}); + assert.equal(sources.length, 1, `expected ${component} to ship exactly one file`); + const body = await readFile(new URL(`../../${sources[0]}`, import.meta.url), "utf8"); + return { component, source: sources[0], body, manifest }; +} + +test("the managed-files-guard caller audits clean on every target that manages it", async () => { + const { component, source, body, manifest } = await managedFilesGuardCaller(); + const managedTargets = Object.entries(manifest.targets) + .filter(([, definition]) => (definition.managed ?? []).includes(component)) + .map(([target]) => target); + assert.ok(managedTargets.length > 0, `expected at least one target to manage ${component}`); + for (const target of managedTargets) { + const visibility = isPublicTarget(target) ? "public" : "private"; + const root = await consumerCarrying({ body, visibility, selfHostedCi: false }); + assert.deepEqual( + await auditRepository({ root, githubRepository: target, fetchImpl: HERMETIC_FETCH_STUB }), + [], + `${source} is managed for ${target} (${visibility}, hosted-only) but does not audit clean there`, + ); + } +}); + +test("the managed-files-guard caller is not admitted to a selector-enrolled private consumer", async () => { + const { body, source } = await managedFilesGuardCaller(); + const root = await consumerCarrying({ body, visibility: "private", selfHostedCi: true }); + const findings = await auditRepository({ + root, + githubRepository: "melodic-software/medley", + fetchImpl: HERMETIC_FETCH_STUB, + }); + assert.ok( + findings.length > 0, + `${source} audits clean for an enrolled private consumer; the selector-routed second hop may be unnecessary — revisit the hosted-only exclusion deliberately`, + ); +}); diff --git a/distribution/README.md b/distribution/README.md index bcdf627..a7327cf 100644 --- a/distribution/README.md +++ b/distribution/README.md @@ -83,8 +83,10 @@ Native adoption remains authoritative where it naturally lives: - package and `extends` references in consumer manifests; - actions and reusable workflows in consumer workflow files, with the - Claude review-lane callers as the recorded exception (see - [Claude review-lane caller components](#claude-review-lane-caller-components)); + Claude review-lane callers and the managed-files-guard caller as the + recorded exceptions (see + [Claude review-lane caller components](#claude-review-lane-caller-components) + and [managed-files-guard caller component](#managed-files-guard-caller-component)); - repository governance in the relevant `github-iac` repository; - repository reachability and App access in live GitHub state. @@ -183,7 +185,8 @@ requests. The report tells you which entries moved. component. 3. Add native packages, local adapters, workflow callers (other than the sync-managed - [Claude review-lane callers](#claude-review-lane-caller-components)), + [Claude review-lane callers](#claude-review-lane-caller-components) and + [managed-files-guard caller](#managed-files-guard-caller-component)), permissions, and the CI gateway in the consumer repository where those executable facts belong. 4. Review the generated materialization PR and verify CI. @@ -271,8 +274,9 @@ not a general workload runner or fallback. During migration the legacy unroutabl The synchronizer deliberately does not invent those files: workflow shape, exceptions, and dependency-update configuration are executable facts owned by each consumer. The -[Claude review-lane callers](#claude-review-lane-caller-components) are the -one recorded exception to that workflow-shape rule. A materialization PR is +[Claude review-lane callers](#claude-review-lane-caller-components) and the +[managed-files-guard caller](#managed-files-guard-caller-component) are the +two recorded exceptions to that workflow-shape rule. A materialization PR is not an adoption completion signal until its corresponding integration PR supplies this wiring and CI passes. @@ -368,6 +372,30 @@ public-target test for `components/claude-lanes/`, and either absorbing claude-code-plugins' repo-owned `security-review-evidence` guard into the reusable or accepting that caller stays `locally-owned`. +## managed-files-guard caller component + +`managed-files-guard-caller` materializes the thin hosted-only caller for the +`ci-workflows` `managed-files-guard` composite action at +`.github/workflows/managed-files-guard.yml`. The action fails a consumer pull +request that hand-edits one of that repository's managed destinations, which +is the signal ADR-0007 assigned to a downstream edit of a managed file. It is +the second recorded exception to the consumer-owned-caller rule, on the same +grounds as the first: the guard is a fleet signal only if every target runs +the same caller at the same pin. + +The caller runs on the approved hosted label directly, with no selector, so +it is `managed` for the hosted-only-eligible targets (the public targets plus +`claude-code-proxy`, private but not enrolled for local routing) and +deliberately NOT for the four selector-enrolled private targets (`dotfiles`, +`github-iac`, `medley`, `provisioning`), where runner-policy requires a +selector route for every read-only job; those take a selector-routed sibling +in a second hop. `ci-workflows` is `locally-owned`: it hosts the action and +already runs the guard from its own tree. The check is advisory (not in any +`ci-status`) during its soak, and the caller passes `standards-ref: main` +until the soak completes. Rationale, pins, the advance path through the +`claude-lanes-repin` cascade, and the promotion record live in +[`components/managed-files-guard/README.md`](../components/managed-files-guard/README.md). + ## Review-instructions reconciliation (medley) `review-instructions` is `locally-owned` in `melodic-software/medley`, not diff --git a/distribution/sync-manifest.yml b/distribution/sync-manifest.yml index 5c648f9..080a3d4 100644 --- a/distribution/sync-manifest.yml +++ b/distribution/sync-manifest.yml @@ -225,6 +225,32 @@ components: lychee: files: lychee.toml: lychee.toml + # Advisory managed-files-guard caller (standards#496, ADR-0007): a thin + # hosted-only workflow calling the ci-workflows `managed-files-guard` + # composite action, which fails a consumer PR that hand-edits one of that + # repository's managed destinations. The second recorded exception to the + # workflow-caller exclusion in README.md, for the same reason as the first: + # the guard is only a fleet signal if every target carries the same caller + # at the same pin, and the sync is the one mechanism that holds that. + # + # FIRST HOP — hosted-only-eligible targets only. The caller's `runs-on` is + # the approved hosted label directly, which runner-policy admits on a + # public repository and on a private one not enrolled for local routing. + # Deliberate exclusions this hop, with their removal trigger: + # - dotfiles, github-iac, medley, provisioning: private targets enrolled + # for selector routing (each manages `runner-policy` with a + # selector-routed lane caller), where a fixed hosted read-only job fails + # the runner-policy gate. They take a selector-routed sibling component + # in the second hop; trigger: that sibling landing. + # - ci-workflows: `locally-owned` — it hosts the action and already runs + # the guard as a job of its own ci.yml (dogfood via a `./` action ref + # pinned to its own commit), so a second caller would double the check. + # - standards: manifest source, not a target. + # Not in `ci-status` anywhere during soak; promotion is per-target and + # recorded in components/managed-files-guard/README.md. + managed-files-guard-caller: + files: + components/managed-files-guard/managed-files-guard.yml: .github/workflows/managed-files-guard.yml markdownlint: files: .markdownlint-cli2.jsonc: .markdownlint-cli2.jsonc @@ -307,6 +333,7 @@ targets: - editorconfig-checker - gitleaks - lychee + - managed-files-guard-caller - markdownlint - pr-body-contract-rule - repository-text @@ -323,6 +350,7 @@ targets: - editorconfig-checker - gitleaks - lychee + - managed-files-guard-caller - markdownlint - repository-text - typos @@ -343,6 +371,7 @@ targets: - gitleaks - go-analysis - lychee + - managed-files-guard-caller - markdownlint - pr-body-contract-rule - repository-text @@ -370,12 +399,20 @@ targets: - review-instructions - shellcheck - typos + # ci-workflows hosts the managed-files-guard action and already runs it + # as a job of its own ci.yml, through a `./` action ref that resolves to + # the commit under test. A synced caller pinned to an older commit would + # run the guard twice per PR, once at each revision. Removal trigger: + # ci-workflows retiring its in-repo job in favor of the synced caller. + locally-owned: + - managed-files-guard-caller melodic-software/claude-code-plugins: managed: - actionlint - editorconfig-checker - gitleaks - lychee + - managed-files-guard-caller - markdownlint - node-runtime - path-detection-guardrails @@ -450,6 +487,7 @@ targets: - lefthook-powershell - lefthook-python - lychee + - managed-files-guard-caller - markdownlint - node-runtime - psscriptanalyzer @@ -480,6 +518,7 @@ targets: - editorconfig-checker - gitleaks - lychee + - managed-files-guard-caller - markdownlint - pr-body-contract-rule - repository-text @@ -490,6 +529,7 @@ targets: - editorconfig-checker - gitleaks - lychee + - managed-files-guard-caller - markdownlint - repository-text - typos