diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md new file mode 100644 index 0000000..a934187 --- /dev/null +++ b/.github/actions/apply-repo-settings/README.md @@ -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. diff --git a/.github/actions/apply-repo-settings/action.sh b/.github/actions/apply-repo-settings/action.sh new file mode 100755 index 0000000..e0f656b --- /dev/null +++ b/.github/actions/apply-repo-settings/action.sh @@ -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/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" diff --git a/.github/actions/apply-repo-settings/action.yml b/.github/actions/apply-repo-settings/action.yml new file mode 100644 index 0000000..2ab1479 --- /dev/null +++ b/.github/actions/apply-repo-settings/action.yml @@ -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 diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml new file mode 100644 index 0000000..59feb1d --- /dev/null +++ b/.github/workflows/pages.yaml @@ -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 diff --git a/pages/index.html b/pages/index.html new file mode 100644 index 0000000..820fe74 --- /dev/null +++ b/pages/index.html @@ -0,0 +1,608 @@ + + + + + nsheaps/github-actions — GitHub App Setup + + + + +

GitHub App Setup

+

+ Builds a + GitHub App manifest + for one of the apps below, walks through registration on github.com, and exchanges the + manifest code for the new App's credentials client-side. +

+ +
+ + + + + + + + + + + + + + +
+ Why does redirect URL have to be this page? +

+ GitHub creates the app, then redirects to the URL you give it with a + ?code=… query parameter. That code is good for ~1 hour and can be exchanged + once for the new app's ID, private key, and webhook secret. This page exchanges it + client-side via POST /app-manifests/{code}/conversions and shows you the + credentials to copy. +

+

+ If you'd rather host this page elsewhere, copy pages/index.html into any + HTTPS-served location and use that URL here. file:// won't work — GitHub + requires HTTPS. +

+
+ + +
+ + + + + + + + + +

Detailed setup walkthrough

+ +
+ What's a GitHub App manifest, and what does the page do? +

+ The page builds a JSON manifest declaring the new app's name, redirect URL, default + permissions, and webhook events — then POSTs it to + github.com/organizations/<org>/settings/apps/new. GitHub creates the app + and redirects back to this page with a temporary code. The page then calls + POST https://api.github.com/app-manifests/<code>/conversions from your + browser to retrieve the new app's ID, private key, and webhook secret. +

+

+ Reference: + Registering a GitHub App from a manifest. +

+
+ +
+ How do I host this page so it works as the redirect URL? +

+ For nsheaps repos this page is auto-deployed via GitHub Pages from + nsheaps/github-actions/pages/. For your own host: +

+
+cp pages/index.html docs/index.html
+# 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. +

+
+ +
+ I get "Manifest is invalid" from GitHub after clicking the button +

+ Open DevTools, find the manifest payload in the form before submission, and validate it + against + GitHub's manifest schema. Common gotchas: +

+ +
+ +
+ I lost the code. Can I get the private key again? +

+ No — GitHub only shows the private key once via the manifest exchange. Regenerate one from + the app settings: Private keys → Generate a private key. The old key is + invalidated; update the secret. +

+
+ +
+ Manual install (no manifest flow) +
    +
  1. + Go to Org SettingsDeveloper settingsGitHub Apps → + New GitHub App. +
  2. +
  3. + Name it (anything globally unique). Homepage URL: + https://github.com/<org> works. +
  4. +
  5. Uncheck "Active" under Webhook unless your preset needs webhooks.
  6. +
  7. + Set permissions matching the preset's + What permissions does this app grant? section. +
  8. +
  9. Where can this be installed: Only on this account (or Any account if shareable).
  10. +
  11. Create. Note the App ID, generate a private key.
  12. +
  13. Install it on the repos you want to manage.
  14. +
+
+ +
+ How do query-param prefills work? +

+ Any form field on this page can be prefilled by appending matching query parameters to the + URL. Field names are listed under each input. +

+

Examples:

+ +

+ The mechanism is generic: every <input> / <select> / + <textarea> with a name attribute is candidate. Adding new + fields later auto-supports the same prefill pattern. +

+
+ + + +