From 579fe4d199d397a06599b81e2be67f775bc04cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 18:41:21 +0000 Subject: [PATCH 1/9] feat(apply-repo-settings): add export mode for reverse rulesets sync Add a 'mode' input (apply|export). In export mode the action reads the repo's live rulesets (and managed repository keys) from the API and writes them back into settings.yml, exposing a 'changed' output. Pairs with a branch_protection_rule-triggered workflow to capture UI changes back into source control. --- .github/actions/apply-repo-settings/README.md | 52 +++++- .github/actions/apply-repo-settings/action.sh | 149 +++++++++++++++++- .../actions/apply-repo-settings/action.yml | 20 ++- 3 files changed, 208 insertions(+), 13 deletions(-) diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md index a8626ba..3147744 100644 --- a/.github/actions/apply-repo-settings/README.md +++ b/.github/actions/apply-repo-settings/README.md @@ -4,6 +4,8 @@ An ephemeral, in-workflow alternative to the [repository-settings GitHub App](ht Reads `.github/settings.yml` from the current repo and applies the supported sections to the target repo via the GitHub API, using a GitHub App token (so the action runs only when invoked — no always-on bot, no third-party service). +It also runs in reverse: with `mode: export` it reads the repo's **live** config/rulesets and writes them back into `settings.yml`, so manual changes made in the GitHub UI can be captured back into source control (see [Reverse sync](#reverse-sync-mode-export) below). + ## Why not self-host the upstream app? The upstream app is a Probot webhook server — it's designed to listen for `push` events on a long-running process. Adapting it for ephemeral one-shot runs is more invasive than building a minimal applier from scratch. This action covers the two sections we actually use (`repository:` config and `rulesets:`) in ~170 lines of bash + `gh api`. Other sections (`labels`, `collaborators`, `teams`, `environments`, legacy `branches`) are not yet implemented — labels are managed via a separate sync today. @@ -15,15 +17,57 @@ The upstream app is a Probot webhook server — it's designed to listen for `pus | `token` | yes | — | GitHub token with `Administration: write` on the target repo (typically from `checkout-as-app` or `github-app-auth`). | | `owner` | no | current owner | Target repo owner. | | `repo` | no | current repo | Target repo name. | -| `settings-file` | no | `.github/settings.yml` | Path to the YAML to apply. | -| `dry-run` | no | `false` | Print what would change without applying. | -| `sections` | no | `repository,rulesets` | Comma-separated section names to apply. | +| `settings-file` | no | `.github/settings.yml` | Path to the YAML to apply (or write, in export mode). | +| `dry-run` | no | `false` | Print what would change without applying / writing. | +| `mode` | no | `apply` | `apply` (file → repo) or `export` (repo → file, reverse sync). | +| `sections` | no | `repository,rulesets` | Comma-separated section names to sync. | The action does **not** do any templating or placeholder substitution on `settings-file` — it applies the YAML as-is. If you need env-var-based substitution (e.g. resolving a GitHub App ID into `bypass_actors[].actor_id`), render the file upstream (e.g. with `envsubst`) before invoking this action. ## Outputs -- `summary` — JSON object: `{ repository, rulesets_created, rulesets_updated, rulesets_unchanged }`. +- `summary` — JSON object. In `apply` mode: `{ repository, rulesets_created, rulesets_updated, rulesets_unchanged }`. In `export` mode: `{ mode, changed, sections }`. +- `changed` — export mode only: `"true"` if `settings-file` was modified, else `"false"`. Empty in apply mode. + +## Reverse sync (`mode: export`) + +`mode: export` flips the direction: instead of pushing the YAML to the repo, it reads the repo's live state from the API and writes it **back** into `settings-file`, then reports whether the file changed via the `changed` output. This lets a workflow capture changes made directly in the GitHub UI back into source control instead of letting them drift. + +- **`rulesets`** → lists `/repos/{owner}/{repo}/rulesets`, fetches each, normalizes to the file's shape (`name`, `target`, `enforcement`, `conditions`, `bypass_actors`, `rules`), and replaces the `.rulesets` array wholesale. +- **`repository`** → reads `/repos/{owner}/{repo}` and updates **only the keys already present** in the file's `.repository` block (so drift on managed keys is captured without introducing keys the repo deliberately omits). + +The rest of the file (other top-level keys, the repository keys you don't manage) is preserved. The touched section is normalized, so inline comments inside `.rulesets` are dropped — fine for an auto-capture that lands as a reviewable PR diff. Classic branch protection (the legacy `branches:` protection API) is **not** exported — this org models protection as rulesets. + +### Auto-capturing UI changes + +Pair export with the `branch_protection_rule` workflow trigger so any protection change re-exports the rulesets and opens a PR: + +```yaml +on: + branch_protection_rule: + types: [created, edited, deleted] + +jobs: + export: + runs-on: ubuntu-latest + steps: + - uses: nsheaps/github-actions/.github/actions/checkout-as-app@main + id: checkout + with: + app-id: ${{ secrets.AUTOMATION_GITHUB_APP_ID }} + private-key: ${{ secrets.AUTOMATION_GITHUB_APP_PRIVATE_KEY }} + - uses: nsheaps/github-actions/.github/actions/apply-repo-settings@main + id: export + with: + token: ${{ steps.checkout.outputs.token }} + mode: export + sections: rulesets + # then: if steps.export.outputs.changed == 'true', commit + open-pr-if-needed +``` + +> There is no `repository_ruleset` Actions trigger, so `branch_protection_rule` (classic protection) is the available hook; the export re-reads rulesets on any such event. For ruleset-edit-driven exports, dispatch this action from an org-level ruleset webhook via `repository_dispatch`. + +The full wired-up workflow (commit + PR) lives at [`nsheaps/.github`'s `apply-repo-settings.yaml` template](https://github.com/nsheaps/.github/blob/main/ansible/templates/.github/workflows/apply-repo-settings.yaml). ## Setting up the GitHub App diff --git a/.github/actions/apply-repo-settings/action.sh b/.github/actions/apply-repo-settings/action.sh index 52f778d..52076ba 100755 --- a/.github/actions/apply-repo-settings/action.sh +++ b/.github/actions/apply-repo-settings/action.sh @@ -1,13 +1,24 @@ #!/usr/bin/env bash -# Apply Repo Settings — reads SETTINGS_FILE and applies the supported -# top-level sections to {OWNER}/{REPO} via the GitHub API. +# Apply Repo Settings — syncs the supported top-level sections between +# SETTINGS_FILE and {OWNER}/{REPO} via the GitHub API. +# +# Direction is controlled by MODE: +# apply (default) — read SETTINGS_FILE and apply it to the repo. +# export — read the repo's live state and write it back into +# SETTINGS_FILE (reverse sync, for branch_protection_rule +# triggered auto-capture of UI changes). # # Supported sections: -# repository → PATCH /repos/{owner}/{repo} -# rulesets → POST/PUT/DELETE /repos/{owner}/{repo}/rulesets +# repository → apply: PATCH /repos/{owner}/{repo} +# export: GET /repos/{owner}/{repo} (managed keys only) +# rulesets → apply: POST/PUT /repos/{owner}/{repo}/rulesets +# export: GET /repos/{owner}/{repo}/rulesets # # Not supported (yet): labels, collaborators, teams, environments, branches. -# Those are handled by other workflows in nsheaps/.github today. +# Those are handled by other workflows in nsheaps/.github today. Note that +# classic branch protection (the `branches:` section / the legacy protection +# API) is intentionally NOT modeled — this org uses rulesets, so export +# captures rulesets, not classic protection. set -euo pipefail @@ -16,11 +27,18 @@ set -euo pipefail : "${REPO:?REPO required}" : "${SETTINGS_FILE:=.github/settings.yml}" : "${DRY_RUN:=false}" +: "${MODE:=apply}" : "${SECTIONS:=repository,rulesets}" if [[ ! -f "$SETTINGS_FILE" ]]; then - echo "::error file=$SETTINGS_FILE::settings file not found" - exit 1 + if [[ "$MODE" == "export" ]]; then + # Export can bootstrap a settings file from the repo's live state. + mkdir -p "$(dirname "$SETTINGS_FILE")" + printf '# Generated by apply-repo-settings (export mode).\n' > "$SETTINGS_FILE" + else + echo "::error file=$SETTINGS_FILE::settings file not found" + exit 1 + fi fi log() { echo "::group::$*"; } @@ -184,9 +202,126 @@ apply_rulesets() { endlog } +############################################################################### +# export: write the repo's live state back into SETTINGS_FILE. +# +# `yq` here is guaranteed to be mikefarah/yq (the wrapping action.yml installs +# it), so YAML editing + `load()` splicing is available. Splicing whole nodes +# normalizes the touched section (inline comments in that block are dropped) — +# acceptable for an auto-capture that lands as a reviewable PR diff. +############################################################################### +# repository: pull live config, but only for the keys ALREADY present in the +# file, so export reflects drift on managed keys without introducing keys the +# repo deliberately omits. +export_repository() { + log "repository (export)" + local keys + keys="$(yq -o=json -I=0 '.repository // {} | keys' "$SETTINGS_FILE" 2>/dev/null || echo '[]')" + if [[ -z "$keys" || "$keys" == "[]" || "$keys" == "null" ]]; then + info "no repository block in file, nothing to export" + endlog + return + fi + + local live new + live="$(api GET "/repos/${OWNER}/${REPO}")" + # Project live config to just the keys the file already manages. + new="$(jq -c --argjson keys "$keys" \ + '. as $r | reduce $keys[] as $k ({}; . + {($k): $r[$k]})' <<<"$live")" + + if [[ "$DRY_RUN" == "true" ]]; then + echo "$new" | jq '.' + endlog + return + fi + + local tmp; tmp="$(mktemp --suffix=.yml)" + echo "$new" | yq -P '{"repository": .}' > "$tmp" + # Deep-merge over the existing block so only the managed keys are touched. + yq -i ".repository = (.repository // {}) * load(\"$tmp\").repository" "$SETTINGS_FILE" + rm -f "$tmp" + info "wrote repository block" + endlog +} + +# rulesets: fetch every live ruleset, normalize to the same shape the file +# uses, and replace `.rulesets` wholesale. +export_rulesets() { + log "rulesets (export)" + + local list ids + list="$(gh api --paginate "/repos/${OWNER}/${REPO}/rulesets" --jq '[.[] | {name, id}]')" + info "found $(echo "$list" | jq 'length') live ruleset(s)" + ids="$(echo "$list" | jq -r '.[].id')" + + local arr='[]' id full norm + for id in $ids; do + full="$(gh api "/repos/${OWNER}/${REPO}/rulesets/${id}")" + # Match the file schema: name, target, enforcement, conditions, + # bypass_actors[{actor_id,actor_type,bypass_mode}], rules[{type,parameters?}]. + norm="$(echo "$full" | jq '{ + name, + target, + enforcement, + conditions, + bypass_actors: [ (.bypass_actors // [])[] | {actor_id, actor_type, bypass_mode} ], + rules: [ (.rules // [])[] | {type} + (if has("parameters") and .parameters != null then {parameters} else {} end) ] + }')" + arr="$(jq -c --argjson item "$norm" '. + [$item]' <<<"$arr")" + done + + if [[ "$DRY_RUN" == "true" ]]; then + echo "$arr" | jq '.' + endlog + return + fi + + local tmp; tmp="$(mktemp --suffix=.yml)" + echo "$arr" | yq -P '{"rulesets": .}' > "$tmp" + yq -i ".rulesets = load(\"$tmp\").rulesets" "$SETTINGS_FILE" + rm -f "$tmp" + info "wrote $(echo "$arr" | jq 'length') ruleset(s)" + endlog +} + +run_export() { + echo "Exporting $OWNER/$REPO live state into $SETTINGS_FILE (dry-run=$DRY_RUN, sections=$SECTIONS)" + + local before after + before="$(sha256sum "$SETTINGS_FILE" | cut -d' ' -f1)" + + if want_section "repository"; then export_repository; fi + if want_section "rulesets"; then export_rulesets; fi + + after="$(sha256sum "$SETTINGS_FILE" | cut -d' ' -f1)" + if [[ "$before" == "$after" ]]; then CHANGED="false"; else CHANGED="true"; fi + + summary="$(jq -nc --arg changed "$CHANGED" --arg sections "$SECTIONS" \ + '{mode: "export", changed: $changed, sections: ($sections | split(","))}')" + echo "Summary: $summary" + { + echo "summary=$summary" + echo "changed=$CHANGED" + } >> "$GITHUB_OUTPUT" + + { + echo "## Apply Repo Settings (export) — \`$OWNER/$REPO\`" + echo + echo "- **changed**: \`$CHANGED\`" + echo "- **sections**: \`$SECTIONS\`" + echo + echo "Target: \`$SETTINGS_FILE\` · Dry run: \`$DRY_RUN\`" + } >> "$GITHUB_STEP_SUMMARY" +} + ############################################################################### # main ############################################################################### +if [[ "$MODE" == "export" ]]; then + run_export + exit 0 +fi + CREATED=() UPDATED=() UNCHANGED=() diff --git a/.github/actions/apply-repo-settings/action.yml b/.github/actions/apply-repo-settings/action.yml index 1abe130..c550d8f 100644 --- a/.github/actions/apply-repo-settings/action.yml +++ b/.github/actions/apply-repo-settings/action.yml @@ -30,16 +30,31 @@ inputs: required: false default: 'false' + mode: + description: | + Direction of the sync: + apply — read settings-file and apply it to the repo (default). + export — read the repo's live config/rulesets from the API and write + them back into settings-file (reverse sync). Pair this with a + `branch_protection_rule`-triggered workflow to capture manual + changes (made in the GitHub UI) back into source control. + required: false + default: 'apply' + sections: - description: 'Comma-separated list of top-level keys to apply. Defaults to "repository,rulesets". Add others (labels, collaborators, teams) as we extend support.' + description: 'Comma-separated list of top-level keys to sync. Defaults to "repository,rulesets". Add others (labels, collaborators, teams) as we extend support.' required: false default: 'repository,rulesets' outputs: summary: - description: 'JSON summary of changes applied. Keys: repository, rulesets_created, rulesets_updated, rulesets_unchanged.' + description: 'JSON summary of changes. In apply mode keys are: repository, rulesets_created, rulesets_updated, rulesets_unchanged. In export mode: mode, changed, sections.' value: ${{ steps.apply.outputs.summary }} + changed: + description: 'In export mode: "true" if settings-file was modified, else "false". Empty in apply mode.' + value: ${{ steps.apply.outputs.changed }} + runs: using: 'composite' steps: @@ -93,5 +108,6 @@ runs: REPO: ${{ inputs.repo }} SETTINGS_FILE: ${{ inputs.settings-file }} DRY_RUN: ${{ inputs.dry-run }} + MODE: ${{ inputs.mode }} SECTIONS: ${{ inputs.sections }} run: ${{ github.action_path }}/action.sh From 22c7fb6e6f2194094677fa99c82c986537e372ed Mon Sep 17 00:00:00 2001 From: nsheaps <1282393+nsheaps@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:14:56 +0000 Subject: [PATCH 2/9] chore: `mise format` Triggered by: 0ea8fc71f8fb0dc6b04d42c3d0d8f2fb8c206a0d Workflow run: https://github.com/nsheaps/github-actions/actions/runs/27160984206 --- .github/actions/apply-repo-settings/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md index 3147744..3e550a4 100644 --- a/.github/actions/apply-repo-settings/README.md +++ b/.github/actions/apply-repo-settings/README.md @@ -18,7 +18,7 @@ The upstream app is a Probot webhook server — it's designed to listen for `pus | `owner` | no | current owner | Target repo owner. | | `repo` | no | current repo | Target repo name. | | `settings-file` | no | `.github/settings.yml` | Path to the YAML to apply (or write, in export mode). | -| `dry-run` | no | `false` | Print what would change without applying / writing. | +| `dry-run` | no | `false` | Print what would change without applying / writing. | | `mode` | no | `apply` | `apply` (file → repo) or `export` (repo → file, reverse sync). | | `sections` | no | `repository,rulesets` | Comma-separated section names to sync. | From 485e3fc7ee6f9da19dc4a021286e55e12c1b3773 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 19:31:29 +0000 Subject: [PATCH 3/9] refactor(apply-repo-settings): drop branch_protection_rule from export docs Remove branch_protection_rule references from the action comments and README. The reverse-sync export is now driven by a workflow-file push (preview in the same branch) plus a weekly schedule, not the branch_protection_rule event. The action itself is event-agnostic; this is a docs/comment update only. --- .github/actions/apply-repo-settings/README.md | 22 ++++++++++++------- .github/actions/apply-repo-settings/action.sh | 5 +++-- .../actions/apply-repo-settings/action.yml | 6 ++--- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md index 3e550a4..9a88e16 100644 --- a/.github/actions/apply-repo-settings/README.md +++ b/.github/actions/apply-repo-settings/README.md @@ -36,16 +36,18 @@ The action does **not** do any templating or placeholder substitution on `settin - **`rulesets`** → lists `/repos/{owner}/{repo}/rulesets`, fetches each, normalizes to the file's shape (`name`, `target`, `enforcement`, `conditions`, `bypass_actors`, `rules`), and replaces the `.rulesets` array wholesale. - **`repository`** → reads `/repos/{owner}/{repo}` and updates **only the keys already present** in the file's `.repository` block (so drift on managed keys is captured without introducing keys the repo deliberately omits). -The rest of the file (other top-level keys, the repository keys you don't manage) is preserved. The touched section is normalized, so inline comments inside `.rulesets` are dropped — fine for an auto-capture that lands as a reviewable PR diff. Classic branch protection (the legacy `branches:` protection API) is **not** exported — this org models protection as rulesets. +The rest of the file (other top-level keys, the repository keys you don't manage) is preserved. The touched section is normalized, so inline comments inside `.rulesets` are dropped — fine for an auto-capture that lands as a reviewable commit/diff. Classic branch protection (the legacy `branches:` protection API) is **not** exported — this org models protection as rulesets. -### Auto-capturing UI changes +### Auto-capturing live drift -Pair export with the `branch_protection_rule` workflow trigger so any protection change re-exports the rulesets and opens a PR: +Run export from a workflow, then commit whatever it captured. The canonical wiring triggers export on a push that changes the workflow file (so the captured `settings.yml` lands in the same branch/PR as a preview) and on a weekly schedule (to catch UI ruleset edits that fire no push): ```yaml on: - branch_protection_rule: - types: [created, edited, deleted] + push: + paths: ['.github/workflows/apply-repo-settings.yaml'] + schedule: + - cron: '0 0 * * 0' jobs: export: @@ -62,12 +64,16 @@ jobs: token: ${{ steps.checkout.outputs.token }} mode: export sections: rulesets - # then: if steps.export.outputs.changed == 'true', commit + open-pr-if-needed + - if: steps.export.outputs.changed == 'true' + run: | + git add .github/settings.yml + git commit -m "chore: sync rulesets into settings.yml" + git push origin "HEAD:${{ github.ref_name }}" ``` -> There is no `repository_ruleset` Actions trigger, so `branch_protection_rule` (classic protection) is the available hook; the export re-reads rulesets on any such event. For ruleset-edit-driven exports, dispatch this action from an org-level ruleset webhook via `repository_dispatch`. +> There is no `repository_ruleset` Actions trigger, so pure UI ruleset edits (with no corresponding push) are caught by the weekly schedule. For immediate ruleset-edit-driven exports, dispatch this action from an org-level ruleset webhook via `repository_dispatch`. -The full wired-up workflow (commit + PR) lives at [`nsheaps/.github`'s `apply-repo-settings.yaml` template](https://github.com/nsheaps/.github/blob/main/ansible/templates/.github/workflows/apply-repo-settings.yaml). +The full wired-up workflow (push/schedule gating + commit-back) lives at [`nsheaps/.github`'s `apply-repo-settings.yaml` template](https://github.com/nsheaps/.github/blob/main/ansible/templates/.github/workflows/apply-repo-settings.yaml). ## Setting up the GitHub App diff --git a/.github/actions/apply-repo-settings/action.sh b/.github/actions/apply-repo-settings/action.sh index 52076ba..53f64d0 100755 --- a/.github/actions/apply-repo-settings/action.sh +++ b/.github/actions/apply-repo-settings/action.sh @@ -5,8 +5,9 @@ # Direction is controlled by MODE: # apply (default) — read SETTINGS_FILE and apply it to the repo. # export — read the repo's live state and write it back into -# SETTINGS_FILE (reverse sync, for branch_protection_rule -# triggered auto-capture of UI changes). +# SETTINGS_FILE (reverse sync). A calling workflow can +# then commit the result, e.g. to capture live drift back +# into source control. # # Supported sections: # repository → apply: PATCH /repos/{owner}/{repo} diff --git a/.github/actions/apply-repo-settings/action.yml b/.github/actions/apply-repo-settings/action.yml index c550d8f..90a9d3f 100644 --- a/.github/actions/apply-repo-settings/action.yml +++ b/.github/actions/apply-repo-settings/action.yml @@ -35,9 +35,9 @@ inputs: Direction of the sync: apply — read settings-file and apply it to the repo (default). export — read the repo's live config/rulesets from the API and write - them back into settings-file (reverse sync). Pair this with a - `branch_protection_rule`-triggered workflow to capture manual - changes (made in the GitHub UI) back into source control. + them back into settings-file (reverse sync). A calling + workflow can then commit the result to capture changes made + in the GitHub UI back into source control. required: false default: 'apply' From 0ae144a96a432f13b002918c48253359d612b2be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 19:40:35 +0000 Subject: [PATCH 4/9] docs(skills): add testing-github-actions skill for cross-repo action testing Documents pinning a consumer workflow at the action's branch/SHA to prove changes work before merge, and the false-positive trap where GitHub silently ignores undefined inputs (so a consumer pinned to @main goes green without running new code). Captures the real apply-repo-settings mode:export case. --- .../skills/testing-github-actions/SKILL.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .claude/skills/testing-github-actions/SKILL.md diff --git a/.claude/skills/testing-github-actions/SKILL.md b/.claude/skills/testing-github-actions/SKILL.md new file mode 100644 index 0000000..64122c6 --- /dev/null +++ b/.claude/skills/testing-github-actions/SKILL.md @@ -0,0 +1,142 @@ +--- +name: testing-github-actions +description: > + Use this skill when developing or changing a composite action in this repo + (nsheaps/github-actions) and you need to PROVE it works end-to-end before + merging — especially when a new input/output or behavior was added. Covers + pinning a consumer workflow (e.g. in nsheaps/.github) at the action's feature + branch or commit SHA, triggering the run, and verifying the new code actually + executed. Recall it whenever a consumer workflow "passed" but you're not sure + it exercised your changes, or when you see/expect an "Unexpected input(s)" + warning. Triggers: "test the action", "prove the action works", "why did the + workflow pass", "test before merge", "pin action to branch/SHA". +--- + +# Testing GitHub Actions cross-repo + +Actions in this repo are referenced by **other repos** (e.g. `nsheaps/.github`, +`nsheaps/.org`) as `uses: nsheaps/github-actions/.github/actions/@main`. +That `@main` is the crux of testing: a consumer workflow pinned to `@main` runs +the action **as it exists on `main` right now** — i.e. the _pre-merge_ version, +not the code on your branch. Pushing changes to a feature branch does nothing +to what `@main` consumers see. + +To test action changes end-to-end you must point a consumer at your branch/SHA. + +## The trap: a green check does NOT mean your code ran + +GitHub Actions **silently ignores undefined inputs** to an action. If you pass +an input the resolved action version doesn't declare, you get a non-fatal +annotation: + +``` +Warning: Unexpected input(s) 'mode', valid inputs are ['token', 'owner', ...] +``` + +The step still runs and **succeeds** — using only the inputs the action does +declare. So if you add a `mode: export` input on a branch, then run a consumer +pinned to `@main` (which has no `mode` yet), the job goes green while running +the _old_ code path and ignoring `mode` entirely. The green check is a false +positive — it proves nothing about your change. + +> Real example from this repo: `.github`'s `apply-repo-settings.yaml` passed +> `mode: export` to `apply-repo-settings@main` before the `mode` input was +> merged. The export job "passed" — but `@main` had no `mode` input, so it +> silently ran the old **apply** path. Nothing exported. + +**Rule of thumb:** if you changed an action's `inputs`/`outputs`/behavior, a +consumer pinned to `@main` cannot validate it. Pin to your ref first. + +## How to test against your branch + +1. **Push your action changes** to its feature branch in this repo + (`nsheaps/github-actions`). Note the branch name or the commit SHA. + +2. **Point a consumer workflow at that ref.** In the consuming repo (often + `nsheaps/.github`), edit every `uses:` line for the action under test: + + ```yaml + # from: + - uses: nsheaps/github-actions/.github/actions/apply-repo-settings@main + # to (branch): + - uses: nsheaps/github-actions/.github/actions/apply-repo-settings@claude/zealous-heisenberg-wfaxhk + # or (immutable, preferred for a definitive proof): + - uses: nsheaps/github-actions/.github/actions/apply-repo-settings@ + ``` + + A branch ref re-resolves on every run (good while iterating). A SHA is + immutable (good for a final, reproducible proof). Reusable workflows + (`uses: org/repo/.github/workflows/x.yaml@ref`) pin the same way. + +3. **Trigger the consumer.** Use whatever the workflow listens for — push to a + branch, `workflow_dispatch`, or `repository_dispatch`. Prefer a non-default + branch / dry-run input so a test run can't mutate production state. + +4. **Watch the run and read the logs** (see "Observing runs" below). Don't + trust the green ✓ alone — open the step and confirm the new behavior. + +5. **Verify your inputs were actually accepted** — the key check: + - **No `Unexpected input(s)` warning** for your new inputs → the resolved + action version declares them → your version ran. + - Look for a log line that only your new code path emits (add one if + needed, e.g. `echo "mode=$MODE"`), and confirm the expected branch ran. + - Check the step's `outputs` if your change added one. + +6. **Revert the pin before merging the consumer.** Change every `uses:` back + to `@main` (or the next released tag). Shipping a consumer pinned to a + feature branch is a landmine — the branch gets deleted and the consumer + breaks. Make reverting part of the consumer PR's final commit. + +## Order of operations across repos + +Because consumers reference `@main`, the action change must land first: + +1. Test the action on its branch via a pinned consumer (steps above). +2. Merge the **action** PR (`nsheaps/github-actions`) to `main`. +3. Re-point consumers to `@main`, confirm green, merge the **consumer** PRs + (`nsheaps/.github`, which then syncs to managed repos including `.org`). + +Never merge a consumer that depends on unreleased action behavior while it's +still pinned to `@main` — it'll run stale code in production. + +## Observing runs + +Authenticated access is needed to read run logs. With `gh` + a token +(`GH_TOKEN`), from any repo: + +```bash +# latest runs of a workflow +gh run list --repo nsheaps/.github --workflow apply-repo-settings.yaml --limit 5 +# watch a run to completion +gh run watch --repo nsheaps/.github +# full logs (grep for your markers / warnings) +gh run view --repo nsheaps/.github --log | grep -iE 'unexpected input|mode=|export' +``` + +In web sessions the git remote is a local proxy — use `gh api --hostname +github.com ...` or the GitHub MCP tools instead of `gh run` if `gh` isn't +configured for github.com. If no token is available, fall back to the run's +**Actions** page in the GitHub UI and read the step logs there. + +## Quick checklist + +- [ ] Action change pushed to its branch; SHA/branch noted. +- [ ] Consumer's `uses:` repinned from `@main` to the branch/SHA (every occurrence). +- [ ] Run triggered on a safe branch / with dry-run where possible. +- [ ] Logs show **no** `Unexpected input(s)` warning for new inputs. +- [ ] A log line / output unique to the new code path is present. +- [ ] Consumer repinned back to `@main` before its PR merges. +- [ ] Action PR merged before consumer PR. + +## Gotchas + +- **Undefined inputs never fail** — only warn. Absence of the warning is your + signal the right version ran; presence means you're hitting a stale ref. +- **Composite actions are fetched at the pinned ref**, including their own + internal `uses:` and scripts. Editing a script on your branch only takes + effect for consumers pinned to that branch. +- **`@main` is a moving target** — a consumer test that passed yesterday can + break when `main` moves. Pin to a SHA for a stable proof. +- **Don't dry-run-only and call it done** — `dry-run: true` proves the action + parses inputs and reaches its logic, but not that the real API + calls/side-effects work. Do at least one non-dry-run on a throwaway target. From 6bc8b4370f0678bc573b0dd4279e6fda8ec7231b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 17:23:56 +0000 Subject: [PATCH 5/9] feat(apply-repo-settings): add labels, collaborators, teams sections Add apply + export support for labels, collaborators, and teams alongside repository/rulesets. Export captures ALL repo labels and direct collaborators/ teams into settings.yml; apply creates/updates them. Non-destructive for every section (never deletes labels/rulesets or revokes access) with TODO(prune) markers to add an opt-in authoritative mode later. Default sections now include all five. Labels section replaces the github-label-sync flow. --- .github/actions/apply-repo-settings/README.md | 39 ++-- .github/actions/apply-repo-settings/action.sh | 214 ++++++++++++++++-- .../actions/apply-repo-settings/action.yml | 6 +- 3 files changed, 221 insertions(+), 38 deletions(-) diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md index 9a88e16..1b2aea2 100644 --- a/.github/actions/apply-repo-settings/README.md +++ b/.github/actions/apply-repo-settings/README.md @@ -4,29 +4,31 @@ An ephemeral, in-workflow alternative to the [repository-settings GitHub App](ht Reads `.github/settings.yml` from the current repo and applies the supported sections to the target repo via the GitHub API, using a GitHub App token (so the action runs only when invoked — no always-on bot, no third-party service). -It also runs in reverse: with `mode: export` it reads the repo's **live** config/rulesets and writes them back into `settings.yml`, so manual changes made in the GitHub UI can be captured back into source control (see [Reverse sync](#reverse-sync-mode-export) below). +It also runs in reverse: with `mode: export` it reads the repo's **live** state and writes it back into `settings.yml`, so manual changes made in the GitHub UI can be captured back into source control (see [Reverse sync](#reverse-sync-mode-export) below). + +Supported sections: `repository`, `rulesets`, `labels`, `collaborators`, `teams`. **Apply is non-destructive for every section** — it creates/updates what's listed but never deletes rulesets/labels or revokes collaborator/team access that exists on the repo but isn't in `settings.yml`. (A future opt-in prune mode will let `settings.yml` be authoritative per section.) Not modeled: `environments` and classic branch protection (`branches:`) — this org uses rulesets. ## Why not self-host the upstream app? -The upstream app is a Probot webhook server — it's designed to listen for `push` events on a long-running process. Adapting it for ephemeral one-shot runs is more invasive than building a minimal applier from scratch. This action covers the two sections we actually use (`repository:` config and `rulesets:`) in ~170 lines of bash + `gh api`. Other sections (`labels`, `collaborators`, `teams`, `environments`, legacy `branches`) are not yet implemented — labels are managed via a separate sync today. +The upstream app is a Probot webhook server — it's designed to listen for `push` events on a long-running process. Adapting it for ephemeral one-shot runs is more invasive than building a minimal applier from scratch. This action covers the sections we use in bash + `gh api`. The `labels` section **replaces** the previous `github-label-sync` flow — org-standard labels now live in the org settings template and flow through the same per-repo `settings.yml` pipeline as everything else. ## Inputs -| Input | Required | Default | Description | -| --------------- | -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `token` | yes | — | GitHub token with `Administration: write` on the target repo (typically from `checkout-as-app` or `github-app-auth`). | -| `owner` | no | current owner | Target repo owner. | -| `repo` | no | current repo | Target repo name. | -| `settings-file` | no | `.github/settings.yml` | Path to the YAML to apply (or write, in export mode). | -| `dry-run` | no | `false` | Print what would change without applying / writing. | -| `mode` | no | `apply` | `apply` (file → repo) or `export` (repo → file, reverse sync). | -| `sections` | no | `repository,rulesets` | Comma-separated section names to sync. | +| Input | Required | Default | Description | +| --------------- | -------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `token` | yes | — | GitHub token with `Administration: write` on the target repo (typically from `checkout-as-app` or `github-app-auth`). | +| `owner` | no | current owner | Target repo owner. | +| `repo` | no | current repo | Target repo name. | +| `settings-file` | no | `.github/settings.yml` | Path to the YAML to apply (or write, in export mode). | +| `dry-run` | no | `false` | Print what would change without applying / writing. | +| `mode` | no | `apply` | `apply` (file → repo) or `export` (repo → file, reverse sync). | +| `sections` | no | `repository,rulesets,labels,collaborators,teams` | Comma-separated section names to sync. | The action does **not** do any templating or placeholder substitution on `settings-file` — it applies the YAML as-is. If you need env-var-based substitution (e.g. resolving a GitHub App ID into `bypass_actors[].actor_id`), render the file upstream (e.g. with `envsubst`) before invoking this action. ## Outputs -- `summary` — JSON object. In `apply` mode: `{ repository, rulesets_created, rulesets_updated, rulesets_unchanged }`. In `export` mode: `{ mode, changed, sections }`. +- `summary` — JSON object. In `apply` mode: `{ repository, rulesets_created, rulesets_updated, rulesets_unchanged, labels_applied, collaborators_applied, teams_applied }`. In `export` mode: `{ mode, changed, sections }`. - `changed` — export mode only: `"true"` if `settings-file` was modified, else `"false"`. Empty in apply mode. ## Reverse sync (`mode: export`) @@ -35,8 +37,11 @@ The action does **not** do any templating or placeholder substitution on `settin - **`rulesets`** → lists `/repos/{owner}/{repo}/rulesets`, fetches each, normalizes to the file's shape (`name`, `target`, `enforcement`, `conditions`, `bypass_actors`, `rules`), and replaces the `.rulesets` array wholesale. - **`repository`** → reads `/repos/{owner}/{repo}` and updates **only the keys already present** in the file's `.repository` block (so drift on managed keys is captured without introducing keys the repo deliberately omits). +- **`labels`** → reads **every** label on the repo (`/repos/{owner}/{repo}/labels`) and replaces `.labels` with `{ name, color, description }` entries. +- **`collaborators`** → reads **direct** collaborators (`?affiliation=direct`; org-inherited access is left alone) and writes `{ username, permission }`, normalizing `role_name` to a PUT-compatible permission (`read`→`pull`, `write`→`push`). +- **`teams`** → reads teams with repo access and writes `{ name, permission }` where `name` is the team slug (the merger's identity key for teams). -The rest of the file (other top-level keys, the repository keys you don't manage) is preserved. The touched section is normalized, so inline comments inside `.rulesets` are dropped — fine for an auto-capture that lands as a reviewable commit/diff. Classic branch protection (the legacy `branches:` protection API) is **not** exported — this org models protection as rulesets. +The rest of the file (other top-level keys, the repository keys you don't manage) is preserved. Each touched section is normalized, so inline comments inside it are dropped — fine for an auto-capture that lands as a reviewable commit/diff. Classic branch protection (the legacy `branches:` protection API) and `environments` are **not** exported. ### Auto-capturing live drift @@ -135,11 +140,15 @@ jobs: - if no ruleset with that name exists → `POST /repos/{owner}/{repo}/rulesets` - if one exists and content matches → no-op (logged as `unchanged`) - if one exists and content differs → `PUT /repos/{owner}/{repo}/rulesets/{id}` +- **`labels:` list** → `POST` to create, `PATCH /repos/{owner}/{repo}/labels/{name}` to update color/description. +- **`collaborators:` list** → `PUT /repos/{owner}/{repo}/collaborators/{username}` with `{ permission }`. +- **`teams:` list** → `PUT /orgs/{owner}/teams/{slug}/repos/{owner}/{repo}` with `{ permission }`. -Rulesets that exist on the repo but are absent from the YAML are **not deleted** (safer default — repos may have UI-created rulesets we don't want to wipe). If you want destructive sync, add a `--prune` mode in a follow-up. +**Nothing is ever deleted or revoked.** Rulesets, labels, collaborators, and teams that exist on the repo but are absent from the YAML are left untouched (safer default — repos may have UI-created rulesets/labels, and we never want to silently revoke access). A future opt-in prune mode (tracked by `TODO(prune)` markers in `action.sh`) will make `settings.yml` authoritative per section once export is proven. ## Limitations - The upstream repository-settings app reads from the default branch only. This action reads from the workflow's checkout, so it works on any branch — useful for testing changes in a PR before merging. -- No support yet for `labels`, `collaborators`, `teams`, `environments`. Those are tracked separately. +- `environments` and classic branch protection (`branches:`) are not supported. +- `collaborators`/`teams` apply needs the App to have `Administration: write` (and org `Members` for teams). Export only reads. - `bypass_actors[].actor_id` for `RepositoryRole` must be the role's numeric ID (community-documented: 1=read, 2=triage, 3=write, 4=maintain, 5=admin). Custom roles have user-assigned IDs. diff --git a/.github/actions/apply-repo-settings/action.sh b/.github/actions/apply-repo-settings/action.sh index 53f64d0..88b4e3a 100755 --- a/.github/actions/apply-repo-settings/action.sh +++ b/.github/actions/apply-repo-settings/action.sh @@ -10,16 +10,28 @@ # into source control. # # Supported sections: -# repository → apply: PATCH /repos/{owner}/{repo} -# export: GET /repos/{owner}/{repo} (managed keys only) -# rulesets → apply: POST/PUT /repos/{owner}/{repo}/rulesets -# export: GET /repos/{owner}/{repo}/rulesets +# repository → apply: PATCH /repos/{owner}/{repo} +# export: GET /repos/{owner}/{repo} (managed keys only) +# rulesets → apply: POST/PUT /repos/{owner}/{repo}/rulesets +# export: GET /repos/{owner}/{repo}/rulesets +# labels → apply: POST/PATCH /repos/{owner}/{repo}/labels +# export: GET /repos/{owner}/{repo}/labels (ALL labels) +# collaborators → apply: PUT /repos/{owner}/{repo}/collaborators/{user} +# export: GET /repos/{owner}/{repo}/collaborators?affiliation=direct +# teams → apply: PUT /orgs/{org}/teams/{slug}/repos/{owner}/{repo} +# export: GET /repos/{owner}/{repo}/teams # -# Not supported (yet): labels, collaborators, teams, environments, branches. -# Those are handled by other workflows in nsheaps/.github today. Note that -# classic branch protection (the `branches:` section / the legacy protection -# API) is intentionally NOT modeled — this org uses rulesets, so export -# captures rulesets, not classic protection. +# PRUNE BEHAVIOR: apply is currently **non-destructive for every section** — it +# creates/updates what's in SETTINGS_FILE but never deletes rulesets/labels or +# revokes collaborators/teams that exist on the repo but aren't listed. This +# matches the github-label-sync (--allow-added-labels) behavior the labels +# section replaces, and avoids auto-revoking access. TODO(prune): once export +# is proven to capture full live state reliably, add an opt-in prune mode so +# SETTINGS_FILE can be made authoritative per-section. +# +# Not modeled: environments, and classic branch protection (the `branches:` +# section / legacy protection API) — this org uses rulesets, so export captures +# rulesets, not classic protection. set -euo pipefail @@ -29,7 +41,7 @@ set -euo pipefail : "${SETTINGS_FILE:=.github/settings.yml}" : "${DRY_RUN:=false}" : "${MODE:=apply}" -: "${SECTIONS:=repository,rulesets}" +: "${SECTIONS:=repository,rulesets,labels,collaborators,teams}" if [[ ! -f "$SETTINGS_FILE" ]]; then if [[ "$MODE" == "export" ]]; then @@ -117,6 +129,14 @@ want_section() { [[ ",$SECTIONS," == *",$target,"* ]] } +# URL-encode a path segment (label names can contain spaces, etc.). +_uri() { jq -rn --arg s "$1" '$s|@uri'; } + +# Normalize a GitHub permission/role_name to a PUT-compatible value +# (the collaborators/teams PUT APIs want pull/push/admin/maintain/triage; +# the read endpoints report read/write for the first two). +_norm_perm() { jq -rn --arg p "$1" '{"read":"pull","write":"push"}[$p] // $p'; } + ############################################################################### # repository: PATCH /repos/{owner}/{repo} ############################################################################### @@ -203,6 +223,99 @@ apply_rulesets() { endlog } +############################################################################### +# labels: create/update by name. Non-destructive — extra repo labels are kept +# (replaces github-label-sync --allow-added-labels). TODO(prune): opt-in delete. +############################################################################### +apply_labels() { + log "labels" + local count + count="$(yq '.labels | length // 0' "$SETTINGS_FILE")" + if [[ "$count" == "0" || "$count" == "null" ]]; then + info "no labels block, skipping"; endlog; return + fi + + local existing + existing="$(gh api --paginate "/repos/${OWNER}/${REPO}/labels" --jq '[.[] | {name, color, description}]')" + info "found $(echo "$existing" | jq 'length') existing label(s)" + + local i + for ((i=0; i/dev/null + else + info "update label: $name" + [[ "$DRY_RUN" == "true" ]] || \ + api PATCH "/repos/${OWNER}/${REPO}/labels/${name_enc}" "$body" >/dev/null + fi + LABELS_APPLIED+=("$name") + done + endlog +} + +############################################################################### +# collaborators: PUT each user's permission. Non-destructive — never removes +# collaborators absent from the file (no silent access revocation). +# TODO(prune): opt-in removal once export is proven. +############################################################################### +apply_collaborators() { + log "collaborators" + local count + count="$(yq '.collaborators | length // 0' "$SETTINGS_FILE")" + if [[ "$count" == "0" || "$count" == "null" ]]; then + info "no collaborators block, skipping"; endlog; return + fi + + local i + for ((i=0; i/dev/null + COLLAB_APPLIED+=("$user") + done + endlog +} + +############################################################################### +# teams: PUT each team's repo permission (org endpoint). Non-destructive. +# TODO(prune): opt-in removal once export is proven. +############################################################################### +apply_teams() { + log "teams" + local count + count="$(yq '.teams | length // 0' "$SETTINGS_FILE")" + if [[ "$count" == "0" || "$count" == "null" ]]; then + info "no teams block, skipping"; endlog; return + fi + + local i + for ((i=0; i/dev/null + TEAMS_APPLIED+=("$slug") + done + endlog +} + ############################################################################### # export: write the repo's live state back into SETTINGS_FILE. # @@ -285,14 +398,68 @@ export_rulesets() { endlog } +# labels: write EVERY label on the repo into the file (replaces `.labels`). +_export_section_array() { + # _export_section_array KEY JSON-ARRAY — splice a JSON array under top-level KEY. + local key="$1" arr="$2" tmp + tmp="$(mktemp --suffix=.yml)" + echo "$arr" | yq -P "{\"$key\": .}" > "$tmp" + yq -i ".$key = load(\"$tmp\").$key" "$SETTINGS_FILE" + rm -f "$tmp" +} + +export_labels() { + log "labels (export)" + local arr + arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/labels" --jq '[.[] | {name, color, description}]')" + info "found $(echo "$arr" | jq 'length') label(s)" + if [[ "$DRY_RUN" == "true" ]]; then echo "$arr" | jq '.'; endlog; return; fi + _export_section_array labels "$arr" + info "wrote $(echo "$arr" | jq 'length') label(s)" + endlog +} + +# collaborators: direct collaborators only (org-inherited access is not ours to +# manage). role_name normalized to a PUT-compatible permission so apply round-trips. +export_collaborators() { + log "collaborators (export)" + local arr + arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/collaborators?affiliation=direct" \ + --jq '[.[] | {username: .login, permission: (.role_name // "push")}]')" + arr="$(echo "$arr" | jq 'map(.permission |= ({"read":"pull","write":"push"}[.] // .))')" + info "found $(echo "$arr" | jq 'length') direct collaborator(s)" + if [[ "$DRY_RUN" == "true" ]]; then echo "$arr" | jq '.'; endlog; return; fi + _export_section_array collaborators "$arr" + info "wrote $(echo "$arr" | jq 'length') collaborator(s)" + endlog +} + +# teams: teams with direct repo access. `name` is the team slug (the merger's +# identity key for teams). permission normalized like collaborators. +export_teams() { + log "teams (export)" + local arr + arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/teams" \ + --jq '[.[] | {name: .slug, permission: (.permission // "push")}]')" + arr="$(echo "$arr" | jq 'map(.permission |= ({"read":"pull","write":"push"}[.] // .))')" + info "found $(echo "$arr" | jq 'length') team(s)" + if [[ "$DRY_RUN" == "true" ]]; then echo "$arr" | jq '.'; endlog; return; fi + _export_section_array teams "$arr" + info "wrote $(echo "$arr" | jq 'length') team(s)" + endlog +} + run_export() { echo "Exporting $OWNER/$REPO live state into $SETTINGS_FILE (dry-run=$DRY_RUN, sections=$SECTIONS)" local before after before="$(sha256sum "$SETTINGS_FILE" | cut -d' ' -f1)" - if want_section "repository"; then export_repository; fi - if want_section "rulesets"; then export_rulesets; fi + if want_section "repository"; then export_repository; fi + if want_section "rulesets"; then export_rulesets; fi + if want_section "labels"; then export_labels; fi + if want_section "collaborators"; then export_collaborators; fi + if want_section "teams"; then export_teams; fi after="$(sha256sum "$SETTINGS_FILE" | cut -d' ' -f1)" if [[ "$before" == "$after" ]]; then CHANGED="false"; else CHANGED="true"; fi @@ -326,17 +493,18 @@ fi CREATED=() UPDATED=() UNCHANGED=() +LABELS_APPLIED=() +COLLAB_APPLIED=() +TEAMS_APPLIED=() REPO_CHANGED="false" echo "Applying $SETTINGS_FILE to $OWNER/$REPO (dry-run=$DRY_RUN, sections=$SECTIONS)" -if want_section "repository"; then - apply_repository -fi - -if want_section "rulesets"; then - apply_rulesets -fi +if want_section "repository"; then apply_repository; fi +if want_section "rulesets"; then apply_rulesets; fi +if want_section "labels"; then apply_labels; fi +if want_section "collaborators"; then apply_collaborators; fi +if want_section "teams"; then apply_teams; fi # Summary summary="$(jq -nc \ @@ -344,7 +512,10 @@ summary="$(jq -nc \ --argjson created "$(printf '%s\n' "${CREATED[@]:-}" | jq -R . | jq -s 'map(select(length>0))')" \ --argjson updated "$(printf '%s\n' "${UPDATED[@]:-}" | jq -R . | jq -s 'map(select(length>0))')" \ --argjson unchanged "$(printf '%s\n' "${UNCHANGED[@]:-}" | jq -R . | jq -s 'map(select(length>0))')" \ - '{repository: $repo_changed, rulesets_created: $created, rulesets_updated: $updated, rulesets_unchanged: $unchanged}' + --argjson labels "${#LABELS_APPLIED[@]}" \ + --argjson collaborators "${#COLLAB_APPLIED[@]}" \ + --argjson teams "${#TEAMS_APPLIED[@]}" \ + '{repository: $repo_changed, rulesets_created: $created, rulesets_updated: $updated, rulesets_unchanged: $unchanged, labels_applied: $labels, collaborators_applied: $collaborators, teams_applied: $teams}' )" echo "Summary: $summary" @@ -358,6 +529,9 @@ echo "summary=$summary" >> "$GITHUB_OUTPUT" echo "- **rulesets created**: ${#CREATED[@]}${CREATED:+: ${CREATED[*]}}" echo "- **rulesets updated**: ${#UPDATED[@]}${UPDATED:+: ${UPDATED[*]}}" echo "- **rulesets unchanged**: ${#UNCHANGED[@]}${UNCHANGED:+: ${UNCHANGED[*]}}" + echo "- **labels applied**: ${#LABELS_APPLIED[@]}" + echo "- **collaborators applied**: ${#COLLAB_APPLIED[@]}" + echo "- **teams applied**: ${#TEAMS_APPLIED[@]}" echo echo "Source: \`$SETTINGS_FILE\` · Dry run: \`$DRY_RUN\` · Sections: \`$SECTIONS\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/apply-repo-settings/action.yml b/.github/actions/apply-repo-settings/action.yml index 90a9d3f..5f4d9d1 100644 --- a/.github/actions/apply-repo-settings/action.yml +++ b/.github/actions/apply-repo-settings/action.yml @@ -42,13 +42,13 @@ inputs: default: 'apply' sections: - description: 'Comma-separated list of top-level keys to sync. Defaults to "repository,rulesets". Add others (labels, collaborators, teams) as we extend support.' + description: 'Comma-separated list of top-level keys to sync. Supported: repository, rulesets, labels, collaborators, teams. Apply is non-destructive (never deletes/revokes) for every section.' required: false - default: 'repository,rulesets' + default: 'repository,rulesets,labels,collaborators,teams' outputs: summary: - description: 'JSON summary of changes. In apply mode keys are: repository, rulesets_created, rulesets_updated, rulesets_unchanged. In export mode: mode, changed, sections.' + description: 'JSON summary of changes. In apply mode keys are: repository, rulesets_created, rulesets_updated, rulesets_unchanged, labels_applied, collaborators_applied, teams_applied. In export mode: mode, changed, sections.' value: ${{ steps.apply.outputs.summary }} changed: From 6bcbb3b5e5403d6b33ee7191184fda8bda69877f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 18:44:51 +0000 Subject: [PATCH 6/9] feat(apply-repo-settings): export deep-merges live state (additive, lossless) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export no longer replaces sections wholesale. It now builds a live-state source (full repository key set; rulesets/labels/collaborators/teams) and deep-merges it INTO settings.yml via merge_live.py (ruamel, comment-preserving): - file wins on existing scalars; live only ADDS missing keys/list-items (e.g. a new bypass actor, collaborator, or ruleset appended to the end) - nothing already in the file is removed — pending settings not yet applied to the repo survive - comments/formatting preserved; file rewritten only on real content change Repository export captures the full settable key set, not just keys already in the file. --- .github/actions/apply-repo-settings/README.md | 23 +- .../__pycache__/merge_live.cpython-311.pyc | Bin 0 -> 7917 bytes .github/actions/apply-repo-settings/action.sh | 230 +++++++----------- .../actions/apply-repo-settings/merge_live.py | 150 ++++++++++++ 4 files changed, 258 insertions(+), 145 deletions(-) create mode 100644 .github/actions/apply-repo-settings/__pycache__/merge_live.cpython-311.pyc create mode 100644 .github/actions/apply-repo-settings/merge_live.py diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md index 1b2aea2..312fb23 100644 --- a/.github/actions/apply-repo-settings/README.md +++ b/.github/actions/apply-repo-settings/README.md @@ -33,15 +33,24 @@ The action does **not** do any templating or placeholder substitution on `settin ## Reverse sync (`mode: export`) -`mode: export` flips the direction: instead of pushing the YAML to the repo, it reads the repo's live state from the API and writes it **back** into `settings-file`, then reports whether the file changed via the `changed` output. This lets a workflow capture changes made directly in the GitHub UI back into source control instead of letting them drift. +`mode: export` flips the direction: instead of pushing the YAML to the repo, it reads the repo's live state from the API and **deep-merges it into** `settings-file`, then reports whether the file changed via the `changed` output. It lets a workflow capture changes made directly in the GitHub UI back into source control instead of letting them drift. -- **`rulesets`** → lists `/repos/{owner}/{repo}/rulesets`, fetches each, normalizes to the file's shape (`name`, `target`, `enforcement`, `conditions`, `bypass_actors`, `rules`), and replaces the `.rulesets` array wholesale. -- **`repository`** → reads `/repos/{owner}/{repo}` and updates **only the keys already present** in the file's `.repository` block (so drift on managed keys is captured without introducing keys the repo deliberately omits). -- **`labels`** → reads **every** label on the repo (`/repos/{owner}/{repo}/labels`) and replaces `.labels` with `{ name, color, description }` entries. -- **`collaborators`** → reads **direct** collaborators (`?affiliation=direct`; org-inherited access is left alone) and writes `{ username, permission }`, normalizing `role_name` to a PUT-compatible permission (`read`→`pull`, `write`→`push`). -- **`teams`** → reads teams with repo access and writes `{ name, permission }` where `name` is the team slug (the merger's identity key for teams). +The merge (`merge_live.py`, ruamel) is **additive and lossless**, so `settings-file` becomes a faithful, growing record of the repo: -The rest of the file (other top-level keys, the repository keys you don't manage) is preserved. Each touched section is normalized, so inline comments inside it are dropped — fine for an auto-capture that lands as a reviewable commit/diff. Classic branch protection (the legacy `branches:` protection API) and `environments` are **not** exported. +- The file **wins on existing scalars** — export never overwrites a value already in the file. +- Live state only **adds what's missing**: new top-level keys, and within identity-matched list items (rulesets by `name`, rules by `type`, `bypass_actors` by `(actor_id, actor_type)`, labels by `name`, collaborators by `username`, teams by `name`) it adds missing sub-keys and list entries (e.g. a newly-added bypass actor or collaborator). +- New list items (e.g. a ruleset created in the UI) are **appended**; **nothing already in the file is removed** — entries not yet applied to the repo survive. +- **Comments and formatting are preserved.** + +Per-section live capture: + +- **`repository`** → the full settable key set (`has_issues`, `allow_*`, `default_branch`, `description`, `homepage`, `topics`, `private`, merge-commit settings, …; nulls dropped). No longer narrowed to keys already in the file. +- **`rulesets`** → every ruleset, normalized to `name`/`target`/`enforcement`/`conditions`/`bypass_actors`/`rules`. +- **`labels`** → every label as `{ name, color, description }`. +- **`collaborators`** → **direct** collaborators (`?affiliation=direct`; org-inherited access left alone) as `{ username, permission }`, normalizing `role_name` (`read`→`pull`, `write`→`push`). +- **`teams`** → teams with repo access as `{ name, permission }` (`name` = team slug). + +Classic branch protection (legacy `branches:`) and `environments` are **not** exported. The file is rewritten only when the merge actually changes content, so an up-to-date file is left untouched. ### Auto-capturing live drift diff --git a/.github/actions/apply-repo-settings/__pycache__/merge_live.cpython-311.pyc b/.github/actions/apply-repo-settings/__pycache__/merge_live.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ee184333e19e86eafd7d1d8c03807e477114d7a GIT binary patch literal 7917 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 Si+p#U>n^i|+XJJNng0)n2S`x> literal 0 HcmV?d00001 diff --git a/.github/actions/apply-repo-settings/action.sh b/.github/actions/apply-repo-settings/action.sh index 88b4e3a..a6822ce 100755 --- a/.github/actions/apply-repo-settings/action.sh +++ b/.github/actions/apply-repo-settings/action.sh @@ -4,16 +4,18 @@ # # Direction is controlled by MODE: # apply (default) — read SETTINGS_FILE and apply it to the repo. -# export — read the repo's live state and write it back into -# SETTINGS_FILE (reverse sync). A calling workflow can -# then commit the result, e.g. to capture live drift back -# into source control. +# export — read the repo's live state and deep-merge it INTO +# SETTINGS_FILE (reverse sync). The merge is additive and +# lossless (see merge_live.py): the file wins on existing +# scalars, live only ADDS missing keys/list-items, comments +# are preserved, and nothing already in the file is removed. +# A calling workflow can then commit the result. # # Supported sections: # repository → apply: PATCH /repos/{owner}/{repo} -# export: GET /repos/{owner}/{repo} (managed keys only) +# export: GET /repos/{owner}/{repo} (full settings key set) # rulesets → apply: POST/PUT /repos/{owner}/{repo}/rulesets -# export: GET /repos/{owner}/{repo}/rulesets +# export: GET /repos/{owner}/{repo}/rulesets (deep-merged) # labels → apply: POST/PATCH /repos/{owner}/{repo}/labels # export: GET /repos/{owner}/{repo}/labels (ALL labels) # collaborators → apply: PUT /repos/{owner}/{repo}/collaborators/{user} @@ -21,12 +23,13 @@ # teams → apply: PUT /orgs/{org}/teams/{slug}/repos/{owner}/{repo} # export: GET /repos/{owner}/{repo}/teams # -# PRUNE BEHAVIOR: apply is currently **non-destructive for every section** — it -# creates/updates what's in SETTINGS_FILE but never deletes rulesets/labels or -# revokes collaborators/teams that exist on the repo but aren't listed. This +# PRUNE BEHAVIOR: BOTH directions are **non-destructive**. apply creates/updates +# what's in SETTINGS_FILE but never deletes rulesets/labels or revokes +# collaborators/teams absent from it; export only ADDS to SETTINGS_FILE and +# never removes entries (so pending settings not yet on the repo survive). This # matches the github-label-sync (--allow-added-labels) behavior the labels -# section replaces, and avoids auto-revoking access. TODO(prune): once export -# is proven to capture full live state reliably, add an opt-in prune mode so +# section replaces, and avoids auto-revoking access. TODO(prune): once export is +# proven to capture full live state reliably, add an opt-in prune mode so # SETTINGS_FILE can be made authoritative per-section. # # Not modeled: environments, and classic branch protection (the `branches:` @@ -317,152 +320,103 @@ apply_teams() { } ############################################################################### -# export: write the repo's live state back into SETTINGS_FILE. +# export: merge the repo's LIVE state INTO SETTINGS_FILE (reverse sync). # -# `yq` here is guaranteed to be mikefarah/yq (the wrapping action.yml installs -# it), so YAML editing + `load()` splicing is available. Splicing whole nodes -# normalizes the touched section (inline comments in that block are dropped) — -# acceptable for an auto-capture that lands as a reviewable PR diff. +# Builds a "source" JSON of the live repo state (in the settings.yml schema) for +# the requested sections, then deep-merges it into the existing file via +# merge_live.py (ruamel, comment-preserving). The merge is ADDITIVE and lossless: +# the file wins on existing scalars, live only ADDS missing keys/list-items, and +# nothing already in the file is removed (so pending settings not yet applied to +# the repo survive). The file is rewritten only when content actually changes. ############################################################################### -# repository: pull live config, but only for the keys ALREADY present in the -# file, so export reflects drift on managed keys without introducing keys the -# repo deliberately omits. -export_repository() { - log "repository (export)" - local keys - keys="$(yq -o=json -I=0 '.repository // {} | keys' "$SETTINGS_FILE" 2>/dev/null || echo '[]')" - if [[ -z "$keys" || "$keys" == "[]" || "$keys" == "null" ]]; then - info "no repository block in file, nothing to export" - endlog - return - fi - local live new - live="$(api GET "/repos/${OWNER}/${REPO}")" - # Project live config to just the keys the file already manages. - new="$(jq -c --argjson keys "$keys" \ - '. as $r | reduce $keys[] as $k ({}; . + {($k): $r[$k]})' <<<"$live")" +# Full set of repository settings keys we capture (drops nulls). This is the +# *complete* settable set — export no longer narrows to keys already in the file, +# so settings.yml can represent the full repo configuration. +_REPO_KEYS='{has_issues, has_projects, has_wiki, has_downloads, is_template, default_branch, allow_squash_merge, allow_merge_commit, allow_rebase_merge, allow_auto_merge, allow_update_branch, delete_branch_on_merge, squash_merge_commit_title, squash_merge_commit_message, merge_commit_title, merge_commit_message, description, homepage, topics, private, web_commit_signoff_required, archived}' - if [[ "$DRY_RUN" == "true" ]]; then - echo "$new" | jq '.' - endlog - return - fi +# Build the live-state source object (only requested sections) on stdout. +build_export_source() { + local obj='{}' - local tmp; tmp="$(mktemp --suffix=.yml)" - echo "$new" | yq -P '{"repository": .}' > "$tmp" - # Deep-merge over the existing block so only the managed keys are touched. - yq -i ".repository = (.repository // {}) * load(\"$tmp\").repository" "$SETTINGS_FILE" - rm -f "$tmp" - info "wrote repository block" - endlog -} - -# rulesets: fetch every live ruleset, normalize to the same shape the file -# uses, and replace `.rulesets` wholesale. -export_rulesets() { - log "rulesets (export)" - - local list ids - list="$(gh api --paginate "/repos/${OWNER}/${REPO}/rulesets" --jq '[.[] | {name, id}]')" - info "found $(echo "$list" | jq 'length') live ruleset(s)" - ids="$(echo "$list" | jq -r '.[].id')" - - local arr='[]' id full norm - for id in $ids; do - full="$(gh api "/repos/${OWNER}/${REPO}/rulesets/${id}")" - # Match the file schema: name, target, enforcement, conditions, - # bypass_actors[{actor_id,actor_type,bypass_mode}], rules[{type,parameters?}]. - norm="$(echo "$full" | jq '{ - name, - target, - enforcement, - conditions, - bypass_actors: [ (.bypass_actors // [])[] | {actor_id, actor_type, bypass_mode} ], - rules: [ (.rules // [])[] | {type} + (if has("parameters") and .parameters != null then {parameters} else {} end) ] - }')" - arr="$(jq -c --argjson item "$norm" '. + [$item]' <<<"$arr")" - done + if want_section "repository"; then + local live rep + live="$(api GET "/repos/${OWNER}/${REPO}")" + rep="$(jq -c "$_REPO_KEYS | with_entries(select(.value != null))" <<<"$live")" + obj="$(jq -c --argjson v "$rep" '.repository = $v' <<<"$obj")" + fi - if [[ "$DRY_RUN" == "true" ]]; then - echo "$arr" | jq '.' - endlog - return + if want_section "rulesets"; then + local list arr='[]' id full norm + list="$(gh api --paginate "/repos/${OWNER}/${REPO}/rulesets" --jq '[.[].id]')" + for id in $(echo "$list" | jq -r '.[]'); do + full="$(gh api "/repos/${OWNER}/${REPO}/rulesets/${id}")" + norm="$(echo "$full" | jq '{ + name, target, enforcement, conditions, + bypass_actors: [ (.bypass_actors // [])[] | {actor_id, actor_type, bypass_mode} ], + rules: [ (.rules // [])[] | {type} + (if has("parameters") and .parameters != null then {parameters} else {} end) ] + }')" + arr="$(jq -c --argjson i "$norm" '. + [$i]' <<<"$arr")" + done + obj="$(jq -c --argjson v "$arr" '.rulesets = $v' <<<"$obj")" fi - local tmp; tmp="$(mktemp --suffix=.yml)" - echo "$arr" | yq -P '{"rulesets": .}' > "$tmp" - yq -i ".rulesets = load(\"$tmp\").rulesets" "$SETTINGS_FILE" - rm -f "$tmp" - info "wrote $(echo "$arr" | jq 'length') ruleset(s)" - endlog -} + if want_section "labels"; then + local arr + arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/labels" --jq '[.[] | {name, color, description}]')" + obj="$(jq -c --argjson v "$arr" '.labels = $v' <<<"$obj")" + fi -# labels: write EVERY label on the repo into the file (replaces `.labels`). -_export_section_array() { - # _export_section_array KEY JSON-ARRAY — splice a JSON array under top-level KEY. - local key="$1" arr="$2" tmp - tmp="$(mktemp --suffix=.yml)" - echo "$arr" | yq -P "{\"$key\": .}" > "$tmp" - yq -i ".$key = load(\"$tmp\").$key" "$SETTINGS_FILE" - rm -f "$tmp" -} + if want_section "collaborators"; then + local arr + arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/collaborators?affiliation=direct" \ + --jq '[.[] | {username: .login, permission: (.role_name // "push")}]')" + arr="$(jq -c 'map(.permission |= ({"read":"pull","write":"push"}[.] // .))' <<<"$arr")" + obj="$(jq -c --argjson v "$arr" '.collaborators = $v' <<<"$obj")" + fi -export_labels() { - log "labels (export)" - local arr - arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/labels" --jq '[.[] | {name, color, description}]')" - info "found $(echo "$arr" | jq 'length') label(s)" - if [[ "$DRY_RUN" == "true" ]]; then echo "$arr" | jq '.'; endlog; return; fi - _export_section_array labels "$arr" - info "wrote $(echo "$arr" | jq 'length') label(s)" - endlog -} + if want_section "teams"; then + local arr + arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/teams" \ + --jq '[.[] | {name: .slug, permission: (.permission // "push")}]')" + arr="$(jq -c 'map(.permission |= ({"read":"pull","write":"push"}[.] // .))' <<<"$arr")" + obj="$(jq -c --argjson v "$arr" '.teams = $v' <<<"$obj")" + fi -# collaborators: direct collaborators only (org-inherited access is not ours to -# manage). role_name normalized to a PUT-compatible permission so apply round-trips. -export_collaborators() { - log "collaborators (export)" - local arr - arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/collaborators?affiliation=direct" \ - --jq '[.[] | {username: .login, permission: (.role_name // "push")}]')" - arr="$(echo "$arr" | jq 'map(.permission |= ({"read":"pull","write":"push"}[.] // .))')" - info "found $(echo "$arr" | jq 'length') direct collaborator(s)" - if [[ "$DRY_RUN" == "true" ]]; then echo "$arr" | jq '.'; endlog; return; fi - _export_section_array collaborators "$arr" - info "wrote $(echo "$arr" | jq 'length') collaborator(s)" - endlog + echo "$obj" } -# teams: teams with direct repo access. `name` is the team slug (the merger's -# identity key for teams). permission normalized like collaborators. -export_teams() { - log "teams (export)" - local arr - arr="$(gh api --paginate "/repos/${OWNER}/${REPO}/teams" \ - --jq '[.[] | {name: .slug, permission: (.permission // "push")}]')" - arr="$(echo "$arr" | jq 'map(.permission |= ({"read":"pull","write":"push"}[.] // .))')" - info "found $(echo "$arr" | jq 'length') team(s)" - if [[ "$DRY_RUN" == "true" ]]; then echo "$arr" | jq '.'; endlog; return; fi - _export_section_array teams "$arr" - info "wrote $(echo "$arr" | jq 'length') team(s)" - endlog +_ensure_ruamel() { + python3 -c 'import ruamel.yaml' 2>/dev/null && return 0 + info "installing ruamel.yaml..." + python3 -m pip install --quiet --disable-pip-version-check ruamel.yaml >/dev/null 2>&1 \ + || python3 -m pip install --quiet --break-system-packages ruamel.yaml >/dev/null 2>&1 \ + || { echo "::error::failed to install ruamel.yaml (needed for export merge)"; return 1; } } run_export() { echo "Exporting $OWNER/$REPO live state into $SETTINGS_FILE (dry-run=$DRY_RUN, sections=$SECTIONS)" + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - local before after - before="$(sha256sum "$SETTINGS_FILE" | cut -d' ' -f1)" + _ensure_ruamel - if want_section "repository"; then export_repository; fi - if want_section "rulesets"; then export_rulesets; fi - if want_section "labels"; then export_labels; fi - if want_section "collaborators"; then export_collaborators; fi - if want_section "teams"; then export_teams; fi + log "build live source" + local source_json + source_json="$(build_export_source)" + if [[ "$DRY_RUN" == "true" ]]; then + echo "$source_json" | jq '.' + fi + endlog - after="$(sha256sum "$SETTINGS_FILE" | cut -d' ' -f1)" - if [[ "$before" == "$after" ]]; then CHANGED="false"; else CHANGED="true"; fi + log "merge into $SETTINGS_FILE" + local write_flag="" out + [[ "$DRY_RUN" == "true" ]] || write_flag="--write" + out="$(echo "$source_json" | python3 "$script_dir/merge_live.py" --file "$SETTINGS_FILE" $write_flag)" + echo "$out" + CHANGED="false" + [[ "$out" == *"changed=true"* ]] && CHANGED="true" + endlog summary="$(jq -nc --arg changed "$CHANGED" --arg sections "$SECTIONS" \ '{mode: "export", changed: $changed, sections: ($sections | split(","))}')" @@ -478,7 +432,7 @@ run_export() { echo "- **changed**: \`$CHANGED\`" echo "- **sections**: \`$SECTIONS\`" echo - echo "Target: \`$SETTINGS_FILE\` · Dry run: \`$DRY_RUN\`" + echo "Target: \`$SETTINGS_FILE\` · Dry run: \`$DRY_RUN\` (merge is additive — the file is never narrowed)" } >> "$GITHUB_STEP_SUMMARY" } diff --git a/.github/actions/apply-repo-settings/merge_live.py b/.github/actions/apply-repo-settings/merge_live.py new file mode 100644 index 0000000..b99245f --- /dev/null +++ b/.github/actions/apply-repo-settings/merge_live.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Merge live repo state (JSON on stdin) INTO an existing settings YAML file. + +Used by apply-repo-settings *export* mode. The file is the merge TARGET and the +live state is the SOURCE; semantics are deliberately additive and lossless: + + - mappings: recurse + - scalars: TARGET wins (the file's value is kept; export + never overwrites a value already in the file) + - identity-keyed lists: match items by identity key, recurse into matches + (so missing sub-keys / list entries — e.g. a new + bypass actor or collaborator — are added), append + source-only items to the END, and KEEP every + target-only item (things in the file not yet on + the repo are never removed) + - other lists: concat + dedupe + +Comments and formatting are preserved via ruamel.yaml round-trip. The file is +rewritten ONLY when the merge actually changes content (and only with --write), +so an up-to-date file is left byte-for-byte untouched. + +Prints `changed=true` or `changed=false`. +""" +from __future__ import annotations + +import argparse +import io +import json +import sys + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +# (path-tuple-pattern, identity-key) — "*" matches any single segment. +IDENTITY_KEYS: list[tuple[tuple[str, ...], object]] = [ + (("rulesets",), "name"), + (("rulesets", "*", "rules"), "type"), + (("rulesets", "*", "bypass_actors"), ("actor_id", "actor_type")), + (("labels",), "name"), + (("collaborators",), "username"), + (("teams",), "name"), +] + + +def _path_match(pattern: tuple[str, ...], path: tuple[str, ...]) -> bool: + return len(pattern) == len(path) and all( + p == "*" or p == q for p, q in zip(pattern, path) + ) + + +def _lookup_identity(path: tuple[str, ...]): + for pattern, key in IDENTITY_KEYS: + if _path_match(pattern, path): + return key + return None + + +def _ident(item, key) -> tuple: + if isinstance(key, str): + return (item.get(key),) + return tuple(item.get(k) for k in key) + + +def deep_merge(source, target, path: tuple[str, ...] = ()): + """Merge source into target (target wins on scalars). Mutates target.""" + if source is None: + return target + if target is None: + return source + + if isinstance(source, dict) and isinstance(target, dict): + for k, sv in source.items(): + if k in target: + target[k] = deep_merge(sv, target[k], path + (k,)) + else: + target[k] = sv + return target + + if isinstance(source, list) and isinstance(target, list): + key = _lookup_identity(path) + if key is None: + for item in source: + if item not in target: + target.append(item) + return target + index_by_id = { + _ident(item, key): i + for i, item in enumerate(target) + if isinstance(item, dict) + } + for s_item in source: + if not isinstance(s_item, dict): + if s_item not in target: + target.append(s_item) + continue + sid = _ident(s_item, key) + if sid in index_by_id: + idx = index_by_id[sid] + target[idx] = deep_merge(s_item, target[idx], path + ("*",)) + else: + target.append(s_item) + return target + + # scalar / mismatched types: target wins (never overwrite the file's value). + return target + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--file", required=True, help="settings YAML file (merge target)") + ap.add_argument("--write", action="store_true", help="write the merged result back") + args = ap.parse_args() + + source = json.load(sys.stdin) + + yaml = YAML() + yaml.preserve_quotes = True + yaml.width = 4096 # don't wrap long descriptions + # Match the org_settings_merge.py canonical style so re-serializing a file + # produced by that merger doesn't reindent (which would be spurious drift). + yaml.indent(mapping=2, sequence=4, offset=2) + + try: + with open(args.file, encoding="utf-8") as fh: + target = yaml.load(fh) + except FileNotFoundError: + target = None + if target is None: + target = CommentedMap() + + def dump(obj) -> str: + buf = io.StringIO() + yaml.dump(obj, buf) + return buf.getvalue() + + before = dump(target) # round-tripped original (formatting-neutral baseline) + merged = deep_merge(source, target) + after = dump(merged) + + changed = before != after + if changed and args.write: + with open(args.file, "w", encoding="utf-8") as fh: + fh.write(after) + + print("changed=true" if changed else "changed=false") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c40d74695de95a725778c2898587f6c2582214ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:25:39 +0000 Subject: [PATCH 7/9] test(apply-repo-settings): unit tests for export deep-merge (merge_live) Add 10 unit tests covering the merge semantics: target wins on existing scalars, missing keys/list-items added (rulesets/rules/bypass_actors/labels/ collaborators/teams by identity), new items appended, target-only entries never removed, comment preservation, dry-run no-write, and idempotent change detection. Wire a 'test' mise task + check.yaml Test job (python 3.12). --- .../apply-repo-settings/merge_live_test.py | 165 ++++++++++++++++++ .github/workflows/check.yaml | 16 ++ .mise/tasks/test | 10 ++ mise.toml | 1 + 4 files changed, 192 insertions(+) create mode 100644 .github/actions/apply-repo-settings/merge_live_test.py create mode 100755 .mise/tasks/test diff --git a/.github/actions/apply-repo-settings/merge_live_test.py b/.github/actions/apply-repo-settings/merge_live_test.py new file mode 100644 index 0000000..36faed7 --- /dev/null +++ b/.github/actions/apply-repo-settings/merge_live_test.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Unit tests for merge_live.py (apply-repo-settings export deep-merge). + +Run: python3 merge_live_test.py (requires ruamel.yaml) +""" +import json +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +from merge_live import deep_merge # noqa: E402 + +MERGE = os.path.join(HERE, "merge_live.py") + + +def merge_file(yaml_text, source_obj, write=True): + """Run merge_live.py against yaml_text with source_obj; return (changed, new_text).""" + with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as fh: + fh.write(yaml_text) + path = fh.name + try: + args = [sys.executable, MERGE, "--file", path] + if write: + args.append("--write") + out = subprocess.run( + args, input=json.dumps(source_obj), capture_output=True, text=True, check=True + ).stdout + changed = "changed=true" in out + with open(path, encoding="utf-8") as fh: + return changed, fh.read() + finally: + os.unlink(path) + + +class DeepMergeLogic(unittest.TestCase): + def test_scalar_target_wins(self): + # file (target) value is kept; live (source) never overwrites it. + out = deep_merge({"has_issues": False}, {"has_issues": True}) + self.assertEqual(out["has_issues"], True) + + def test_adds_missing_key(self): + out = deep_merge({"a": 1, "b": 2}, {"a": 9}) + self.assertEqual(out, {"a": 9, "b": 2}) + + def test_nested_mapping_recurse(self): + out = deep_merge( + {"repository": {"has_issues": False, "default_branch": "main"}}, + {"repository": {"has_issues": True}}, + ) + self.assertEqual(out["repository"], {"has_issues": True, "default_branch": "main"}) + + def test_rulesets_identity_merge_append_and_keep(self): + source = { + "rulesets": [ + { + "name": "protect", + "bypass_actors": [ + {"actor_id": 5, "actor_type": "RepositoryRole"}, + {"actor_id": 99, "actor_type": "Integration"}, + ], + "rules": [{"type": "deletion"}, {"type": "non_fast_forward"}], + }, + {"name": "new-from-ui", "target": "branch"}, + ] + } + target = { + "rulesets": [ + {"name": "pending-only", "target": "branch"}, # file-only, must stay + { + "name": "protect", + "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole"}], + "rules": [{"type": "deletion"}], + }, + ] + } + out = deep_merge(source, target) + names = [r["name"] for r in out["rulesets"]] + # pending-only kept, protect kept in place, new-from-ui appended at end. + self.assertEqual(names, ["pending-only", "protect", "new-from-ui"]) + protect = out["rulesets"][1] + actor_ids = sorted(a["actor_id"] for a in protect["bypass_actors"]) + self.assertEqual(actor_ids, [5, 99]) # missing actor added + rule_types = sorted(r["type"] for r in protect["rules"]) + self.assertEqual(rule_types, ["deletion", "non_fast_forward"]) # missing rule added + + def test_labels_collaborators_teams_identity(self): + out = deep_merge( + { + "labels": [{"name": "bug", "color": "ff0000"}, {"name": "new", "color": "00ff00"}], + "collaborators": [{"username": "alice", "permission": "admin"}], + "teams": [{"name": "core", "permission": "push"}], + }, + { + "labels": [{"name": "bug", "color": "d73a4a"}], # color kept (target wins) + "collaborators": [{"username": "alice", "permission": "pull"}], + "teams": [{"name": "core", "permission": "pull"}], + }, + ) + labels = {label["name"]: label["color"] for label in out["labels"]} + self.assertEqual(labels["bug"], "d73a4a") # target wins + self.assertEqual(labels["new"], "00ff00") # appended + self.assertEqual(out["collaborators"][0]["permission"], "pull") # target wins + self.assertEqual(out["teams"][0]["permission"], "pull") # target wins + + def test_target_only_never_removed(self): + out = deep_merge({"labels": []}, {"labels": [{"name": "keep", "color": "x"}]}) + self.assertEqual([label["name"] for label in out["labels"]], ["keep"]) + + +class CliBehavior(unittest.TestCase): + def test_changed_true_when_adding(self): + changed, text = merge_file( + "repository:\n has_issues: true\n", + {"repository": {"has_issues": False, "default_branch": "main"}}, + ) + self.assertTrue(changed) + self.assertIn("default_branch: main", text) + self.assertIn("has_issues: true", text) # not overwritten to false + + def test_changed_false_and_idempotent(self): + yaml_text = ( + "repository:\n has_issues: true\n" + "labels:\n - name: bug\n color: \"d73a4a\"\n" + ) + changed, text = merge_file( + yaml_text, + {"repository": {"has_issues": True}, "labels": [{"name": "bug", "color": "d73a4a"}]}, + ) + self.assertFalse(changed) + self.assertEqual(text, yaml_text) # byte-for-byte untouched when nothing to add + + def test_dry_run_does_not_write(self): + yaml_text = "repository:\n has_issues: true\n" + changed, text = merge_file( + yaml_text, {"repository": {"default_branch": "main"}}, write=False + ) + self.assertTrue(changed) # would change + self.assertEqual(text, yaml_text) # but file untouched (no --write) + + def test_comments_preserved(self): + yaml_text = ( + "# top-of-file comment\n" + "rulesets:\n" + " # keep this ruleset comment\n" + " - name: protect # inline\n" + " enforcement: active\n" + ) + changed, text = merge_file( + yaml_text, + {"rulesets": [{"name": "protect", "enforcement": "active", "target": "branch"}]}, + ) + self.assertTrue(changed) # added target: branch + self.assertIn("# top-of-file comment", text) + self.assertIn("# keep this ruleset comment", text) + self.assertIn("# inline", text) + self.assertIn("target: branch", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 4d6e6fd..83f87ce 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -117,3 +117,19 @@ jobs: id: grype - uses: ./.github/actions/lint-gitleaks id: gitleaks + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Install mise + uses: jdx/mise-action@v2 + + - name: Run action unit tests + run: mise run test + diff --git a/.mise/tasks/test b/.mise/tasks/test new file mode 100755 index 0000000..9e75c2c --- /dev/null +++ b/.mise/tasks/test @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +#MISE description="Run action unit tests (apply-repo-settings merge_live)" +set -euo pipefail + +echo "Installing test deps (ruamel.yaml)..." +python3 -m pip install --quiet --disable-pip-version-check ruamel.yaml \ + || python3 -m pip install --quiet --break-system-packages ruamel.yaml + +echo "Running apply-repo-settings merge_live tests..." +python3 .github/actions/apply-repo-settings/merge_live_test.py diff --git a/mise.toml b/mise.toml index b040e19..0c8a591 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,6 @@ [tools] node = "lts" +python = "3.12" # for action unit tests (merge_live) # Format tools editorconfig-checker = "latest" From 4b5e758f18f0a0f16fb555a58f8bc191cc1fe086 Mon Sep 17 00:00:00 2001 From: nsheaps <1282393+nsheaps@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:26:16 +0000 Subject: [PATCH 8/9] chore: `mise format` Triggered by: 9ebeea972d26797e95297ce8c428b94992ad0d39 Workflow run: https://github.com/nsheaps/github-actions/actions/runs/27239848861 --- .github/workflows/check.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 83f87ce..dbd04ef 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -132,4 +132,3 @@ jobs: - name: Run action unit tests run: mise run test - From 1662327c292761b62ceb3633b9388f5194f413fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:29:02 +0000 Subject: [PATCH 9/9] chore(editorconfig): 4-space indent for Python (merge_live tests) The repo .editorconfig mandates indent_size=2 for all files, which made editorconfig-checker fail on the new 4-space (idiomatic) Python. Add a [*.py] override so the action's Python passes the Format check. --- .editorconfig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.editorconfig b/.editorconfig index f0b85f6..7eee1bc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,3 +16,6 @@ trim_trailing_whitespace = true [*.sh] indent_size = 2 + +[*.py] +indent_size = 4