From e95a2bd09f0c455055ac384fc392f9b8c2bd0a0a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 05:32:29 +0000 Subject: [PATCH 1/4] feat(apply-repo-settings): new action for in-workflow repo settings sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ephemeral alternative to the repository-settings GitHub App (https://github.com/repository-settings/app). The upstream app is a Probot webhook server — adapting it for one-shot in-workflow runs is more invasive than building a minimal applier from scratch. Why a fresh implementation: this action covers the two sections we actually use today (`repository:` config and `rulesets:`) in ~170 lines of bash + `gh api`. Other sections (labels/collaborators/teams/ environments/legacy-branches) aren't implemented — they have separate sync mechanisms in nsheaps/.github already. Contents: action.yml composite action interface (app-id + private-key required; owner/repo/settings-file/dry-run/ sections all optional with sensible defaults). Outputs a JSON summary of changes. action.sh the applier: - PATCH /repos/{owner}/{repo} with .repository - For each ruleset: list existing, diff against desired, POST/PUT as needed. Never deletes rulesets that aren't in the YAML. docs/setup.html static HTML helper that builds a GitHub App manifest with the exact permissions this action needs (administration:write + contents:read + metadata:read), submits to github.com/.../apps/new, then exchanges the returned code for credentials via the manifest conversion endpoint. Accordion sections cover hosting choices and a manual-setup walkthrough. README.md inputs/outputs reference + example workflow that triggers on workflow_dispatch / repository_dispatch / push to settings.yml on main. Permissions intentionally narrow: this app needs Administration:write to manage rulesets and repo config, plus Contents:read to read the settings file. No Issues/PR/Workflow write — labels are managed by sync-labels today. Reuses the existing actions/create-github-app-token pin (29824e69f54612133e76f7eaac726eef6c875baf, v2) used by github-app-auth elsewhere in this repo. --- .github/actions/apply-repo-settings/README.md | 83 +++++++ .github/actions/apply-repo-settings/action.sh | 170 ++++++++++++++ .../actions/apply-repo-settings/action.yml | 79 +++++++ .../apply-repo-settings/docs/setup.html | 221 ++++++++++++++++++ 4 files changed, 553 insertions(+) create mode 100644 .github/actions/apply-repo-settings/README.md create mode 100755 .github/actions/apply-repo-settings/action.sh create mode 100644 .github/actions/apply-repo-settings/action.yml create mode 100644 .github/actions/apply-repo-settings/docs/setup.html diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md new file mode 100644 index 0000000..34258ac --- /dev/null +++ b/.github/actions/apply-repo-settings/README.md @@ -0,0 +1,83 @@ +# 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 + +Open [`docs/setup.html`](./docs/setup.html) (host it via GitHub Pages or any HTTPS static host) — 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, walks you through registration on github.com, and exchanges the manifest code for the App ID + private key. Accordion sections in the page cover hosting choices and a no-manifest manual setup path. + +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/actions/apply-repo-settings/docs/setup.html b/.github/actions/apply-repo-settings/docs/setup.html new file mode 100644 index 0000000..b0799e6 --- /dev/null +++ b/.github/actions/apply-repo-settings/docs/setup.html @@ -0,0 +1,221 @@ + + + + +apply-repo-settings — Create GitHub App + + + + + +

Create the apply-repo-settings GitHub App

+

+ This page builds a GitHub App manifest with the exact permissions the + apply-repo-settings action needs, and walks you through registering it. +

+ +
+ + + + + + + + + + +
+ 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 into repo/org secrets.

+

If you'd rather host this page elsewhere (or open it from file:// won't work), just copy setup.html into any HTTPS-served location and use that URL here.

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

Detailed setup walkthrough

+ +
+ What permissions is this app granted, and why? +
    +
  • Administration: write — needed to update repo config (has_issues, has_wiki, merge settings, default branch) and to create/update rulesets.
  • +
  • Contents: read — to read .github/settings.yml from the repo.
  • +
  • Metadata: read — implicit, required by all GitHub Apps.
  • +
