Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 20 additions & 12 deletions .github/actions/apply-repo-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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>"}`. `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.
72 changes: 68 additions & 4 deletions .github/actions/apply-repo-settings/action.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand All @@ -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"
Expand All @@ -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"
6 changes: 3 additions & 3 deletions .github/actions/apply-repo-settings/action.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading