From f01f0429a2660980a10aeb1671f520e6a88cd99e Mon Sep 17 00:00:00 2001
From: Nathan Heaps
Date: Mon, 20 Jul 2026 16:22:08 -0400
Subject: [PATCH 1/4] feat(apply-repo-settings): implement collaborators
section
PUT /repos/{owner}/{repo}/collaborators/{username} for each
{username, permission} entry in settings.yml's collaborators: list,
matching the rulesets/repository implementation's style and its
non-destructive philosophy (never revokes access absent from the list).
Opt-in only: unlike repository/rulesets, "collaborators" is not added
to the default `sections` input, since granting repo access is a
higher-consequence, easier-to-miss change than a ruleset tweak.
---
.github/actions/apply-repo-settings/action.sh | 72 +++++++++++++++++--
.../actions/apply-repo-settings/action.yml | 6 +-
2 files changed, 71 insertions(+), 7 deletions(-)
diff --git a/.github/actions/apply-repo-settings/action.sh b/.github/actions/apply-repo-settings/action.sh
index 52f778d..5e15f4e 100755
--- a/.github/actions/apply-repo-settings/action.sh
+++ b/.github/actions/apply-repo-settings/action.sh
@@ -3,10 +3,12 @@
# top-level sections to {OWNER}/{REPO} via the GitHub API.
#
# Supported sections:
-# repository → PATCH /repos/{owner}/{repo}
-# rulesets → POST/PUT/DELETE /repos/{owner}/{repo}/rulesets
+# repository → PATCH /repos/{owner}/{repo}
+# rulesets → POST/PUT/DELETE /repos/{owner}/{repo}/rulesets
+# collaborators → PUT /repos/{owner}/{repo}/collaborators/{username}
+# (opt-in only, see SECTIONS default below)
#
-# Not supported (yet): labels, collaborators, teams, environments, branches.
+# Not supported (yet): labels, teams, environments, branches.
# Those are handled by other workflows in nsheaps/.github today.
set -euo pipefail
@@ -184,12 +186,68 @@ apply_rulesets() {
endlog
}
+###############################################################################
+# collaborators: for each {username, permission} entry, PUT to add/update
+# their access. We do NOT remove collaborators absent from the list (same
+# non-destructive philosophy as rulesets — this action never revokes access
+# it didn't explicitly grant here).
+#
+# Opt-in only: unlike repository/rulesets, this is not in SECTIONS' default.
+# Granting repo access to a user is a higher-consequence action than a
+# ruleset tweak, so a repo must explicitly add "collaborators" to its
+# `sections` input even if its settings.yml already has a collaborators:
+# block (e.g. merged in via the nsheaps/.github sync layer).
+###############################################################################
+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 < count; i++)); do
+ local username permission body
+ username="$(yq -r ".collaborators[$i].username" "$SETTINGS_FILE")"
+ permission="$(yq -r ".collaborators[$i].permission" "$SETTINGS_FILE")"
+
+ if [[ -z "$username" || "$username" == "null" ]]; then
+ echo "::error file=$SETTINGS_FILE::collaborators[$i] is missing required 'username'" >&2
+ exit 1
+ fi
+ if [[ -z "$permission" || "$permission" == "null" ]]; then
+ echo "::error file=$SETTINGS_FILE::collaborators[$i] (username=$username) is missing required 'permission'" >&2
+ exit 1
+ fi
+
+ body="$(jq -nc --arg permission "$permission" '{permission: $permission}')"
+
+ info "add/update: $username ($permission)"
+ if [[ "$DRY_RUN" == "true" ]]; then
+ echo "$body" | jq '.'
+ else
+ # A 403 here most likely means the token/app lacks Administration:write
+ # on this repo — the `api` helper already annotates the HTTP status and
+ # response body, and `set -e` stops the run rather than continuing silently.
+ api PUT "/repos/${OWNER}/${REPO}/collaborators/${username}" "$body" >/dev/null
+ fi
+ APPLIED+=("$username")
+ done
+
+ endlog
+}
+
###############################################################################
# main
###############################################################################
CREATED=()
UPDATED=()
UNCHANGED=()
+APPLIED=()
REPO_CHANGED="false"
echo "Applying $SETTINGS_FILE to $OWNER/$REPO (dry-run=$DRY_RUN, sections=$SECTIONS)"
@@ -202,13 +260,18 @@ if want_section "rulesets"; then
apply_rulesets
fi
+if want_section "collaborators"; then
+ apply_collaborators
+fi
+
# Summary
summary="$(jq -nc \
--arg repo_changed "$REPO_CHANGED" \
--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 collaborators_applied "$(printf '%s\n' "${APPLIED[@]:-}" | jq -R . | jq -s 'map(select(length>0))')" \
+ '{repository: $repo_changed, rulesets_created: $created, rulesets_updated: $updated, rulesets_unchanged: $unchanged, collaborators_applied: $collaborators_applied}'
)"
echo "Summary: $summary"
@@ -222,6 +285,7 @@ echo "summary=$summary" >> "$GITHUB_OUTPUT"
echo "- **rulesets created**: ${#CREATED[@]}${CREATED:+: ${CREATED[*]}}"
echo "- **rulesets updated**: ${#UPDATED[@]}${UPDATED:+: ${UPDATED[*]}}"
echo "- **rulesets unchanged**: ${#UNCHANGED[@]}${UNCHANGED:+: ${UNCHANGED[*]}}"
+ echo "- **collaborators applied**: ${#APPLIED[@]}${APPLIED:+: ${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 1abe130..4ca037c 100644
--- a/.github/actions/apply-repo-settings/action.yml
+++ b/.github/actions/apply-repo-settings/action.yml
@@ -1,5 +1,5 @@
name: 'Apply Repo Settings'
-description: 'Read .github/settings.yml and apply repository config + rulesets to a repo via the GitHub API. A minimal ephemeral alternative to the repository-settings GitHub App (https://github.com/repository-settings/app), suitable for running inside a workflow.'
+description: 'Read .github/settings.yml and apply repository config, rulesets, and (opt-in) collaborators to a repo via the GitHub API. A minimal ephemeral alternative to the repository-settings GitHub App (https://github.com/repository-settings/app), suitable for running inside a workflow.'
branding:
icon: 'sliders'
@@ -31,13 +31,13 @@ inputs:
default: 'false'
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 apply. Defaults to "repository,rulesets". "collaborators" is supported but opt-in only — add it explicitly (e.g. "repository,rulesets,collaborators") since granting repo access is higher-consequence than a ruleset tweak. Add others (labels, 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 applied. Keys: repository, rulesets_created, rulesets_updated, rulesets_unchanged, collaborators_applied.'
value: ${{ steps.apply.outputs.summary }}
runs:
From bf3507fd725588d94400ca8a7a4c712f5dd6d5cd Mon Sep 17 00:00:00 2001
From: Nathan Heaps
Date: Mon, 20 Jul 2026 16:22:19 -0400
Subject: [PATCH 2/4] test(apply-repo-settings): add test coverage for
collaborators
No bats/shellspec harness exists yet for this repo's composite
actions, so this adds a self-contained bash test.sh that stubs curl
and asserts on the PUT calls (or lack thereof) action.sh makes,
covering: applying entries, opt-in-only default behavior, no-op when
absent, missing-field validation, and API-error propagation.
Wires it into `mise run test` (new task) and a new CI job in
check.yaml so the suite actually runs on every push/PR, not just
locally. Adds mikefarah/yq to mise.toml as a dev/test dependency
(the same yq flavor action.sh requires at runtime, already installed
ad hoc inside the composite action itself for consumers).
---
.github/actions/apply-repo-settings/test.sh | 197 ++++++++++++++++++++
.github/workflows/check.yaml | 17 ++
.mise/tasks/test | 14 ++
mise.toml | 3 +
4 files changed, 231 insertions(+)
create mode 100755 .github/actions/apply-repo-settings/test.sh
create mode 100755 .mise/tasks/test
diff --git a/.github/actions/apply-repo-settings/test.sh b/.github/actions/apply-repo-settings/test.sh
new file mode 100755
index 0000000..df9a467
--- /dev/null
+++ b/.github/actions/apply-repo-settings/test.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env bash
+# Lightweight test harness for action.sh.
+#
+# This repo has no bats/shellspec setup, so this is a plain bash script:
+# it stubs out `curl` (the only thing action.sh shells out to for the
+# actual GitHub API calls) with a recorder, runs action.sh against fixture
+# settings.yml files, and asserts on the calls made (or not made) and on
+# exit codes. Run directly: `.github/actions/apply-repo-settings/test.sh`
+# (requires mikefarah/yq + jq on PATH, same as action.sh itself).
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ACTION_SH="$SCRIPT_DIR/action.sh"
+
+PASS=0
+FAIL=0
+
+pass() { PASS=$((PASS + 1)); echo " ok - $*"; }
+fail() {
+ FAIL=$((FAIL + 1))
+ echo " not ok - $*"
+}
+
+# assert_contains NEEDLE HAYSTACK LABEL
+assert_contains() {
+ local needle="$1" haystack="$2" label="$3"
+ if [[ "$haystack" == *"$needle"* ]]; then
+ pass "$label"
+ else
+ fail "$label (expected to find: $needle)"
+ echo " --- haystack ---"
+ echo "$haystack" | sed 's/^/ /'
+ fi
+}
+
+# assert_not_contains NEEDLE HAYSTACK LABEL
+assert_not_contains() {
+ local needle="$1" haystack="$2" label="$3"
+ if [[ "$haystack" != *"$needle"* ]]; then
+ pass "$label"
+ else
+ fail "$label (expected NOT to find: $needle)"
+ fi
+}
+
+# assert_eq ACTUAL EXPECTED LABEL
+assert_eq() {
+ local actual="$1" expected="$2" label="$3"
+ if [[ "$actual" == "$expected" ]]; then
+ pass "$label"
+ else
+ fail "$label (expected [$expected], got [$actual])"
+ fi
+}
+
+TMPDIR="$(mktemp -d)"
+trap 'rm -rf "$TMPDIR"' EXIT
+
+MOCK_BIN="$TMPDIR/bin"
+mkdir -p "$MOCK_BIN"
+export MOCK_CURL_LOG="$TMPDIR/curl.log"
+
+# Fake `curl` that records every call action.sh's `api()` helper makes and
+# returns a canned response, so no real network call ever happens. Supports
+# forcing a failure via MOCK_CURL_FORCE_STATUS (used by the permission-denied
+# test case below).
+cat >"$MOCK_BIN/curl" <<'MOCK_CURL'
+#!/usr/bin/env bash
+set -euo pipefail
+method="GET"
+url=""
+body=""
+outfile="/dev/null"
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -X) method="$2"; shift 2 ;;
+ -H) shift 2 ;;
+ -w) shift 2 ;;
+ -d) body="$2"; shift 2 ;;
+ -o) outfile="$2"; shift 2 ;;
+ -s) shift ;;
+ http*) url="$1"; shift ;;
+ *) shift ;;
+ esac
+done
+
+echo "${method} ${url} ${body}" >>"$MOCK_CURL_LOG"
+
+if [[ -n "${MOCK_CURL_FORCE_STATUS:-}" ]]; then
+ echo "${MOCK_CURL_FORCE_BODY:-{}}" >"$outfile"
+ printf '%s' "$MOCK_CURL_FORCE_STATUS"
+ exit 0
+fi
+
+case "$url" in
+ */collaborators/*) echo -n "" >"$outfile"; printf '204' ;;
+ *) echo '{}' >"$outfile"; printf '200' ;;
+esac
+MOCK_CURL
+chmod +x "$MOCK_BIN/curl"
+
+run_action() {
+ # run_action SETTINGS_FILE SECTIONS [extra env assignments...]
+ local settings_file="$1" sections="$2"
+ shift 2
+ : >"$MOCK_CURL_LOG"
+ (
+ cd "$TMPDIR"
+ export PATH="$MOCK_BIN:$PATH"
+ export GH_TOKEN="fake-token"
+ export OWNER="acme"
+ export REPO="widgets"
+ export SETTINGS_FILE="$settings_file"
+ export DRY_RUN="false"
+ export SECTIONS="$sections"
+ export GITHUB_OUTPUT="$TMPDIR/github_output"
+ export GITHUB_STEP_SUMMARY="$TMPDIR/github_step_summary"
+ : >"$GITHUB_OUTPUT"
+ : >"$GITHUB_STEP_SUMMARY"
+ for assignment in "$@"; do
+ export "${assignment?}"
+ done
+ "$ACTION_SH"
+ )
+}
+
+echo "### collaborators: applies each {username, permission} entry via PUT"
+cat >"$TMPDIR/settings-collaborators.yml" <<'YAML'
+collaborators:
+ - username: alice
+ permission: push
+ - username: bob
+ permission: admin
+YAML
+
+if run_action "settings-collaborators.yml" "collaborators" >"$TMPDIR/out.log" 2>&1; then
+ log="$(cat "$MOCK_CURL_LOG")"
+ assert_contains "PUT https://api.github.com/repos/acme/widgets/collaborators/alice {\"permission\":\"push\"}" "$log" \
+ "PUT issued for alice with permission=push"
+ assert_contains "PUT https://api.github.com/repos/acme/widgets/collaborators/bob {\"permission\":\"admin\"}" "$log" \
+ "PUT issued for bob with permission=admin"
+ summary="$(grep '^summary=' "$TMPDIR/github_output" | sed 's/^summary=//')"
+ assert_contains '"collaborators_applied":["alice","bob"]' "$summary" "summary output lists both usernames"
+else
+ fail "action.sh exited non-zero for a valid collaborators block"
+ cat "$TMPDIR/out.log" | sed 's/^/ /'
+fi
+
+echo "### collaborators: opt-in only — default sections does not touch collaborators"
+if run_action "settings-collaborators.yml" "repository,rulesets" >"$TMPDIR/out.log" 2>&1; then
+ log="$(cat "$MOCK_CURL_LOG")"
+ assert_not_contains "/collaborators/" "$log" \
+ "no collaborators PUT issued when sections=repository,rulesets (default) even though the block exists"
+else
+ fail "action.sh exited non-zero on default sections with a collaborators block present"
+ cat "$TMPDIR/out.log" | sed 's/^/ /'
+fi
+
+echo "### collaborators: no collaborators block + section requested → no-op, not an error"
+cat >"$TMPDIR/settings-empty.yml" <<'YAML'
+repository:
+ private: true
+YAML
+if run_action "settings-empty.yml" "repository,rulesets,collaborators" >"$TMPDIR/out.log" 2>&1; then
+ log="$(cat "$MOCK_CURL_LOG")"
+ assert_not_contains "/collaborators/" "$log" \
+ "no collaborators PUT issued when there's no collaborators: block"
+else
+ fail "action.sh exited non-zero when collaborators is requested but absent from settings.yml"
+ cat "$TMPDIR/out.log" | sed 's/^/ /'
+fi
+
+echo "### collaborators: missing 'permission' field fails loudly instead of silently skipping"
+cat >"$TMPDIR/settings-bad.yml" <<'YAML'
+collaborators:
+ - username: carol
+YAML
+if run_action "settings-bad.yml" "collaborators" >"$TMPDIR/out.log" 2>&1; then
+ fail "action.sh should have exited non-zero on a collaborators entry missing 'permission'"
+else
+ assert_contains "missing required 'permission'" "$(cat "$TMPDIR/out.log")" \
+ "clear error message on missing 'permission'"
+fi
+
+echo "### collaborators: API error (e.g. token lacking Administration:write) propagates as a failure"
+if MOCK_CURL_FORCE_STATUS=403 MOCK_CURL_FORCE_BODY='{"message":"Resource not accessible by integration"}' \
+ run_action "settings-collaborators.yml" "collaborators" >"$TMPDIR/out.log" 2>&1; then
+ fail "action.sh should have exited non-zero when the collaborators PUT returns 403"
+else
+ assert_contains "HTTP 403" "$(cat "$TMPDIR/out.log")" \
+ "403 from the collaborators PUT surfaces in the error annotation, not swallowed"
+fi
+
+echo
+echo "$PASS passed, $FAIL failed"
+[[ "$FAIL" -eq 0 ]]
diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
index 4d6e6fd..6d84232 100644
--- a/.github/workflows/check.yaml
+++ b/.github/workflows/check.yaml
@@ -83,6 +83,23 @@ jobs:
echo "Formatting changes were auto-committed. Re-running CI."
exit 1
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Install mise
+ uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2.4.4
+ with:
+ install_args: 'aqua:mikefarah/yq'
+
+ - name: Run tests
+ run: mise run test
+
security:
name: Security
runs-on: ubuntu-latest
diff --git a/.mise/tasks/test b/.mise/tasks/test
new file mode 100755
index 0000000..c89fe60
--- /dev/null
+++ b/.mise/tasks/test
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+#MISE description="Run action test scripts (composite actions have no shared test framework yet — each ships its own test.sh)"
+set -euo pipefail
+
+status=0
+while IFS= read -r -d '' test_script; do
+ echo "::group::$test_script"
+ if ! "$test_script"; then
+ status=1
+ fi
+ echo "::endgroup::"
+done < <(find .github/actions -maxdepth 2 -name 'test.sh' -print0 | sort -z)
+
+exit "$status"
diff --git a/mise.toml b/mise.toml
index b040e19..676c331 100644
--- a/mise.toml
+++ b/mise.toml
@@ -17,3 +17,6 @@ checkov = "latest"
grype = "latest"
gitleaks = "latest"
+# Test tools (mikefarah's yq — same one apply-repo-settings/action.sh requires at runtime)
+"aqua:mikefarah/yq" = "latest"
+
From 8784a8242877cfba58995ee458981aad6b141fb8 Mon Sep 17 00:00:00 2001
From: Nathan Heaps
Date: Mon, 20 Jul 2026 16:22:26 -0400
Subject: [PATCH 3/4] docs(apply-repo-settings): document collaborators support
Documents the new collaborators section: PUT semantics, non-destructive
behavior, the opt-in sections default decision and why, and the
outputs/summary key. Also notes that the existing GitHub App manifest
(administration:write) already covers the collaborators PUT call per
GitHub's docs, so no app permission change is needed.
---
.github/actions/apply-repo-settings/README.md | 32 ++++++++++++-------
1 file changed, 20 insertions(+), 12 deletions(-)
diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md
index a8626ba..9599b11 100644
--- a/.github/actions/apply-repo-settings/README.md
+++ b/.github/actions/apply-repo-settings/README.md
@@ -6,24 +6,24 @@ Reads `.github/settings.yml` from the current repo and applies the supported sec
## 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 `repository:` config, `rulesets:`, and (opt-in) `collaborators:` in a small bash + `gh api` script. Other sections (`labels`, `teams`, `environments`, legacy `branches`) are not yet implemented — labels are managed via a separate sync today.
## 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. |
-| `dry-run` | no | `false` | Print what would change without applying. |
-| `sections` | no | `repository,rulesets` | Comma-separated section names to apply. |
+| 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. |
+| `dry-run` | no | `false` | Print what would change without applying. |
+| `sections` | no | `repository,rulesets` | Comma-separated section names to apply. `collaborators` is supported but **opt-in only** — it is not in the default (see [What it actually does](#what-it-actually-does)). |
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: `{ repository, rulesets_created, rulesets_updated, rulesets_unchanged, collaborators_applied }`.
## Setting up the GitHub App
@@ -35,6 +35,8 @@ It builds a [GitHub App manifest](https://docs.github.com/en/apps/sharing-github
Source: [`pages/index.html`](../../../pages/index.html). Deployed by [`.github/workflows/pages.yaml`](../../workflows/pages.yaml).
+`PUT /repos/{owner}/{repo}/collaborators/{username}` (used by the `collaborators` section) is documented by GitHub as requiring `Administration: write` on the target repo — the same permission this manifest already grants for `repository`/`rulesets`. No additional app permission is needed to enable `collaborators`.
+
After creating the app:
1. Install it on every repo you want to manage.
@@ -85,11 +87,17 @@ 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}`
+- **`collaborators:` list** (only when `collaborators` is included in `sections` — see below) → for each `{username, permission}` entry, `PUT /repos/{owner}/{repo}/collaborators/{username}` with `{"permission": ""}`. `permission` is whatever the GitHub API accepts (`pull`, `triage`, `push`, `maintain`, `admin`, or a custom repository role name); this action does not validate it beyond checking it's present — an invalid value surfaces as an API error.
+
+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). Collaborators absent from the YAML are similarly **not removed** — this action only ever adds/updates, never revokes. If you want destructive sync for either, add a `--prune` mode in a follow-up.
+
+### Why `collaborators` is opt-in, not a default section
-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.
+`repository` and `rulesets` ship in the default `sections` value, so a repo with no `repository:`/`rulesets:` block in its `settings.yml` sees no behavior change either way — the section is simply skipped. The same is true for `collaborators`. The difference is what happens once a repo's `settings.yml` _does_ gain a `collaborators:` block — for example via the `nsheaps/.github` sync layer's `collaborators` deep-merge, which a repo owner may not have driven themselves. Granting a user push/admin access is a higher-consequence, harder-to-notice change than adding a ruleset, so this action requires each repo to explicitly add `collaborators` to its `sections` input before any `PUT /repos/{owner}/{repo}/collaborators/{username}` call is made. Until a repo opts in, an incoming `collaborators:` block is inert from this action's point of view.
## 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.
+- No support yet for `labels`, `teams`, `environments`. Those are tracked separately.
- `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.
+- `collaborators` only manages **outside collaborators / explicit repo-level access grants** — adding an org member via this call does not add them to the organization, only grants/updates their repo-level permission. It does not touch org membership, invite state, or team-based access.
From c43a880bbef9697a3244cbf6508e1fdf6149476e Mon Sep 17 00:00:00 2001
From: nsheaps-oura <300890611+nsheaps-oura@users.noreply.github.com>
Date: Mon, 20 Jul 2026 20:44:32 +0000
Subject: [PATCH 4/4] chore: `mise format`
Triggered by: b4a403e847ec0be3f56dab3823af15ab6889397c
Workflow run: https://github.com/nsheaps/github-actions/actions/runs/29777349915
---
.../1/1771874092/documentation/REPORT.md | 16 ++++++++--------
LICENSE.md | 2 +-
pages/index.html | 3 +--
3 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/.claude/pr-reviews/nsheaps/github-actions/1/1771874092/documentation/REPORT.md b/.claude/pr-reviews/nsheaps/github-actions/1/1771874092/documentation/REPORT.md
index 47964ad..65c5390 100644
--- a/.claude/pr-reviews/nsheaps/github-actions/1/1771874092/documentation/REPORT.md
+++ b/.claude/pr-reviews/nsheaps/github-actions/1/1771874092/documentation/REPORT.md
@@ -100,15 +100,15 @@ This PR delivers strong, well-structured documentation that significantly exceed
### Summary of Deductions
-| Category | Deduction | Reason |
+| Category | Deduction | Reason |
| ---------------------------------- | --------- | --------------------------------------------------------------------- | --- | -------------------------------- |
-| Missing troubleshooting/error docs | -5 | No guidance on common failure modes |
-| Missing prerequisites | -2 | No runner/dependency requirements noted |
-| Incomplete edge-case docs | -4 | maxdepth 2 limit, root-path sync naming, never-deletes implications |
-| env-vars behavior mismatch | -2 | README implies compose-level injection, script does GITHUB_ENV export |
-| auth-type values underdocumented | -2 | No explanation of what each auth type requires |
-| Missing function docstrings | -2 | `discover_compose_files` and `export_env_vars` lack docstrings |
-| Minor inline comment gaps | -1 | ` | | true`on curl,`sort -z` rationale |
+| Missing troubleshooting/error docs | -5 | No guidance on common failure modes |
+| Missing prerequisites | -2 | No runner/dependency requirements noted |
+| Incomplete edge-case docs | -4 | maxdepth 2 limit, root-path sync naming, never-deletes implications |
+| env-vars behavior mismatch | -2 | README implies compose-level injection, script does GITHUB_ENV export |
+| auth-type values underdocumented | -2 | No explanation of what each auth type requires |
+| Missing function docstrings | -2 | `discover_compose_files` and `export_env_vars` lack docstrings |
+| Minor inline comment gaps | -1 | ` | | true`on curl,`sort -z` rationale |
## References
diff --git a/LICENSE.md b/LICENSE.md
index e70157b..c3fc5da 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -11,7 +11,7 @@ Copyright (c) 2026 Nathan Heaps. All rights reserved.
> (https://polyformproject.org/licenses/internal-use/1.0.0), which is
> purpose-built for "use the software for internal business operations, do
> not distribute it." The one substantive adaptation is that PolyForm grants
-> rights to *"you and your company"* (whoever accepts the license), whereas
+> rights to _"you and your company"_ (whoever accepts the license), whereas
> this license grants them to a **single, specifically named licensee**
> (Oura, defined below). That is a real difference: the licensor here is not
> a generic member of the licensee's organization, so the "permitted
diff --git a/pages/index.html b/pages/index.html
index 820fe74..caec1b6 100644
--- a/pages/index.html
+++ b/pages/index.html
@@ -319,8 +319,7 @@ Detailed setup walkthrough
cp pages/index.html docs/index.html
-# Settings → Pages → Source: GitHub Actions (or branch /docs)
+# Settings → Pages → Source: GitHub Actions (or branch /docs)
Then use https://<your-org>.github.io/<repo>/ as the redirect. Any
other HTTPS static host (Vercel, Netlify, etc.) works equally well.