+

It is explicitly not granted Issues, Pull requests, Workflows, or Code write. Labels are managed by a separate sync today.

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

The simplest option is GitHub Pages on any repo you control:

+
cd your-repo
+cp path/to/setup.html docs/index.html
+# Settings → Pages → Source: deploy from branch (main / docs)
+

Then use https://your-org.github.io/your-repo/ as the redirect URL on this form. Any other static host (Vercel, Netlify, S3+CloudFront) works just as well — GitHub only requires HTTPS.

+
+ +
+ 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:

+
    +
  • url must be HTTPS
  • +
  • redirect_url must be HTTPS
  • +
  • App name must be unique on github.com — try a more specific name.
  • +
+
+ +
+ I lost the code. Can I get the private key again? +

No — GitHub only shows the private key once via the manifest exchange. You can regenerate it in the app settings:

+

App page → Private keysGenerate a private key. The old key is invalidated; update the secret.

+
+ +
+ Manual install (no manifest flow) +

If you prefer to set things up by hand:

+
    +
  1. Go to Org SettingsDeveloper settingsGitHub AppsNew GitHub App.
  2. +
  3. Name it (anything globally unique). Homepage URL: https://github.com/<org> works.
  4. +
  5. Uncheck "Active" under Webhook (we don't use webhooks).
  6. +
  7. Permissions: Repository → Administration: Read & write, Contents: Read. Leave everything else at "No access".
  8. +
  9. Where can this be installed: Only on this account (or Any account if you want it shareable).
  10. +
  11. Create. Note the App ID, generate a private key.
  12. +
  13. Install it on the repos you want to manage.
  14. +
+
+ + + + From 92298e7f8b3e355a44526b9d9769c4b3700e3bc4 Mon Sep 17 00:00:00 2001 From: "jack-nsheaps[bot]" <254347511+jack-nsheaps[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 17:26:46 +0000 Subject: [PATCH 2/4] chore: `mise format` Triggered by: 374f864d6fc5a0a13ea2cf60f1ec6ceafbfbd103 Workflow run: https://github.com/nsheaps/github-actions/actions/runs/26590895624 --- .github/actions/apply-repo-settings/README.md | 19 +- .../apply-repo-settings/docs/setup.html | 662 ++++++++++++------ 2 files changed, 454 insertions(+), 227 deletions(-) diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md index 34258ac..c1fe417 100644 --- a/.github/actions/apply-repo-settings/README.md +++ b/.github/actions/apply-repo-settings/README.md @@ -10,15 +10,15 @@ The upstream app is a Probot webhook server — it's designed to listen for `pus ## 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. | +| 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 @@ -29,6 +29,7 @@ The upstream app is a Probot webhook server — it's designed to listen for `pus Open [`docs/setup.html`](./docs/setup.html) (host it via GitHub Pages or any HTTPS static host) — 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, walks you through registration on github.com, and exchanges the manifest code for the App ID + private key. Accordion sections in the page cover hosting choices and a no-manifest manual setup path. 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. diff --git a/.github/actions/apply-repo-settings/docs/setup.html b/.github/actions/apply-repo-settings/docs/setup.html index b0799e6..7226a5f 100644 --- a/.github/actions/apply-repo-settings/docs/setup.html +++ b/.github/actions/apply-repo-settings/docs/setup.html @@ -1,221 +1,447 @@ - + - - -apply-repo-settings — Create GitHub App - - - - - -

Create the apply-repo-settings GitHub App

-

- This page builds a GitHub App manifest with the exact permissions the - apply-repo-settings action needs, and walks you through registering it. -

- -
- - - - - - - - - - -
- 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 into repo/org secrets.

-

If you'd rather host this page elsewhere (or open it from file:// won't work), just copy setup.html into any HTTPS-served location and use that URL here.

-
- - -
- - - - - - - - - -

Detailed setup walkthrough

- -
- What permissions is this app granted, and why? -
    -
  • Administration: write — needed to update repo config (has_issues, has_wiki, merge settings, default branch) and to create/update rulesets.
  • -
  • Contents: read — to read .github/settings.yml from the repo.
  • -
  • Metadata: read — implicit, required by all GitHub Apps.
  • -
-

It is explicitly not granted Issues, Pull requests, Workflows, or Code write. Labels are managed by a separate sync today.

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

The simplest option is GitHub Pages on any repo you control:

-
cd your-repo
+  
+    
+    apply-repo-settings — Create GitHub App
+    
+    
+  
+  
+    

Create the apply-repo-settings GitHub App

+

+ This page builds a + GitHub App manifest + with the exact permissions the + apply-repo-settings + action needs, and walks you through registering it. +

+ +
+ + + + + + + + + +
+ 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 into repo/org secrets. +

+

+ If you'd rather host this page elsewhere (or open it from file:// won't + work), just copy setup.html into any HTTPS-served location and use that URL + here. +

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

