Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions .github/workflows/standards-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
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}):"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading