Skip to content
Merged
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
90 changes: 90 additions & 0 deletions .github/actions/apply-repo-settings/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# apply-repo-settings

An ephemeral, in-workflow alternative to the [repository-settings GitHub App](https://github.com/repository-settings/app).

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).

## 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.

## Inputs

| Input | Required | Default | Description |
| --------------- | -------- | ---------------------- | ------------------------------------------------------------------ |
| `app-id` | yes | — | GitHub App ID. Needs `Administration: write` and `Contents: read`. |
| `private-key` | yes | — | The App's PEM private key. |
| `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. |

## Outputs

- `summary` — JSON object: `{ repository, rulesets_created, rulesets_updated, rulesets_unchanged }`.

## Setting up the GitHub App

Use the hosted setup helper:

**→ https://nsheaps.github.io/github-actions/?preset=apply-repo-settings**

It builds a [GitHub App manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) with exactly the permissions this action needs (`administration:write` + `contents:read` + `metadata:read`), submits to `github.com/.../apps/new`, then exchanges the returned code for the App ID + private key client-side. The page is generic — a dropdown selects which app to create (preset auto-selected via the `?preset=` qparam above; every form field also supports query-param prefill, e.g. `&org=nsheaps&appname=my-settings-app`). Accordion sections cover hosting, troubleshooting, and a no-manifest manual setup path.

Source: [`pages/index.html`](../../../pages/index.html). Deployed by [`.github/workflows/pages.yaml`](../../workflows/pages.yaml).

After creating the app:

1. Install it on every repo you want to manage.
2. Add `APPLY_REPO_SETTINGS_APP_ID` and `APPLY_REPO_SETTINGS_PRIVATE_KEY` as secrets.

## Example workflow

```yaml
name: Apply Repo Settings

on:
workflow_dispatch:
inputs:
dry-run:
description: "Render only; don't apply"
type: boolean
default: false
repository_dispatch:
types: [apply-repo-settings]
push:
branches: [main]
paths:
- '.github/settings.yml'

permissions:
contents: read

jobs:
apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: nsheaps/github-actions/.github/actions/apply-repo-settings@main
with:
app-id: ${{ secrets.APPLY_REPO_SETTINGS_APP_ID }}
private-key: ${{ secrets.APPLY_REPO_SETTINGS_PRIVATE_KEY }}
dry-run: ${{ inputs.dry-run || false }}
```

## What it actually does

- **`repository:` block** → single `PATCH /repos/{owner}/{repo}` with the JSON body of `.repository`.
- **`rulesets:` list** → lists current rulesets, then for each entry in the YAML:
- 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}`

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.

