diff --git a/.github/workflows/standards-sync.yml b/.github/workflows/standards-sync.yml index f9a0cc1..3235d95 100644 --- a/.github/workflows/standards-sync.yml +++ b/.github/workflows/standards-sync.yml @@ -7,12 +7,11 @@ name: standards-sync # synced files are read-only-by-convention downstream (change them in `standards`, # which re-cascades). See standards docs/dedup-program/config-distribution-plan.md. # -# DRAFT — NOT YET ACTIVE. Two gates must clear before this runs (both tracked in -# the plan doc): (1) a GitHub App with contents:write + pull-requests:write on the -# targets, provisioned via github-iac (Pulumi) and its app-id/private-key wired as -# secrets; (2) the caller pinning this workflow by SHA. Until then `dry-run` -# defaults true (plan only, no writes). Validate against this repo's actionlint + -# zizmor lanes before first merge. +# Activation is gated in the standards caller (see the plan doc): real runs need +# the GitHub App secrets (contents:write + pull-requests:write on the targets) +# and the caller pinning this workflow by SHA. `dry-run` defaults true, so a +# caller without secrets can still plan; `targets` narrows a run to named repos +# for the pilot and the staged rollout. on: workflow_call: @@ -31,6 +30,12 @@ on: opening PRs. Default true keeps the workflow inert until activated. type: boolean default: true + targets: + description: >- + Comma-separated allowlist of manifest targets (owner/name) to sync; + empty means all. Narrows a run for the pilot / staged rollout. + type: string + default: '' secrets: # Optional so a caller can invoke dry-run (the default, inert mode) without # wiring secrets before the App exists. The sync job validates they are @@ -79,21 +84,49 @@ jobs: id: build env: MANIFEST: ${{ inputs.manifest }} + TARGETS: ${{ inputs.targets }} run: | set -euo pipefail # yq is preinstalled on ubuntu-latest runners. Emit one matrix entry # per target: { repo, layout }. The per-file mapping is resolved in the # sync job from the same manifest (kept out of the matrix to bound size). - matrix="$(yq -o=json -I=0 '{"include": [.targets[] | {"repo": .repo, "repo_owner": (.repo | split("/")[0]), "repo_name": (.repo | split("/")[1]), "layout": .layout}]}' "$MANIFEST")" + # TARGETS narrows to an exact-match repo allowlist (the set subtraction + # is empty iff .repo is listed — no substring matching). Normalize it — + # strip spaces, squeeze/trim commas — so "a, b" and "a,b" filter alike + # and split never yields empty tokens; a filter with no repo names at + # all (e.g. ",,") is a hard error, not a silent plan-nothing run. + only="$(tr -d '[:space:]' <<<"$TARGETS" | tr -s ',' | sed 's/^,//; s/,$//')" + if [ -n "$(tr -d '[:space:]' <<<"$TARGETS")" ] && [ -z "$only" ]; then + echo "::error::targets filter '$TARGETS' contains no repo names" + exit 1 + fi + matrix="$(only="$only" yq -o=json -I=0 ' + {"include": [.targets[] + | select(strenv(only) == "" or ([.repo] - (strenv(only) | split(",")) | length) == 0) + | {"repo": .repo, "repo_owner": (.repo | split("/")[0]), "repo_name": (.repo | split("/")[1]), "layout": .layout}]} + ' "$MANIFEST")" + # Fail on ANY unmatched filter entry, not just an empty matrix — a + # typo in one of several targets must not silently drop that repo + # from a staged rollout while the rest sync "successfully". + if [ -n "$only" ]; then + unmatched="$(only="$only" yq -r '((strenv(only) | split(",")) - [.targets[].repo]) | join(",")' "$MANIFEST")" + if [ -n "$unmatched" ]; then + echo "::error::targets filter names unknown manifest targets: $unmatched" + exit 1 + fi + fi echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - name: Log distribution plan # The dry-run preview: list source -> dest per target from the manifest # alone — no target checkout, no token — so it works before the App is # provisioned and on every real run as a record of what will sync. + # Iterates the built matrix (not the raw manifest) so the logged plan is + # exactly the target set the sync job will run, filter included. env: MANIFEST: ${{ inputs.manifest }} DRY_RUN: ${{ inputs.dry-run }} + MATRIX: ${{ steps.build.outputs.matrix }} run: | set -euo pipefail echo "Distribution plan (dry-run=${DRY_RUN}):" @@ -106,7 +139,7 @@ jobs: if [ "$dest" = "null" ]; then echo " $tool: (skipped — not for layout $layout)"; continue; fi echo " $tool: $src -> $dest" done < <(repo="$repo" yq -r '.targets[] | select(.repo == strenv(repo)) | .include[]' "$MANIFEST") - done < <(yq -r '.targets[].repo' "$MANIFEST") + done < <(yq -p=json -r '.include[].repo' <<<"$MATRIX") sync: needs: plan @@ -137,6 +170,10 @@ jobs: private-key: ${{ secrets.app-private-key }} owner: ${{ matrix.repo_owner }} repositories: ${{ matrix.repo_name }} + # Narrow the minted token to the sync's needs (push branch + open PR) + # even if the App installation ever carries wider grants. + permission-contents: write + permission-pull-requests: write - name: Check out standards (sources) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0