From b8f63902e294baa2678fb35f2bd83716faf86c0e Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:05:24 +0800 Subject: [PATCH 1/3] fix: bootstrap phase 5 sets four repository settings left at GitHub defaults Each default contradicted something the repository already documents. squash_merge_commit_title=PR_TITLE. AGENTS.md, CONTRIBUTING.md, pr-authoring and branch-and-commit all state the PR title becomes the commit message on main. GitHub's COMMIT_OR_PR_TITLE default makes that false whenever a PR has exactly one commit -- visible in this repo's own log, where #7 and #9 carry the (#N) suffix and #11, #13 and #15 do not. squash_merge_commit_message=PR_BODY. The COMMIT_MESSAGES default concatenates every branch commit message into the main commit body, and release-please deliberately parses that body for further Conventional Commits and BREAKING-CHANGE footers. Already live here: f98cbf9's body carries "* chore: trigger CI on release PR", harmless only because chore is release-please's hidden bucket. A branch commit reading "fix: wip" would have produced a phantom changelog entry or an unintended bump. allow_rebase_merge=false. The comment justifying rebase claimed release-please merges its own PR. It does not -- ADR-0002, the release-please workflow header and the release-management skill all require a human merge. Nor has rebase ever been used: the history has no merge commits, all eight merged PRs were squash-merged, and both release PRs cut their tags that way. allow_update_branch=true. The ruleset sets strict_required_status_checks_policy=false, so the "Update branch" button is not offered at all without it. Also corrects skills/release-management/SKILL.md, which instructed `gh pr merge --merge`. allow_merge_commit=false has been set since day one, so that command has always returned HTTP 405 -- the documented release procedure was broken. Its parenthetical was wrong too: release-please does not merge. Accepted cost: with PR_BODY an unmodified PR template lands verbatim in the main commit message. BLANK was rejected -- it drops Co-authored-by trailers and discards the RISK/rollback record. PR_BODY reduces the misparse surface but does not eliminate it; a Conventional-Commit-shaped line in a PR body is still parsed. Closes #24 Co-Authored-By: Claude Opus 5 --- docs/setup/bootstrap.md | 30 +++++++++++++++++------ scripts/bootstrap.sh | 38 ++++++++++++++++++++++++++---- skills/release-management/SKILL.md | 4 ++-- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/docs/setup/bootstrap.md b/docs/setup/bootstrap.md index b572818..0d0e709 100644 --- a/docs/setup/bootstrap.md +++ b/docs/setup/bootstrap.md @@ -101,13 +101,29 @@ one-time step; see `docs/setup/project-views.md`. ### 5. Repo settings -Sets merge strategy (squash + rebase allowed, merge commits disabled), -`delete_branch_on_merge`, issues on, wiki off. - -Manual: **Settings → General → Pull Requests**: enable "Allow squash -merging" and "Allow rebase merging", disable "Allow merge commits", enable -"Automatically delete head branches". Under **Features**: Issues on, Wikis -off. +Sets squash as the only merge strategy, pins the squash commit message to the +PR title and body, and turns on `delete_branch_on_merge`, `allow_update_branch`, +issues, and wiki off. + +The two squash-message settings are not cosmetic. GitHub's defaults are +`COMMIT_OR_PR_TITLE` and `COMMIT_MESSAGES`, which mean the PR title is used +only when a PR has two or more commits, and every branch commit message is +concatenated into the `main` commit body. Both break promises this repository +makes elsewhere: `AGENTS.md` states the PR title becomes the commit message on +`main`, and release-please parses that commit body for further Conventional +Commits and `BREAKING-CHANGE` footers — so a stray `feat:` or `fix:` on a +branch can produce a phantom changelog entry or an unintended version bump. + +Rebase is off because nothing needs it. The release PR is merged by a human +like any other PR (see `docs/adr/ADR-0002-release-flow.md`), not by +release-please, and squash is what release-please recommends for the linear +history it parses. + +Manual: **Settings → General → Pull Requests** — enable "Allow squash merging" +and, under it, set the default commit message dropdown to **"Pull request title +and description"**; disable "Allow merge commits" and "Allow rebase merging"; +enable "Always suggest updating pull request branches" and "Automatically +delete head branches". Under **Features**: Issues on, Wikis off. ### 6. Actions PR permission diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index db072f8..bfc41b7 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -662,19 +662,47 @@ phase_project() { phase_repo_settings() { doing "Phase 5: repo settings" - # rebase stays on: release-please merges its own PR via normal PR merge; - # squash is the human default per AGENTS.md; wiki off = docs live in-repo. + # Squash is the ONLY merge strategy. AGENTS.md ("squash merge; the PR title + # becomes the commit message on main"), CONTRIBUTING.md, pr-authoring and + # branch-and-commit all declare it; these settings make the buttons match the + # docs. Rebase is off because nothing needs it: the release PR is merged by a + # human like any other PR (ADR-0002 -- never auto-merge), not by + # release-please, and release-please recommends squash-merge for the linear + # history it parses. + # + # squash_merge_commit_title=PR_TITLE: GitHub's default is COMMIT_OR_PR_TITLE, + # which silently uses the branch commit's subject whenever a PR has exactly + # one commit -- making the documented claim above untrue for most PRs. + # + # squash_merge_commit_message=PR_BODY: GitHub's default concatenates every + # branch commit message into the main commit body, and release-please + # deliberately parses that body for additional Conventional Commits and + # BREAKING-CHANGE footers. A WIP `feat:`/`fix:` commit on a branch would + # become a phantom changelog entry or an unintended version bump. Not + # hypothetical: `chore: release 0.2.0 (#7)` on this repo's main carries + # `* chore: trigger CI on release PR` in its body -- harmless only because + # `chore` is release-please's hidden bucket. + # + # allow_update_branch=true: the ruleset sets + # strict_required_status_checks_policy=false, so the "Update branch" button is + # not offered at all without this. Its merge commits are squashed away. + # + # wiki off = docs live in-repo. run_or_dry gh api -X PATCH "repos/${REPO}" \ -F allow_squash_merge=true \ -F allow_merge_commit=false \ - -F allow_rebase_merge=true \ + -F allow_rebase_merge=false \ + -f squash_merge_commit_title=PR_TITLE \ + -f squash_merge_commit_message=PR_BODY \ + -F allow_update_branch=true \ -F delete_branch_on_merge=true \ -F has_issues=true \ -F has_wiki=false \ || { fail "gh api repo settings PATCH failed"; record_phase "5. Repo settings" "fail"; return 1; } - ok "merge strategy: squash + rebase allowed, merge commits disabled" - ok "delete_branch_on_merge=true, has_issues=true, has_wiki=false" + ok "merge strategy: squash only (merge commits and rebase disabled)" + ok "squash commit message: PR title + PR body" + ok "delete_branch_on_merge=true, allow_update_branch=true, has_issues=true, has_wiki=false" record_phase "5. Repo settings" "ok" } diff --git a/skills/release-management/SKILL.md b/skills/release-management/SKILL.md index a72ecdc..3bfea79 100644 --- a/skills/release-management/SKILL.md +++ b/skills/release-management/SKILL.md @@ -37,11 +37,11 @@ gh issue list --milestone "v0.2.0" --label "priority:p0,priority:p1" --state ope # empty output = exit criterion met ``` -Merge the release-please PR (never squash-merge it manually outside its own flow; let release-please's merge produce the tag): +Merge the release-please PR yourself, with squash — the only strategy this repo enables (bootstrap phase 5 sets `allow_merge_commit=false` and `allow_rebase_merge=false`). release-please does not merge its own PR; its next run on `push: main` detects the merged release PR and cuts the tag and GitHub Release: ```bash gh pr view --search "head:release-please--branches--main" --json number,statusCheckRollup -gh pr merge --merge +gh pr merge --squash ``` Add the human TLDR after the release is cut: From 3a43bc8fb959d8a653384e33c94d70bdb10be9c4 Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:17:51 +0800 Subject: [PATCH 2/3] feat: bootstrap phase 6 reports and offers repository security settings Bootstrap did nothing about security settings -- grep for secret, scanning, visibility, vulnerability or dependabot in the script returned nothing. Two consequences. Converting a repo private to public grants ACCESS to secret scanning but does not enable it, and push protection in particular must be switched on explicitly, which is how a public repo ends up without it. And .github/dependabot.yml asserts Dependabot alerts and security updates are enabled while nothing verifies that. The write path is bounded by cost, not by capability: the only settings this phase ever enables are free by construction. - Public repo: offers secret scanning and push protection, both free. - Private or internal: never writes them, under any flag. There they need a paid Advanced Security / Secret Protection seat, and a setup script must not commit an adopter's account to a per-committer charge. It reports the state and emits a MANUAL step. - Dependabot alerts and automated security fixes: free everywhere, so offered regardless of visibility. - Repository visibility is never changed and never offered. Private to public erases stars and watchers and publishes all Actions history -- a one-way door, and the phase comment says so, because "detect whether the repo is public" invites someone to add that prompt later. Two states the GitHub UI blurs are kept distinct. Unreadable settings mean the token lacks admin, not that the setting is off, so security_and_analysis coming back null degrades to warn plus MANUAL rather than reporting "disabled". And automated-security-fixes returns {"enabled":..,"paused":..}: enabled but paused means no fix PR ever opens, so it is reported separately. Verified read-only before writing the phase: gh api documents the key[subkey]=value nested syntax, so the security_and_analysis PATCH stays a normal run_or_dry call visible under --dry-run; /vulnerability-alerts returns 204 enabled and 404 disabled; /automated-security-fixes returns enabled and paused. Unlike phases 3/5/8 this phase runs several independent checks, so it accumulates a result and calls record_phase once at the end -- the pattern phase_issue_types uses, matching run_phase's one-record-per-exit-path contract. Phases 6/7/8 renumber to 7/8/9 across the script and docs. Because every read runs for real under --dry-run, bootstrap --dry-run now doubles as a zero-risk security audit. Closes #26 Co-Authored-By: Claude Opus 5 --- SECURITY.md | 20 ++++ docs/setup/bootstrap.md | 68 +++++++++-- docs/template/architecture.md | 5 +- scripts/bootstrap.sh | 212 ++++++++++++++++++++++++++++------ 4 files changed, 261 insertions(+), 44 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index dfe6a48..3abf79a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,6 +32,26 @@ Only the latest release and `main` are supported. Older tagged releases do not receive backported fixes — update to the latest release or rebase your adoption on `main`. +## Repository security settings + +`scripts/bootstrap.sh` phase 6 reports, and offers to enable, four +repository-level protections: secret scanning, push protection, Dependabot +alerts, and Dependabot security updates. Two things worth knowing about how it +behaves: + +- **It only ever enables what is free.** On a public repository secret scanning + and push protection cost nothing, so it offers them. On a private or internal + repository they require a paid GitHub Advanced Security / Secret Protection + seat, and bootstrap will not commit your account to a per-committer charge — + it reports the state and hands you a manual step instead. +- **Making a repository public does not enable them for you.** Going public + grants *access* to those features; it does not switch them on. Push + protection in particular has to be enabled explicitly, which is exactly how a + public repository ends up without it. + +`scripts/bootstrap.sh --dry-run` performs every read for real and no writes at +all, so it works as a zero-risk audit of an existing repository. + ## Secret hygiene `gitleaks` runs in CI (`make lint-secrets`) to catch committed secrets before diff --git a/docs/setup/bootstrap.md b/docs/setup/bootstrap.md index 0d0e709..e6ac1c3 100644 --- a/docs/setup/bootstrap.md +++ b/docs/setup/bootstrap.md @@ -7,8 +7,8 @@ phase, plus the flag reference and troubleshooting. Run the script when you can — it's idempotent, so re-running it later syncs label drift back to what's declared in `.github/labels.yml`. The branch ruleset (`.github/rulesets/main-branch.json`) is create-once, not synced: -re-running skips phase 7 if `main-branch-protection` already exists. To pick -up ruleset changes, delete the existing ruleset on GitHub first (see phase 7 +re-running skips phase 8 if `main-branch-protection` already exists. To pick +up ruleset changes, delete the existing ruleset on GitHub first (see phase 8 below), then re-run. ## Flags @@ -19,7 +19,7 @@ below), then re-run. | `--yes` | No prompts; accept defaults for every phase. | | `--prune` | Delete undeclared repo labels without prompting (the default answer is already yes; use this to skip the prompt in scripts/CI). | | `--skip-project` | Skip Project creation and field setup (phase 4). | -| `--keep-template-docs` | Skip de-templating (phase 8); keep `docs/template/` and the starter README. | +| `--keep-template-docs` | Skip de-templating (phase 9); keep `docs/template/` and the starter README. | | `--help` | Show usage and exit. | ## Phases, and their manual equivalent @@ -125,7 +125,54 @@ and description"**; disable "Allow merge commits" and "Allow rebase merging"; enable "Always suggest updating pull request branches" and "Automatically delete head branches". Under **Features**: Issues on, Wikis off. -### 6. Actions PR permission +### 6. Security + +Reports repository visibility and the state of secret scanning, push +protection, Dependabot alerts and Dependabot security updates, then offers to +enable whatever is off — subject to one rule. + +**The only settings bootstrap enables here are the ones that are free.** +Anything with a billing consequence is reported and handed back to you as a +manual step. Concretely: + +- **Public repo** — secret scanning and push protection are free, so the script + offers to enable them. Going public does *not* switch them on by itself; + push protection in particular has to be enabled explicitly. +- **Private or internal repo** — the script will **not** enable secret scanning + for you at all, under any flag. There it needs a paid GitHub Advanced + Security / Secret Protection seat, and committing your account to a + per-committer charge is not a setup step. You get the current state, an + explanation, and a manual step. +- **Dependabot alerts and security updates** — free on every plan, so they are + offered regardless of visibility. `.github/dependabot.yml` already assumes + both are on; until now nothing verified that. + +The phase never changes repository visibility and never offers to. Going from +private to public erases stars and watchers and publishes your entire Actions +history — a one-way door, not something a setup script should ask about +in passing. + +Two states the summary distinguishes that the GitHub UI blurs: settings that +are *unreadable* (your token lacks admin on the repo) are reported as unknown +rather than as disabled, and Dependabot security updates that are enabled but +**paused** are called out, because paused means no fix PR will ever open. + +Since every read runs for real even under `--dry-run`, +`scripts/bootstrap.sh --dry-run` doubles as a zero-risk security audit of an +existing repository. + +**One thing that sounds alarming and is not:** GitHub's documentation lists +"all push rulesets will be disabled" among the consequences of making a repo +public. This template's ruleset (`.github/rulesets/main-branch.json`) has +`"target": "branch"`, not `"push"`, so it is unaffected — your `main` +protection survives a visibility change. + +Manual: **Settings → Advanced Security**. Enable "Secret scanning" and, under +it, "Push protection". Enable "Dependabot alerts" and "Dependabot security +updates". On a private repo the first two require a Secret Protection licence; +the Dependabot pair are free everywhere. + +### 7. Actions PR permission Enables Actions to create and approve pull requests — required for release-please to open its release PR. @@ -138,7 +185,7 @@ release-please's workflow run fails with: GitHub Actions is not permitted to create or approve pull requests. ``` -### 7. Ruleset +### 8. Ruleset Imports `.github/rulesets/main-branch.json` as a repository ruleset named `main-branch-protection`, if a ruleset with that name doesn't already exist. @@ -150,7 +197,7 @@ select `.github/rulesets/main-branch.json`. Review the imported rules (branch deletion/force-push blocked, PR required, `ci` status check required) and click **Create**. -### 8. De-template +### 9. De-template One-time conversion from the template product to your project: @@ -194,6 +241,13 @@ yourself: `git commit -m "chore: bootstrap repository"`. ## Troubleshooting +**Security settings come back empty / "could not read"** — the +`security_and_analysis` object is only populated for callers with admin +permission on the repository, so a token without it sees nothing rather than +seeing "disabled". Phase 6 reports this as unknown and emits a manual step +instead of guessing. Check `gh auth status`, and re-run once the token has +admin, or set the four toggles by hand in **Settings → Advanced Security**. + **"token scopes do not list 'project'"** — the default `gh auth login` token doesn't request the `project` scope. Fix: `gh auth refresh -s project`, then re-run. @@ -205,7 +259,7 @@ effect until issue types are enabled at the org level (or the repo is transferred into an org that has them). **Ruleset name conflict** — if a ruleset named `main-branch-protection` -already exists, the script skips phase 7 rather than overwriting it (syncing +already exists, the script skips phase 8 rather than overwriting it (syncing a ruleset means delete-then-rerun, since there's no partial-update path for rule lists via `gh api`). To pick up changes from `.github/rulesets/main-branch.json`: delete the existing ruleset in diff --git a/docs/template/architecture.md b/docs/template/architecture.md index bf471ba..5618ca0 100644 --- a/docs/template/architecture.md +++ b/docs/template/architecture.md @@ -35,5 +35,6 @@ Before merging a release PR: 1. `make verify` green locally; CI green on `main`. 2. Scratch-repo E2E: create a repo from the template (`gh repo create --template ...`), run `scripts/bootstrap.sh --dry-run` then live, re-run to confirm idempotence, run the de-template phase, open one issue per form (native type + labels land), open a trivial PR (CI runs, ruleset enforces), then delete the scratch repo. -3. Private-info sweep: `grep -riE '' .` returns nothing (see release-management skill). -4. Merge release PR (human), then add the hand-written TLDR to the GitHub Release. +3. Phase 6 security matrix — needs **two** scratch repos, one public and one private. Public: accept the prompts, then assert via `gh api repos// --jq '.security_and_analysis'` that secret scanning and push protection are on, and re-run to confirm zero further writes. Private: assert the phase issues **zero** PATCH calls for secret scanning and that `security_and_analysis.secret_scanning.status` is unchanged — that is the no-spend guarantee, and it is the only check that proves it. +4. Private-info sweep: `grep -riE '' .` returns nothing (see release-management skill). +5. Merge release PR (human), then add the hand-written TLDR to the GitHub Release. diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index bfc41b7..197ea8b 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # bootstrap.sh — one-time (and re-runnable) setup for a repo created from the # "GitHub Project OS" template. Applies everything a template can't ship as -# files: labels, milestone, GitHub Project fields, repo settings, ruleset, +# files: labels, milestone, GitHub Project fields, repo settings, security +# settings, ruleset, # and (once) converts the repo from template docs to your project docs. # # Idempotent: re-running syncs label state to what's declared in # .github/labels.yml. The branch ruleset is create-once, not synced: if -# main-branch-protection already exists, phase 7 is skipped rather than +# main-branch-protection already exists, phase 8 is skipped rather than # updated — delete the ruleset on GitHub and re-run to pick up changes to # .github/rulesets/main-branch.json. # @@ -86,7 +87,7 @@ Options: --prune Delete repo labels not declared in .github/labels.yml, without prompting (implies the default prune behavior). --skip-project Skip phase 4 (GitHub Project creation / field setup). - --keep-template-docs Skip phase 8 (de-templating); keep docs/template/ and + --keep-template-docs Skip phase 9 (de-templating); keep docs/template/ and the starter README in place. --help Show this help and exit. @@ -97,9 +98,10 @@ Phases: 3. Milestone create v0.1.0 if missing 4. Project create Project v2 board + Effort field (see --skip-project) 5. Repo settings merge strategy, delete-branch-on-merge, wiki off - 6. Actions permission enable Actions to create/approve PRs (release-please) - 7. Ruleset import .github/rulesets/main-branch.json - 8. De-template convert repo from template docs to your project (see --keep-template-docs) + 6. Security secret scanning + push protection (public repos), Dependabot alerts + 7. Actions permission enable Actions to create/approve PRs (release-please) + 8. Ruleset import .github/rulesets/main-branch.json + 9. De-template convert repo from template docs to your project (see --keep-template-docs) Docs: docs/setup/bootstrap.md (manual fallback + reference for every phase). EOF @@ -707,10 +709,149 @@ phase_repo_settings() { record_phase "5. Repo settings" "ok" } -# --- Phase 6 — Actions PR permission --- +# --- Phase 6 — Security --- + +# Read-mostly by design, and the write path is bounded by COST, not by +# capability: the only settings this phase ever enables are free by +# construction. Anything with a billing consequence is reported and handed to +# the operator as a MANUAL step. +# +# Secret scanning is therefore offered only on a PUBLIC repo, where it is free. +# On a private or internal repo it needs a paid GitHub Advanced Security / +# Secret Protection seat, so this phase reports and stops rather than creating a +# per-committer billing obligation on the adopter's account. Dependabot alerts +# and automated security fixes are free everywhere, so they are offered on any +# visibility -- and .github/dependabot.yml already asserts both are on, with +# nothing until now verifying it. +# +# This phase NEVER changes repository visibility, and must not learn to. Private +# -> public erases stars and watchers and publishes all Actions history; that is +# a one-way door, not a bootstrap decision. Detection only. +# +# Unlike phases 3/5/8, this phase runs several independent checks, so it +# accumulates $result and calls record_phase ONCE at the end (the pattern +# phase_issue_types uses) -- run_phase's contract wants exactly one record per +# exit path, not one per check. +phase_security() { + doing "Phase 6: security settings" + + local result="ok" + + # One read, three facts. security_and_analysis is only populated for callers + # with admin permission on the repo -- it comes back null otherwise -- so the + # "unknown" fallbacks below mean "could not read", never "disabled". + # Exit code checked explicitly: on HTTP errors `gh api` prints the JSON error + # body to stdout (same failure mode as phase 2's issue-types check). + local facts + if ! facts="$(gh api "repos/${REPO}" --jq '[.visibility, (.security_and_analysis.secret_scanning.status // "unknown"), (.security_and_analysis.secret_scanning_push_protection.status // "unknown")] | @tsv' 2>/dev/null)"; then + facts="" + fi + + if [ -z "$facts" ]; then + warn "could not read repos/${REPO} security settings — the token may lack admin on this repo" + manual "Review Settings → Advanced Security by hand: secret scanning, push protection, Dependabot alerts, Dependabot security updates" + record_phase "6. Security" "warn" + return + fi + + local visibility secret_scanning push_protection + visibility="$(printf '%s' "$facts" | cut -f1)" + secret_scanning="$(printf '%s' "$facts" | cut -f2)" + push_protection="$(printf '%s' "$facts" | cut -f3)" + ok "repository visibility: ${visibility}" + + if [ "$visibility" = "public" ]; then + if [ "$secret_scanning" = "enabled" ] && [ "$push_protection" = "enabled" ]; then + ok "secret scanning + push protection: already enabled" + else + cat <<'EOF' +Secret scanning and push protection are free on public repositories. Scanning +finds credentials already committed; push protection blocks a push that would +add a new one. Neither is switched on by converting a repo from private to +public — push protection in particular has to be enabled explicitly. +EOF + if confirm "Enable secret scanning and push protection on ${REPO}?" "y"; then + # security_and_analysis is a nested object. gh's key[subkey]=value + # syntax builds it, which keeps the whole call inside run_or_dry and + # visible under --dry-run instead of needing a piped JSON body. + if run_or_dry gh api -X PATCH "repos/${REPO}" \ + -f 'security_and_analysis[secret_scanning][status]=enabled' \ + -f 'security_and_analysis[secret_scanning_push_protection][status]=enabled'; then + ok "secret scanning + push protection enabled" + else + warn "secret scanning PATCH failed — needs admin on ${REPO}" + manual "Enable Settings → Advanced Security → Secret scanning and Push protection" + result="warn" + fi + else + skip "secret scanning + push protection (both free on this public repo)" + manual "Enable Settings → Advanced Security → Secret scanning and Push protection" + result="warn" + fi + fi + else + warn "repository is ${visibility}: secret scanning (${secret_scanning}), push protection (${push_protection})" + warn " on a private/internal repo these need a paid GitHub Advanced Security / Secret Protection seat" + warn " bootstrap will not enable them for you — that is a billing decision, not a setup step" + manual "Private repo: decide whether to license GitHub Secret Protection, then enable secret scanning + push protection in Settings → Advanced Security" + result="warn" + fi + + # GET returns 204 when enabled and 404 when not, so the exit code IS the + # answer -- but a 403 (a token without admin) also exits non-zero, so the + # wording covers "or not visible" rather than asserting the wrong one. + if gh api "repos/${REPO}/vulnerability-alerts" >/dev/null 2>&1; then + ok "Dependabot alerts: enabled" + elif confirm "Enable Dependabot alerts? (free on every plan; .github/dependabot.yml assumes it)" "y"; then + if run_or_dry gh api -X PUT "repos/${REPO}/vulnerability-alerts"; then + ok "Dependabot alerts enabled" + else + warn "could not enable Dependabot alerts — needs admin on ${REPO}, or they are not visible to this token" + manual "Enable Settings → Advanced Security → Dependabot alerts" + result="warn" + fi + else + skip "Dependabot alerts" + manual "Enable Settings → Advanced Security → Dependabot alerts (.github/dependabot.yml assumes it is on)" + result="warn" + fi + + # {"enabled":bool,"paused":bool}. Enabled-but-paused is a real state and must + # not be reported as plain "enabled" — paused means no fix PRs ever open. + local fixes_facts fixes paused + if ! fixes_facts="$(gh api "repos/${REPO}/automated-security-fixes" --jq '[.enabled, .paused] | @tsv' 2>/dev/null)"; then + fixes_facts="" + fi + fixes="$(printf '%s' "$fixes_facts" | cut -f1)" + paused="$(printf '%s' "$fixes_facts" | cut -f2)" + + if [ "$fixes" = "true" ] && [ "$paused" = "true" ]; then + warn "Dependabot security updates: enabled but PAUSED — no automatic fix PRs will open" + manual "Un-pause Dependabot security updates in Settings → Advanced Security" + result="warn" + elif [ "$fixes" = "true" ]; then + ok "Dependabot security updates: enabled" + elif confirm "Enable Dependabot security updates (automated security fixes)?" "y"; then + if run_or_dry gh api -X PUT "repos/${REPO}/automated-security-fixes"; then + ok "Dependabot security updates enabled" + else + warn "could not enable Dependabot security updates — needs admin, and Dependabot alerts must be on first" + manual "Enable Settings → Advanced Security → Dependabot security updates" + result="warn" + fi + else + skip "Dependabot security updates" + manual "Enable Settings → Advanced Security → Dependabot security updates" + result="warn" + fi + + record_phase "6. Security" "$result" +} + +# --- Phase 7 — Actions PR permission --- phase_actions_permission() { - doing "Phase 6: Actions PR creation/approval permission" + doing "Phase 7: Actions PR creation/approval permission" cat <<'EOF' release-please opens and updates its own release PR from a workflow run. By @@ -730,25 +871,25 @@ EOF run_or_dry gh api -X PUT "repos/${REPO}/actions/permissions/workflow" \ -f default_workflow_permissions=read \ -F can_approve_pull_request_reviews=true \ - || { fail "gh api Actions permissions PUT failed"; record_phase "6. Actions permission" "fail"; return 1; } + || { fail "gh api Actions permissions PUT failed"; record_phase "7. Actions permission" "fail"; return 1; } ok "Actions can now create and approve pull requests" - record_phase "6. Actions permission" "ok" + record_phase "7. Actions permission" "ok" else skip "Actions PR permission (release-please will fail until this is enabled)" manual "Enable Settings → Actions → General → 'Allow GitHub Actions to create and approve pull requests', or release-please will fail" - record_phase "6. Actions permission" "skip" + record_phase "7. Actions permission" "skip" fi } -# --- Phase 7 — Ruleset --- +# --- Phase 8 — Ruleset --- phase_ruleset() { - doing "Phase 7: branch ruleset" + doing "Phase 8: branch ruleset" local ruleset_file=".github/rulesets/main-branch.json" if [ ! -f "$ruleset_file" ]; then warn "no ${ruleset_file} found — skipping ruleset import" - record_phase "7. Ruleset" "skip" + record_phase "8. Ruleset" "skip" return fi @@ -762,17 +903,17 @@ phase_ruleset() { if printf '%s\n' "$existing" | grep -qxF "main-branch-protection"; then ok "ruleset 'main-branch-protection' already exists — rulesets are create-once, this run will NOT sync changes; delete it on GitHub (Settings → Rules → Rulesets) and re-run to update" - record_phase "7. Ruleset" "skip" + record_phase "8. Ruleset" "skip" return fi run_or_dry gh api -X POST "repos/${REPO}/rulesets" --input "$ruleset_file" \ - || { fail "gh api ruleset POST failed"; record_phase "7. Ruleset" "fail"; return 1; } + || { fail "gh api ruleset POST failed"; record_phase "8. Ruleset" "fail"; return 1; } ok "ruleset 'main-branch-protection' created" - record_phase "7. Ruleset" "ok" + record_phase "8. Ruleset" "ok" } -# --- Phase 8 — De-template --- +# --- Phase 9 — De-template --- CHANGELOG_SEED='# Changelog @@ -786,16 +927,16 @@ No entries yet. phase_detemplate() { if [ "$KEEP_TEMPLATE_DOCS" -eq 1 ]; then - skip "Phase 8: de-template (--keep-template-docs)" - record_phase "8. De-template" "skip" + skip "Phase 9: de-template (--keep-template-docs)" + record_phase "9. De-template" "skip" return fi - doing "Phase 8: de-template" + doing "Phase 9: de-template" if [ ! -d "docs/template" ]; then ok "docs/template/ absent — repo is already de-templated, nothing to do" - record_phase "8. De-template" "skip" + record_phase "9. De-template" "skip" return fi @@ -810,7 +951,7 @@ phase_detemplate() { if [ -n "$dirty_paths" ]; then warn "de-template skipped: affected paths have uncommitted changes — commit or stash first" printf '%s\n' "$dirty_paths" | sed 's/^/ /' - record_phase "8. De-template" "skip" + record_phase "9. De-template" "skip" return fi @@ -829,7 +970,7 @@ EOF if [ "$do_detemplate" -eq 0 ]; then skip "de-templating" - record_phase "8. De-template" "skip" + record_phase "9. De-template" "skip" return fi @@ -851,12 +992,12 @@ EOF ok "README.md replaced with ${readme_source}" else fail "mv reported success but README.md / ${readme_source} state is not as expected — refusing to remove docs/template/" - record_phase "8. De-template" "fail" + record_phase "9. De-template" "fail" return 1 fi else fail "mv ${readme_source} README.md failed — refusing to remove docs/template/" - record_phase "8. De-template" "fail" + record_phase "9. De-template" "fail" return 1 fi else @@ -866,19 +1007,19 @@ EOF if [ "$safe_to_remove_template" -ne 1 ]; then fail "de-template: mv step did not verify as safe — aborting before docs/template/ removal" - record_phase "8. De-template" "fail" + record_phase "9. De-template" "fail" return 1 fi run_or_dry rm -rf docs/template \ - || { fail "rm -rf docs/template failed"; record_phase "8. De-template" "fail"; return 1; } + || { fail "rm -rf docs/template failed"; record_phase "9. De-template" "fail"; return 1; } ok "docs/template/ removed" if [ "$DRY_RUN" -eq 1 ]; then printf '%s[dry-run]%s would reset CHANGELOG.md to its 8-line seed\n' "$C_YELLOW" "$C_RESET" else printf '%s' "$CHANGELOG_SEED" > CHANGELOG.md \ - || { fail "writing CHANGELOG.md failed"; record_phase "8. De-template" "fail"; return 1; } + || { fail "writing CHANGELOG.md failed"; record_phase "9. De-template" "fail"; return 1; } fi ok "CHANGELOG.md reset to seed" @@ -895,7 +1036,7 @@ EOF printf '%s[dry-run]%s would rewrite %s to {".": "0.0.0"}\n' "$C_YELLOW" "$C_RESET" "$manifest" else printf '{\n ".": "0.0.0"\n}\n' > "$manifest" \ - || { fail "writing ${manifest} failed"; record_phase "8. De-template" "fail"; return 1; } + || { fail "writing ${manifest} failed"; record_phase "9. De-template" "fail"; return 1; } fi ok "${manifest} rewritten to {\".\": \"0.0.0\"}" fi @@ -908,7 +1049,7 @@ follow normal Conventional Commit bumps. EOF manual "Remove the 'release-as: 0.1.0' key from release-please-config.json after your first release ships" - record_phase "8. De-template" "ok" + record_phase "9. De-template" "ok" } # --- Summary --- @@ -974,16 +1115,17 @@ run_phase() { main() { phase_preflight - # Phases 1-8: failures are collected, not fatal — preflight is the only + # Phases 1-9: failures are collected, not fatal — preflight is the only # phase whose failure aborts the whole run. run_phase phase_labels "1. Labels" run_phase phase_issue_types "2. Issue types" run_phase phase_milestone "3. Milestone" run_phase phase_project "4. Project" run_phase phase_repo_settings "5. Repo settings" - run_phase phase_actions_permission "6. Actions permission" - run_phase phase_ruleset "7. Ruleset" - run_phase phase_detemplate "8. De-template" + run_phase phase_security "6. Security" + run_phase phase_actions_permission "7. Actions permission" + run_phase phase_ruleset "8. Ruleset" + run_phase phase_detemplate "9. De-template" print_summary } From 656e4ef62795e6d2084a2fb9a1a64185a9268dc3 Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:31:16 +0800 Subject: [PATCH 3/3] fix: two P2s in the security phase from Codex review 1. An unreadable secret-scanning state was treated as disabled. security_and _analysis is only populated for callers with admin on the repo, so a token without it yields "public\tunknown\tunknown" -- a non-empty facts line that fell through to the else branch and prompted to enable, or PATCHed outright under --yes. That acts on a guess and reports a state never observed, which is the opposite of the unknown-is-not-disabled rule this phase was written around. There is now an explicit unknown branch that warns, emits a manual step, and writes nothing. 2. The Actions-permission troubleshooting entry still said phase 6 after the renumber moved it to 7, so the recovery text pointed at the Security prompt instead. Co-Authored-By: Claude Opus 5 --- docs/setup/bootstrap.md | 4 ++-- scripts/bootstrap.sh | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/setup/bootstrap.md b/docs/setup/bootstrap.md index 5c9ec1a..1ebfed9 100644 --- a/docs/setup/bootstrap.md +++ b/docs/setup/bootstrap.md @@ -341,8 +341,8 @@ rule lists via `gh api`). To pick up changes from **Settings → Rules → Rulesets**, then re-run `scripts/bootstrap.sh`. **"GitHub Actions is not permitted to create or approve pull requests"** in -the release-please workflow run — phase 6 was skipped or declined. Enable it -per the manual step above, or re-run the script and accept the phase 6 +the release-please workflow run — phase 7 was skipped or declined. Enable it +per the manual step above, or re-run the script and accept the phase 7 prompt. ## See also diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 197ea8b..689ba8d 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -761,7 +761,17 @@ phase_security() { ok "repository visibility: ${visibility}" if [ "$visibility" = "public" ]; then - if [ "$secret_scanning" = "enabled" ] && [ "$push_protection" = "enabled" ]; then + if [ "$secret_scanning" = "unknown" ] || [ "$push_protection" = "unknown" ]; then + # security_and_analysis is only populated for callers with admin on the + # repo. "unknown" therefore means COULD NOT READ, never "disabled" -- + # prompting here (or PATCHing under --yes) would act on a guess, and the + # phase would report a state it never actually observed. + warn "cannot read secret scanning state (secret scanning: ${secret_scanning}, push protection: ${push_protection})" + warn " security_and_analysis is only visible to callers with admin on ${REPO}" + warn " this is 'not readable', not 'disabled' — bootstrap will not guess" + manual "Check Settings → Advanced Security → Secret scanning and Push protection by hand; bootstrap could not read their current state" + result="warn" + elif [ "$secret_scanning" = "enabled" ] && [ "$push_protection" = "enabled" ]; then ok "secret scanning + push protection: already enabled" else cat <<'EOF'