## 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.
- `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.
170 changes: 170 additions & 0 deletions .github/actions/apply-repo-settings/action.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# Apply Repo Settings — reads SETTINGS_FILE and applies the supported
# 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
#
# Not supported (yet): labels, collaborators, teams, environments, branches.
# Those are handled by other workflows in nsheaps/.github today.

set -euo pipefail

: "${GH_TOKEN:?GH_TOKEN required}"
: "${OWNER:?OWNER required}"
: "${REPO:?REPO required}"
: "${SETTINGS_FILE:=.github/settings.yml}"
: "${DRY_RUN:=false}"
: "${SECTIONS:=repository,rulesets}"

if [[ ! -f "$SETTINGS_FILE" ]]; then
echo "::error file=$SETTINGS_FILE::settings file not found"
exit 1
fi

log() { echo "::group::$*"; }
endlog() { echo "::endgroup::"; }
info() { echo " → $*"; }

api() {
# api METHOD PATH [JSON-BODY]
local method="$1" path="$2" body="${3:-}"
if [[ -n "$body" ]]; then
gh api -X "$method" "$path" --input - <<<"$body"
else
gh api -X "$method" "$path"
fi
}

want_section() {
local target="$1"
[[ ",$SECTIONS," == *",$target,"* ]]
}

###############################################################################
# repository: PATCH /repos/{owner}/{repo}
###############################################################################
apply_repository() {
log "repository"
local body
body="$(yq -o=json '.repository // {}' "$SETTINGS_FILE")"
if [[ "$body" == "{}" || -z "$body" ]]; then
info "no repository block, skipping"
endlog
REPO_CHANGED="false"
return
fi
info "applying repository config..."
if [[ "$DRY_RUN" == "true" ]]; then
echo "$body" | jq '.'
REPO_CHANGED="dry-run"
else
api PATCH "/repos/${OWNER}/${REPO}" "$body" >/dev/null
REPO_CHANGED="true"
fi
endlog
}

###############################################################################
# rulesets: list existing, then create/update by name.
# We do NOT delete rulesets that aren't in settings.yml (safer default —
# repos may have rulesets created via the UI we don't want to wipe).
###############################################################################
apply_rulesets() {
log "rulesets"

local count
count="$(yq '.rulesets | length // 0' "$SETTINGS_FILE")"
if [[ "$count" == "0" || "$count" == "null" ]]; then
info "no rulesets block, skipping"
endlog
return
fi

# Fetch existing rulesets — map name → id
local existing
existing="$(gh api --paginate "/repos/${OWNER}/${REPO}/rulesets" --jq '[.[] | {name, id}]')"
info "found $(echo "$existing" | jq 'length') existing ruleset(s)"

local i
for ((i=0; i<count; i++)); do
local name body existing_id
name="$(yq -r ".rulesets[$i].name" "$SETTINGS_FILE")"
body="$(yq -o=json ".rulesets[$i]" "$SETTINGS_FILE")"
existing_id="$(echo "$existing" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id // empty')"

if [[ -z "$existing_id" ]]; then
info "create: $name"
if [[ "$DRY_RUN" == "true" ]]; then
echo "$body" | jq '.'
else
api POST "/repos/${OWNER}/${REPO}/rulesets" "$body" >/dev/null
fi
CREATED+=("$name")
else
# Compare current vs desired to decide if PUT is needed.
local current_norm desired_norm
current_norm="$(gh api "/repos/${OWNER}/${REPO}/rulesets/${existing_id}" \
--jq '{name, target, enforcement, conditions, rules, bypass_actors}' \
| jq -S '.')"
desired_norm="$(echo "$body" | jq -S '{name, target, enforcement, conditions, rules, bypass_actors}')"
if [[ "$current_norm" == "$desired_norm" ]]; then
info "unchanged: $name (id=$existing_id)"
UNCHANGED+=("$name")
else
info "update: $name (id=$existing_id)"
if [[ "$DRY_RUN" == "true" ]]; then
diff <(echo "$current_norm") <(echo "$desired_norm") || true
else
api PUT "/repos/${OWNER}/${REPO}/rulesets/${existing_id}" "$body" >/dev/null
fi
UPDATED+=("$name")
fi
fi
done

endlog
}

###############################################################################
# main
###############################################################################
CREATED=()
UPDATED=()
UNCHANGED=()
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

# 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}'
)"

echo "Summary: $summary"
echo "summary=$summary" >> "$GITHUB_OUTPUT"

# Pretty markdown summary for the run page
{
echo "## Apply Repo Settings — \`$OWNER/$REPO\`"
echo
echo "- **repository**: \`$REPO_CHANGED\`"
echo "- **rulesets created**: ${#CREATED[@]}${CREATED:+: ${CREATED[*]}}"
echo "- **rulesets updated**: ${#UPDATED[@]}${UPDATED:+: ${UPDATED[*]}}"
echo "- **rulesets unchanged**: ${#UNCHANGED[@]}${UNCHANGED:+: ${UNCHANGED[*]}}"
echo
echo "Source: \`$SETTINGS_FILE\` · Dry run: \`$DRY_RUN\` · Sections: \`$SECTIONS\`"
} >> "$GITHUB_STEP_SUMMARY"
79 changes: 79 additions & 0 deletions .github/actions/apply-repo-settings/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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.'

branding:
icon: 'sliders'
color: 'blue'

inputs:
app-id:
description: 'GitHub App ID for the applier. Must have Administration:write on the target repo. See docs/setup.html in this action directory for an HTML helper that builds the app via the GitHub manifest flow.'
required: true

private-key:
description: 'GitHub App private key (PEM).'
required: true

owner:
description: 'Target repo owner. Defaults to the current repo owner.'
required: false
default: ${{ github.repository_owner }}

repo:
description: 'Target repo name. Defaults to the current repo.'
required: false
default: ${{ github.event.repository.name }}

settings-file:
description: 'Path to the YAML config to apply. Defaults to .github/settings.yml.'
required: false
default: '.github/settings.yml'

dry-run:
description: 'If true, print what would change without applying.'
required: false
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.'
required: false
default: 'repository,rulesets'

outputs:
summary:
description: 'JSON summary of changes applied. Keys: repository, rulesets_created, rulesets_updated, rulesets_unchanged.'
value: ${{ steps.apply.outputs.summary }}

runs:
using: 'composite'
steps:
- name: Generate App installation token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ inputs.private-key }}
owner: ${{ inputs.owner }}
repositories: ${{ inputs.repo }}

- name: Ensure yq is available
shell: bash
run: |
if ! command -v yq >/dev/null 2>&1; then
echo "Installing yq..."
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
fi
yq --version

- name: Apply settings
id: apply
shell: bash
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
OWNER: ${{ inputs.owner }}
REPO: ${{ inputs.repo }}
SETTINGS_FILE: ${{ inputs.settings-file }}
DRY_RUN: ${{ inputs.dry-run }}
SECTIONS: ${{ inputs.sections }}
run: ${{ github.action_path }}/action.sh
48 changes: 48 additions & 0 deletions .github/workflows/pages.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Deploy Pages

# Publishes the static GitHub App setup helper at pages/index.html to
# GitHub Pages. Lives at https://nsheaps.github.io/github-actions/.
#
# Note: enabling Pages itself is a one-time repo Settings → Pages step
# (Source: "GitHub Actions"). This workflow runs the deploy once Pages
# is enabled.

on:
push:
branches: [main]
paths:
- 'pages/**'
- '.github/workflows/pages.yaml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
# Only one deploy may be in-flight at a time; serial, not preempt.
group: pages
cancel-in-progress: false

jobs:
deploy:
name: Deploy pages/
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@v6

- name: Configure Pages
uses: actions/configure-pages@v5

- name: Upload pages/ artifact
uses: actions/upload-pages-artifact@v3
with:
path: pages

- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@v4
Loading
Loading