Detailed setup walkthrough

+ +
+ What permissions is this app granted, and why? +
    +
  • + Administration: write — needed to update repo config + (has_issues, has_wiki, merge settings, default branch) and to + create/update rulesets. +
  • +
  • + Contents: read — to read .github/settings.yml from the repo. +
  • +
  • Metadata: read — implicit, required by all GitHub Apps.
  • +
+

+ It is explicitly not granted Issues, Pull requests, Workflows, or Code write. + Labels are managed by a separate sync today. +

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

The simplest option is GitHub Pages on any repo you control:

+
+cd your-repo
 cp path/to/setup.html docs/index.html
-# Settings → Pages → Source: deploy from branch (main / docs)
-

Then use https://your-org.github.io/your-repo/ as the redirect URL on this form. Any other static host (Vercel, Netlify, S3+CloudFront) works just as well — GitHub only requires HTTPS.

-
- -
- 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:

-
    -
  • url must be HTTPS
  • -
  • redirect_url must be HTTPS
  • -
  • App name must be unique on github.com — try a more specific name.
  • -
-
- -
- I lost the code. Can I get the private key again? -

No — GitHub only shows the private key once via the manifest exchange. You can regenerate it in the app settings:

-

App page → Private keysGenerate a private key. The old key is invalidated; update the secret.

-
- -
- Manual install (no manifest flow) -

If you prefer to set things up by hand:

