diff --git a/.github/workflows/gitignore-baseline-tests.yml b/.github/workflows/gitignore-baseline-tests.yml index 5bb5db5fc..1b47093fb 100644 --- a/.github/workflows/gitignore-baseline-tests.yml +++ b/.github/workflows/gitignore-baseline-tests.yml @@ -9,6 +9,7 @@ # - test/scripts/sync-gitignore-baseline/** # - test/scripts/compliance-remediate/** # - tests/test_seed_repo_template_gitignore.bats +# - tests/test_seed_repo_template_workflows.bats # # Gates (all must pass before merge): # 1. shellcheck — static analysis of the lib + sync + seed + remediate scripts @@ -28,6 +29,7 @@ on: - 'test/scripts/sync-gitignore-baseline/**' - 'test/scripts/compliance-remediate/**' - 'tests/test_seed_repo_template_gitignore.bats' + - 'tests/test_seed_repo_template_workflows.bats' - '.github/workflows/gitignore-baseline-tests.yml' push: branches: @@ -41,6 +43,7 @@ on: - 'test/scripts/sync-gitignore-baseline/**' - 'test/scripts/compliance-remediate/**' - 'tests/test_seed_repo_template_gitignore.bats' + - 'tests/test_seed_repo_template_workflows.bats' - '.github/workflows/gitignore-baseline-tests.yml' permissions: @@ -82,4 +85,5 @@ jobs: test/scripts/lib/gitignore-baseline.bats \ test/scripts/sync-gitignore-baseline/ \ test/scripts/compliance-remediate/ \ - tests/test_seed_repo_template_gitignore.bats + tests/test_seed_repo_template_gitignore.bats \ + tests/test_seed_repo_template_workflows.bats diff --git a/scripts/seed-repo-template.sh b/scripts/seed-repo-template.sh index 87bcc7bb1..80595e8f0 100755 --- a/scripts/seed-repo-template.sh +++ b/scripts/seed-repo-template.sh @@ -4,6 +4,7 @@ # # Usage: # STANDARDS_DIR= bash scripts/seed-repo-template.sh --emit-baseline .gitignore +# STANDARDS_DIR= bash scripts/seed-repo-template.sh --emit-workflow dev-lead.yml # # --emit-baseline .gitignore # Print the seeded .gitignore: the marker-wrapped L1 secrets baseline on top @@ -11,10 +12,21 @@ # below the END marker. L2 must NOT re-ignore any path the baseline negates # (e.g. .env.example is re-allowed in L1 via !.env.example). # +# --emit-workflow +# Print the caller-stub workflow VERBATIM from $STANDARDS_DIR/standards/workflows/, +# the single source of truth for the org baseline scaffold. The re-seed MUST read +# the stub through this path — never from a stale embedded copy — so it can never +# revert repo-template to a wrong-channel pin (repo-template#86 flipped the +# major-scoped `@/v-stable` tags back to the BARE `@/stable` tier +# tags, undoing the standards-deploy convergence and seeding new repos onto the +# wrong channel — petry-projects/.github#886). As a fail-loud guard, a stub whose +# first-party channel-ref pin is a bare `/` (missing the `v-` major +# prefix the compliance audit requires) is REFUSED rather than emitted. +# # Env: # STANDARDS_DIR path to the petry-projects/.github checkout that holds the -# canonical /.gitignore and scripts/lib/gitignore-baseline.sh. -# Defaults to two levels above this script (the repo root). +# canonical /.gitignore, scripts/lib/gitignore-baseline.sh, and +# standards/workflows/. Defaults to the repo root above this script. set -euo pipefail STANDARDS_DIR="${STANDARDS_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)}" @@ -23,12 +35,78 @@ STANDARDS_DIR="${STANDARDS_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && source "${STANDARDS_DIR}/scripts/lib/gitignore-baseline.sh" _usage() { - echo "Usage: $0 --emit-baseline " >&2 - echo " Supported files: .gitignore" >&2 + echo "Usage: $0 --emit-baseline | --emit-workflow " >&2 + echo " --emit-baseline supported files: .gitignore" >&2 + echo " --emit-workflow reads standards/workflows/ verbatim" >&2 exit 2 } -[ $# -ge 2 ] && [ "$1" = "--emit-baseline" ] || _usage +# _assert_vform_pins — a caller stub delegating to a first-party reusable +# through a channel ref MUST pin the major-scoped `/v-` form, never +# the bare `/`. Those lines carry the inline S7637 marker, so we scope +# the check to them (mirrors test/scripts/standards-templates/vform-pins.bats). +# Exits non-zero listing every offending pin so the re-seed fails loud. +_assert_vform_pins() { + local file="$1" bad unmarked agentref_bad + # Fail-closed (#887 / qodo): the marker-based check below only inspects lines + # carrying the '# NOSONAR(githubactions:S7637) first-party channel ref' marker. + # If a deployable template has a first-party reusable `uses:` line but that line + # LOST the marker, the pin check would see zero lines and pass — emitting an + # unvalidated (possibly bare) pin. So first refuse any first-party reusable + # `uses:` line that lacks the marker: the guard cannot validate what it can't + # see, and must fail closed rather than emit unvalidated. (Local `./` self-host + # refs and third-party actions are not '<...>-reusable.yml@' and are exempt.) + unmarked="$(grep -E '^[[:space:]]*uses:[[:space:]]*petry-projects/[^[:space:]]*-reusable\.yml@' "$file" 2>/dev/null \ + | grep -vE 'S7637\) first-party channel ref' || true)" + if [ -n "$unmarked" ]; then + echo "::error::refusing to emit '${file#"${STANDARDS_DIR}"/}': first-party reusable uses: line(s) missing the '# NOSONAR(githubactions:S7637) first-party channel ref' marker — cannot validate the channel pin (fail-closed):" >&2 + printf '%s\n' "$unmarked" | sed 's/^/ /' >&2 + exit 3 + fi + bad="$(grep -E 'S7637\) first-party channel ref' "$file" 2>/dev/null \ + | sed -E 's/.*-reusable\.yml@([^[:space:]]+).*/\1/' \ + | grep -vE '^[a-z0-9-]+/v[0-9]+-(stable|next|ring[0-9]+)$' || true)" + if [ -n "$bad" ]; then + echo "::error::refusing to emit '${file#"${STANDARDS_DIR}"/}': bare channel pin(s) — must be major-scoped /v-:" >&2 + printf '%s\n' "$bad" | sed 's/^/ /' >&2 + exit 3 + fi + # Same fail-closed guard for `with: agent_ref:` (CodeRabbit): dev-lead.yml / + # add-to-project.yml thread the SAME channel into the reusable via agent_ref, so + # a partial edit could drop it to a bare tier while the marked `uses:` pin stays + # valid. Templates without an agent_ref match nothing here and pass unaffected. + agentref_bad="$(grep -E '^[[:space:]]*agent_ref:[[:space:]]' "$file" 2>/dev/null \ + | sed -E 's/.*agent_ref:[[:space:]]*([^[:space:]#]+).*/\1/' \ + | grep -vE '^[a-z0-9-]+/v[0-9]+-(stable|next|ring[0-9]+)$' || true)" + if [ -n "$agentref_bad" ]; then + echo "::error::refusing to emit '${file#"${STANDARDS_DIR}"/}': agent_ref value(s) not major-scoped /v-:" >&2 + printf '%s\n' "$agentref_bad" | sed 's/^/ /' >&2 + exit 3 + fi +} + +# _emit_workflow — verbatim copy of the standards/workflows/ template, +# gated by the v-form pin guard above. +_emit_workflow() { + local name="$1" src="${STANDARDS_DIR}/standards/workflows/$1" + case "$name" in + */*|.*|"") echo "::error::invalid workflow name '${name}'" >&2; exit 2 ;; + esac + if [ ! -f "$src" ]; then + echo "::error::unknown workflow '${name}' — no template at standards/workflows/${name}" >&2 + exit 2 + fi + _assert_vform_pins "$src" + cat "$src" +} + +[ $# -ge 2 ] || _usage + +case "$1" in + --emit-workflow) _emit_workflow "$2"; exit 0 ;; + --emit-baseline) : ;; # handled below + *) _usage ;; +esac file="$2" case "$file" in diff --git a/test/scripts/compliance-remediate/gitignore-baseline.bats b/test/scripts/compliance-remediate/gitignore-baseline.bats index 16d661e84..34480b984 100644 --- a/test/scripts/compliance-remediate/gitignore-baseline.bats +++ b/test/scripts/compliance-remediate/gitignore-baseline.bats @@ -54,7 +54,16 @@ case "$sub" in else exit "${GH_REF_EXISTS_RC:-0}" fi ;; - *contents/.gitignore"?"ref=*) printf '%s' "${GH_FILE_SHA:-}" ;; + *contents/.gitignore"?"ref=*) + # Branch-sha lookup. A real 200 always carries a sha; an absent file is a + # 404. The shared lib (#865) treats GET-success-with-blank-sha as a hard + # error (sha-unresolved), so the absent case MUST 404 (→ create path), + # not print empty and exit 0. + if [ -n "${GH_FILE_SHA:-}" ]; then + printf '%s' "$GH_FILE_SHA" + else + printf 'gh: Not Found (HTTP 404)\n' >&2; exit 1 + fi ;; *contents/.gitignore) # PUT of the upserted file onto the sync branch. if printf '%s' "$args" | grep -q -- '--method PUT'; then @@ -72,7 +81,17 @@ case "$sub" in printf 'gh: Not Found (HTTP 404)\n' >&2 exit 1 # 404 — .gitignore absent fi ;; - repos/*) printf '%s' "${GH_DEFAULT_BRANCH:-main}" ;; + repos/*) + # The repo endpoint serves BOTH the default-branch lookup and the + # preflight write probe (--jq .permissions.push, added to the shared + # sd_deploy_files_via_pr lib in #865). Differentiate on the jq arg so the + # write probe returns a boolean, not the default-branch string, else the + # preflight reads "main" != "true" and fails as no-write-access. + if printf '%s' "$args" | grep -q 'permissions.push'; then + printf '%s' "${GH_CAN_PUSH:-true}" + else + printf '%s' "${GH_DEFAULT_BRANCH:-main}" + fi ;; esac ;; esac diff --git a/tests/test_seed_repo_template_workflows.bats b/tests/test_seed_repo_template_workflows.bats new file mode 100644 index 000000000..bd0240ad8 --- /dev/null +++ b/tests/test_seed_repo_template_workflows.bats @@ -0,0 +1,149 @@ +#!/usr/bin/env bats +# Tests for the repo-template WORKFLOW-STUB generator in +# scripts/seed-repo-template.sh (#886). +# +# The repo-template baseline re-seed ("chore: seed org baseline file scaffold") +# must source its workflow stubs from the canonical standards/workflows/ — which +# pin the major-scoped `/v-stable` channel and carry the S7637 (uses:) +# and S7635 (secrets: inherit) NOSONAR markers — rather than from a stale +# embedded copy. When it drifted to an embedded copy it re-pinned repo-template +# back to the BARE `/stable` tier tag (repo-template#86: v1-stable → +# stable, v2-stable → stable), undoing the standards-deploy convergence and +# seeding every new repo onto the wrong channel. +# +# `--emit-workflow ` copies standards/workflows/ VERBATIM and +# refuses to emit a stub whose first-party channel-ref pin is a bare +# `/` (no v- major prefix), so a regression fails loud instead of +# being re-seeded fleet-wide. +# +# STANDARDS_DIR points the seed script at this checkout so templates are read +# from the local standards/workflows/ instead of the network. + +setup() { + REPO_ROOT="$(cd -- "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + SEED="${REPO_ROOT}/scripts/seed-repo-template.sh" + WF_DIR="${REPO_ROOT}/standards/workflows" + export STANDARDS_DIR="$REPO_ROOT" +} + +emit() { STANDARDS_DIR="$REPO_ROOT" bash "$SEED" --emit-workflow "$1"; } + +# The stubs the re-seed corrupted in repo-template#86 (the "spot-check" set). +RESEEDED_STUBS="dev-lead.yml auto-rebase.yml dependabot-automerge.yml" + +@test "--emit-workflow dev-lead.yml is byte-identical to the standards template" { + run emit dev-lead.yml + [ "$status" -eq 0 ] + [ "$output" = "$(cat "${WF_DIR}/dev-lead.yml")" ] +} + +@test "emitted dev-lead stub pins @dev-lead/v1-stable, never the bare tier tag" { + run emit dev-lead.yml + [ "$status" -eq 0 ] + echo "$output" | grep -qF 'dev-lead-reusable.yml@dev-lead/v1-stable' + echo "$output" | grep -qF 'agent_ref: dev-lead/v1-stable' + # The exact regression from repo-template#86 must be absent. + ! echo "$output" | grep -qE '@dev-lead/stable([[:space:]]|$)' + ! echo "$output" | grep -qE 'agent_ref:[[:space:]]*dev-lead/stable([[:space:]]|$)' +} + +@test "every re-seeded stub emits a major-scoped v-stable pin (no bare tier)" { + local name ref + for name in $RESEEDED_STUBS; do + run emit "$name" + [ "$status" -eq 0 ] + # Extract the @ from the first-party channel-ref (S7637-marked) uses line. + ref="$(echo "$output" | grep -E 'S7637\) first-party channel ref' \ + | sed -E 's/.*-reusable\.yml@([^[:space:]]+).*/\1/')" + [ -n "$ref" ] || { echo "no channel-ref line in $name"; return 1; } + echo "$ref" | grep -qE '^[a-z0-9-]+/v[0-9]+-stable$' \ + || { echo "$name pins non-v-form '$ref'"; return 1; } + done +} + +@test "every re-seeded stub keeps its S7637 (uses:) and S7635 (secrets: inherit) markers" { + local name + for name in $RESEEDED_STUBS; do + run emit "$name" + [ "$status" -eq 0 ] + echo "$output" | grep -qF 'NOSONAR(githubactions:S7637)' \ + || { echo "$name lost its S7637 marker"; return 1; } + echo "$output" | grep -qE '^[[:space:]]*secrets:[[:space:]]+inherit[[:space:]]+# NOSONAR\(githubactions:S7635\)' \ + || { echo "$name lost its S7635 secrets: inherit marker"; return 1; } + done +} + +@test "--emit-workflow rejects an unknown workflow name" { + run emit no-such-workflow.yml + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'unknown workflow' +} + +@test "--emit-workflow rejects path-traversal, dotfile, and empty names before any FS access" { + # The name guard (case '*/*|.*|"') blocks directory traversal and dotfiles up + # front, so --emit-workflow can never read outside standards/workflows/. + run emit '../etc/passwd' + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'invalid workflow' + + run emit 'foo/bar.yml' + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'invalid workflow' + + run emit '.hidden.yml' + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'invalid workflow' + + run emit '' + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'invalid workflow' +} + +@test "--emit-workflow refuses a template carrying a bare / pin" { + # Fixture: a standards dir whose dev-lead template regressed to the bare pin. + local fixture="${BATS_TEST_TMPDIR}/fixture" + mkdir -p "${fixture}/standards/workflows" "${fixture}/scripts/lib" + cp "${REPO_ROOT}/scripts/lib/gitignore-baseline.sh" "${fixture}/scripts/lib/" + sed -E 's#@dev-lead/v[0-9]+-stable#@dev-lead/stable#g' \ + "${WF_DIR}/dev-lead.yml" > "${fixture}/standards/workflows/dev-lead.yml" + run env STANDARDS_DIR="$fixture" bash "$SEED" --emit-workflow dev-lead.yml + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'bare' +} + +@test "--emit-workflow fails CLOSED when a first-party reusable uses: line lost its S7637 marker" { + # Fixture: a template whose dev-lead uses: line keeps a valid v-form pin but + # DROPPED the S7637 marker. The marker-based pin check would then inspect zero + # lines and pass (fail-open, #887/qodo). The guard must refuse instead. + local fixture="${BATS_TEST_TMPDIR}/fixture-nomarker" + mkdir -p "${fixture}/standards/workflows" "${fixture}/scripts/lib" + cp "${REPO_ROOT}/scripts/lib/gitignore-baseline.sh" "${fixture}/scripts/lib/" + sed -E '/uses:.*-reusable\.yml@/ s/[[:space:]]*# NOSONAR\(githubactions:S7637\).*$//' \ + "${WF_DIR}/dev-lead.yml" > "${fixture}/standards/workflows/dev-lead.yml" + # Sanity: the pin itself is still valid v-form (only the marker was removed). + grep -qE 'uses:.*@dev-lead/v[0-9]+-stable[[:space:]]*$' "${fixture}/standards/workflows/dev-lead.yml" + run env STANDARDS_DIR="$fixture" bash "$SEED" --emit-workflow dev-lead.yml + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'marker' +} + +@test "--emit-workflow fails CLOSED when agent_ref regressed to a bare tier (valid uses: pin)" { + # Fixture: dev-lead template keeps a valid v-form `uses:` pin + its S7637 marker + # but its `with: agent_ref:` dropped to the bare tier. The uses-pin check alone + # would pass; the agent_ref guard (CodeRabbit) must refuse instead. + local fixture="${BATS_TEST_TMPDIR}/fixture-agentref" + mkdir -p "${fixture}/standards/workflows" "${fixture}/scripts/lib" + cp "${REPO_ROOT}/scripts/lib/gitignore-baseline.sh" "${fixture}/scripts/lib/" + sed -E 's#^([[:space:]]*agent_ref:[[:space:]]*)dev-lead/v[0-9]+-stable#\1dev-lead/stable#' \ + "${WF_DIR}/dev-lead.yml" > "${fixture}/standards/workflows/dev-lead.yml" + # Sanity: the uses: pin is untouched (still valid v-form + S7637 marker). + grep -qE 'uses:.*@dev-lead/v[0-9]+-stable.*S7637' "${fixture}/standards/workflows/dev-lead.yml" + run env STANDARDS_DIR="$fixture" bash "$SEED" --emit-workflow dev-lead.yml + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'agent_ref' +} + +@test "usage is shown when --emit-workflow is given no name" { + run bash "$SEED" --emit-workflow + [ "$status" -ne 0 ] +}