-
    -
  1. Go to Org SettingsDeveloper settingsGitHub AppsNew GitHub App.
  2. -
  3. Name it (anything globally unique). Homepage URL: https://github.com/<org> works.
  4. -
  5. Uncheck "Active" under Webhook (we don't use webhooks).
  6. -
  7. Permissions: Repository → Administration: Read & write, Contents: Read. Leave everything else at "No access".
  8. -
  9. Where can this be installed: Only on this account (or Any account if you want it shareable).
  10. -
  11. Create. Note the App ID, generate a private key.
  12. -
  13. Install it on the repos you want to manage.
  14. -
-
- - - +# Settings → Pages → Source: deploy from branch (main / docs)
+

+ Then use https://your-org.github.io/your-repo/ as the redirect URL on this + form. Any other static host (Vercel, Netlify, S3+CloudFront) works just as well — GitHub + only requires HTTPS. +

+
+ +
+ 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: +

+
    +
  • url must be HTTPS
  • +
  • redirect_url must be HTTPS
  • +
  • App name must be unique on github.com — try a more specific name.
  • +
+
+ +
+ I lost the code. Can I get the private key again? +

+ No — GitHub only shows the private key once via the manifest exchange. You can regenerate it + in the app settings: +

+

+ App page → Private keysGenerate a private key. The old + key is invalidated; update the secret. +

+
+ +
+ Manual install (no manifest flow) +

If you prefer to set things up by hand:

+
    +
  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 (we don't use webhooks).
  6. +
  7. + Permissions: Repository → Administration: Read & write, + Contents: Read. Leave everything else at "No access". +
  8. +
  9. + Where can this be installed: Only on this account (or Any account if you want it + shareable). +
  10. +
  11. Create. Note the App ID, generate a private key.
  12. +
  13. Install it on the repos you want to manage.
  14. +
+
+ + + From 6803ec5f11e99485c37097826a4635de57f90a51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 18:13:06 +0000 Subject: [PATCH 3/4] feat(pages): generic multi-app setup page + GH Pages deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the apply-repo-settings setup helper into a reusable page that can register any GitHub App via the manifest flow, then deploys it to GitHub Pages so the hosted redirect URL is stable. Changes: - pages/index.html (moved from .github/actions/apply-repo-settings/ docs/setup.html and substantially restructured): * App preset dropdown driven by a single PRESETS registry. Adding a new app = one entry in the registry; no other code changes. * apply-repo-settings is the seed preset; manifest permissions, secret names, and post-creation next-steps move into the entry. * Generic ?qparam=value form prefill — any input/select/textarea with a `name` matching a query param gets prefilled at load. Supports text/url/select/textarea/checkbox+radio. Field hints under each label name the qparam. * Round-trip state preserved via `&state=preset=...` on the manifest submit, with sessionStorage + ?preset fallback on return, so the right preset's secret names / next-steps render after the GitHub redirect-back. * Redirect URL defaults to the current page (sans code/state). * New "How do query-param prefills work?" accordion documents the mechanism; hosting + manual-setup accordions retained. - .github/workflows/pages.yaml: deploys pages/ via the standard actions/configure-pages + upload-pages-artifact + deploy-pages trio. Triggers on push to main with paths in pages/ or this workflow, plus workflow_dispatch. concurrency: pages, serial. One-time enablement still required at repo Settings → Pages (Source: GitHub Actions) — noted in the workflow comment. - .github/actions/apply-repo-settings/README.md: * "Setting up the GitHub App" section now points at the hosted page with the preset preselected: https://nsheaps.github.io/github-actions/?preset=apply-repo-settings * Cross-references pages/index.html + the deploy workflow. Validation: JS parses clean via `node --check`. Preset block contains the documented permissions and secret names. (Headless browser smoke test not run — chromium not installed in this environment.) --- .github/actions/apply-repo-settings/README.md | 8 +- .../apply-repo-settings/docs/setup.html | 447 ------------------ .github/workflows/pages.yaml | 48 ++ pages/index.html | 371 +++++++++++++++ 4 files changed, 426 insertions(+), 448 deletions(-) delete mode 100644 .github/actions/apply-repo-settings/docs/setup.html create mode 100644 .github/workflows/pages.yaml create mode 100644 pages/index.html diff --git a/.github/actions/apply-repo-settings/README.md b/.github/actions/apply-repo-settings/README.md index c1fe417..a934187 100644 --- a/.github/actions/apply-repo-settings/README.md +++ b/.github/actions/apply-repo-settings/README.md @@ -26,7 +26,13 @@ The upstream app is a Probot webhook server — it's designed to listen for `pus ## Setting up the GitHub App -Open [`docs/setup.html`](./docs/setup.html) (host it via GitHub Pages or any HTTPS static host) — 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, walks you through registration on github.com, and exchanges the manifest code for the App ID + private key. Accordion sections in the page cover hosting choices and a no-manifest manual setup path. +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: diff --git a/.github/actions/apply-repo-settings/docs/setup.html b/.github/actions/apply-repo-settings/docs/setup.html deleted file mode 100644 index 7226a5f..0000000 --- a/.github/actions/apply-repo-settings/docs/setup.html +++ /dev/null @@ -1,447 +0,0 @@ - - - - - apply-repo-settings — Create GitHub App - - - - -

Create the apply-repo-settings GitHub App

-

- This page builds a - GitHub App manifest - with the exact permissions the - apply-repo-settings - action needs, and walks you through registering it. -

- -
- - - - - - - - - -
- 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 into repo/org secrets. -

-

- If you'd rather host this page elsewhere (or open it from file:// won't - work), just copy setup.html into any HTTPS-served location and use that URL - here. -

-
- - -
- - - - - - - - - -

Detailed setup walkthrough

- -
- What permissions is this app granted, and why? -
    -
  • - Administration: write — needed to update repo config - (has_issues, has_wiki, merge settings, default branch) and to - create/update rulesets. -
  • -
  • - Contents: read — to read .github/settings.yml from the repo. -
  • -
  • Metadata: read — implicit, required by all GitHub Apps.
  • -
-

- It is explicitly not granted Issues, Pull requests, Workflows, or Code write. - Labels are managed by a separate sync today. -

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

The simplest option is GitHub Pages on any repo you control:

-
-cd your-repo
-cp path/to/setup.html docs/index.html
-# Settings → Pages → Source: deploy from branch (main / docs)
-

- Then use https://your-org.github.io/your-repo/ as the redirect URL on this - form. Any other static host (Vercel, Netlify, S3+CloudFront) works just as well — GitHub - only requires HTTPS. -

-
- -
- 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: -

-
    -
  • url must be HTTPS
  • -
  • redirect_url must be HTTPS
  • -
  • App name must be unique on github.com — try a more specific name.
  • -
-
- -
- I lost the code. Can I get the private key again? -

- No — GitHub only shows the private key once via the manifest exchange. You can regenerate it - in the app settings: -

-

- App page → Private keysGenerate a private key. The old - key is invalidated; update the secret. -

-
- -
- Manual install (no manifest flow) -

If you prefer to set things up by hand:

-
    -
  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 (we don't use webhooks).
  6. -
  7. - Permissions: Repository → Administration: Read & write, - Contents: Read. Leave everything else at "No access". -
  8. -
  9. - Where can this be installed: Only on this account (or Any account if you want it - shareable). -
  10. -
  11. Create. Note the App ID, generate a private key.
  12. -
  13. Install it on the repos you want to manage.
  14. -
-
- - - - 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..0ee0d19 --- /dev/null +++ b/pages/index.html @@ -0,0 +1,371 @@ + + + + +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:

+
    +
  • url and redirect_url must be HTTPS.
  • +
  • App name must be unique on github.com — try a more specific name.
  • +
  • If the org is restricted to specific apps, the org owner has to approve creation.
  • +
+
+ +
+ 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 AppsNew 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:

+
    +
  • ?preset=apply-repo-settings — selects a preset
  • +
  • ?preset=apply-repo-settings&org=nsheaps&appname=my-settings-app — selects preset + prefills org + name
  • +
  • ?redirect=https://example.com/cb — overrides redirect URL
  • +
+

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

+
+ + + + From 6b942a45862895c6bc6b740a26cbb2f85e5dcaa2 Mon Sep 17 00:00:00 2001 From: nsheaps <1282393+nsheaps@users.noreply.github.com> Date: Thu, 28 May 2026 18:13:51 +0000 Subject: [PATCH 4/4] chore: `mise format` Triggered by: 3d6b64185dcd2bb3e63c6828a97380a0d5581e1b Workflow run: https://github.com/nsheaps/github-actions/actions/runs/26593382819 --- pages/index.html | 967 +++++++++++++++++++++++++++++------------------ 1 file changed, 602 insertions(+), 365 deletions(-) diff --git a/pages/index.html b/pages/index.html index 0ee0d19..820fe74 100644 --- a/pages/index.html +++ b/pages/index.html @@ -1,371 +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:

-
    -
  • url and redirect_url must be HTTPS.
  • -
  • App name must be unique on github.com — try a more specific name.
  • -
  • If the org is restricted to specific apps, the org owner has to approve creation.
  • -
-
- -
- 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 AppsNew 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:

-
    -
  • ?preset=apply-repo-settings — selects a preset
  • -
  • ?preset=apply-repo-settings&org=nsheaps&appname=my-settings-app — selects preset + prefills org + name
  • -
  • ?redirect=https://example.com/cb — overrides redirect URL
  • -
-

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

-
- - - + ` + // If appname is blank, suggest the preset default + const appname = document.getElementById('appname') + if (!appname.value) appname.value = p.defaultAppName + } + + document.getElementById('preset').addEventListener('change', (e) => { + renderPresetInfo(e.target.value) + }) + + // + // --- Default the redirect URL to this page --------------------------------- + // + function defaultRedirect() { + // Strip code/state/etc. that may have come from a previous round-trip + const url = new URL(location.href) + ;['code', 'state'].forEach((k) => url.searchParams.delete(k)) + return url.toString() + } + document.getElementById('redirect').value = defaultRedirect() + + // + // --- Init: populate dropdown, then apply qparams --------------------------- + // + populatePresetDropdown() + applyQParams() // qparams overwrite the default redirect if user passed ?redirect= + renderPresetInfo(document.getElementById('preset').value) + + // + // --- 1. Build manifest + post to github.com/.../apps/new ------------------ + // + document.getElementById('form').addEventListener('submit', (e) => { + e.preventDefault() + const org = document.getElementById('org').value.trim() + const name = document.getElementById('appname').value.trim() + const redirect = document.getElementById('redirect').value.trim() + const presetId = document.getElementById('preset').value + if (!presetId) { + alert('Choose an app preset.') + return + } + if (!org || !name || !redirect) return + if (!/^https:\/\//.test(redirect)) { + alert('Redirect URL must start with https://') + return + } + const preset = PRESETS[presetId] + + const manifest = { + name, + url: redirect, + redirect_url: redirect, + ...preset.manifest, + } + + // Persist the preset across the round-trip via `state` + const state = `preset=${encodeURIComponent(presetId)}` + sessionStorage.setItem('lastPreset', presetId) + + const f = document.getElementById('manifestform') + f.action = `https://github.com/organizations/${encodeURIComponent(org)}/settings/apps/new?state=${state}` + document.getElementById('manifestpayload').value = JSON.stringify(manifest) + f.submit() + }) + + // + // --- 2. On return with ?code=..., exchange + show creds -------------------- + // + const params = new URLSearchParams(location.search) + const code = params.get('code') + if (code) { + // Try to recover preset from state, then sessionStorage, then qparam + const stateRaw = params.get('state') || '' + const stateMatch = stateRaw.match(/preset=([^&]+)/) + const presetId = + (stateMatch && decodeURIComponent(stateMatch[1])) || + sessionStorage.getItem('lastPreset') || + params.get('preset') || + '' + + document.getElementById('form').classList.add('hidden') + document.getElementById('exchange').classList.remove('hidden') + const log = document.getElementById('exchangelog') + log.textContent = 'POST https://api.github.com/app-manifests/' + code + '/conversions ...' + + fetch(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, { + method: 'POST', + headers: { Accept: 'application/vnd.github+json' }, + }) + .then((r) => r.json().then((j) => ({ status: r.status, body: j }))) + .then(({ status, body }) => { + if (status >= 400) + throw new Error(`HTTP ${status}: ${body.message || JSON.stringify(body)}`) + document.getElementById('exchange').classList.add('hidden') + document.getElementById('creds').classList.remove('hidden') + document.getElementById('out-app-id').value = body.id + document.getElementById('out-private-key').value = body.pem + document.getElementById('out-webhook-secret').value = body.webhook_secret || '(none)' + const link = document.getElementById('out-html-url') + link.textContent = body.html_url + link.href = body.html_url + + const preset = PRESETS[presetId] || Object.values(PRESETS)[0] + document.getElementById('secret-name-id').textContent = preset.secretName.id + document.getElementById('secret-name-key').textContent = preset.secretName.key + const ol = document.getElementById('next-steps') + ol.innerHTML = preset + .nextSteps(body.html_url) + .map((s) => `
  • ${s}
  • `) + .join('') + }) + .catch((err) => { + document.getElementById('exchange').classList.add('hidden') + document.getElementById('errbox').classList.remove('hidden') + document.getElementById('errlog').textContent = String(err) + }) + } + +