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/7] 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/7] 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 1ca709de87bddcfd1fc33d691a357040c2bd66a3 Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:46:30 +0800 Subject: [PATCH 3/7] feat: bootstrap phase 9 requires an explicit licence choice De-templating rewrote README, CHANGELOG and the release-please manifest and removed docs/template/, but never touched LICENSE -- grep for "license" in scripts/ returned nothing. So every adopted repository shipped MIT, copyright the template author. For an open-source adopter that is a wrong copyright line. For commissioned work it is an irrevocable written grant to the whole world of the right to use, modify, publish, distribute, sublicense and sell, made before payment, which removes the leverage the payment clause was built on. Four private client repos shipped that way. The obvious fix is itself a defect. Substantial portions of this template ship verbatim in every adopted repo, and MIT requires its copyright and permission notice to travel with them; rewriting LICENSE's copyright line deletes the only copy of that notice from the repository. So attribution is unconditional -- every answer that writes LICENSE also writes NOTICE, MIT-keep included. That, not the prompt, is the load-bearing part of this change. Three answers, no default. A bare Enter re-asks and a closed stdin defers, because confirm() treats closed stdin as "take the default", which here would mean silently shipping the template author's MIT -- the original bug with extra steps. --yes never writes a licence: it defers and files the decision as the first manual step, rendered above the others with a ! marker via a new MANUAL_URGENT list. --license mit|proprietary|defer answers non-interactively. Phase 9 runs before de-template and is not gated by --keep-template-docs: de-template returns early for three unrelated reasons, any of which would otherwise swallow the decision. It guards only LICENSE and NOTICE; adding LICENSE to phase 10's guard would make de-template skip itself on every run once phase 9 had written. LICENSE is regenerated from a seed rather than sed-patched, because holder names legitimately contain & and /. No docs/template/LICENSE.proprietary.example: under --yes the phase writes nothing and files a manual step, and phase 10 then removes docs/template -- deleting the example in the very run that told the adopter to read it. The body lives as a script constant, mirroring CHANGELOG_SEED, and in docs/setup/licensing.md which survives de-templating. Also adds scripts/check-license-marker.sh, wired into make check. If TEMPLATE_COPYRIGHT_* drifts from LICENSE the phase stops recognising its own licence and silently does nothing -- the original defect with no symptom. The check turns that into a red build, and no-ops in adopted repos. main is now guarded by a BASH_SOURCE test so the file can be sourced for its functions without running. Nothing in make verify covers this script; that guard is the only unit-test surface it has, and all nine licence paths were exercised through it. De-template renumbers 9 to 10. ADR-0004 records the decision and seven rejected alternatives. Closes #28 Co-Authored-By: Claude Opus 5 --- Makefile | 1 + README.md | 6 +- docs/adr/ADR-0004-adopter-licence-choice.md | 42 +++ docs/adr/README.md | 1 + docs/setup/bootstrap.md | 51 ++- docs/setup/licensing.md | 211 +++++++++++ docs/template/README.starter.md | 5 +- docs/template/architecture.md | 5 +- scripts/bootstrap.sh | 381 ++++++++++++++++++-- scripts/check-license-marker.sh | 44 +++ skills/anti-patterns/SKILL.md | 2 + skills/release-management/SKILL.md | 1 + 12 files changed, 723 insertions(+), 27 deletions(-) create mode 100644 docs/adr/ADR-0004-adopter-licence-choice.md create mode 100644 docs/setup/licensing.md create mode 100755 scripts/check-license-marker.sh diff --git a/Makefile b/Makefile index 1b76824..3be1b83 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ lint-secrets: ## Scan for committed secrets check: ## Run repo self-consistency scripts (skips scripts not yet added) @if [ -x scripts/check-skills.sh ]; then scripts/check-skills.sh; else echo "skip: scripts/check-skills.sh not present yet"; fi @if [ -x scripts/check-local-md.sh ]; then scripts/check-local-md.sh; else echo "skip: scripts/check-local-md.sh not present yet"; fi + @if [ -x scripts/check-license-marker.sh ]; then scripts/check-license-marker.sh; else echo "skip: scripts/check-license-marker.sh not present yet"; fi lint: lint-docs lint-actions lint-secrets check ## L0 - aggregate all lint/consistency checks diff --git a/README.md b/README.md index d9da3b9..63058b2 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,8 @@ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Questions and ## License -[MIT](LICENSE) +The template is [MIT](LICENSE). **Your repository does not have to be.** +Bootstrap phase 9 makes you choose — MIT under your own name, a proprietary / +all-rights-reserved notice for client and commissioned work, or decide later — +and records the scaffolding's MIT attribution in `NOTICE`, which you keep +either way. See [docs/setup/licensing.md](docs/setup/licensing.md). diff --git a/docs/adr/ADR-0004-adopter-licence-choice.md b/docs/adr/ADR-0004-adopter-licence-choice.md new file mode 100644 index 0000000..53e26e6 --- /dev/null +++ b/docs/adr/ADR-0004-adopter-licence-choice.md @@ -0,0 +1,42 @@ +# ADR-0004: The adopter's licence is an explicit bootstrap decision + +- **Status**: Accepted +- **Date**: 2026-09-02 + +## Context + +A repository created from this template starts out carrying the template's own `LICENSE`: MIT, copyright the template author. De-templating rewrote `README.md`, `CHANGELOG.md` and the release-please manifest, and removed `docs/template/` — but never touched `LICENSE`. Nothing in the script mentioned licensing at all. + +For an open-source adopter that is a wrong copyright line. For an adopter doing client or commissioned work it is considerably worse: MIT is an irrevocable, written grant of the right to use, modify, publish, distribute, sublicense and sell, made to the whole world. A commission agreement typically transfers copyright on final payment; an MIT file in the delivered repository grants far more than that, to more people, before payment, and cannot be withdrawn. It removes the leverage the payment clause was built on. The field report that prompted this ADR came from an adopter who shipped four private client repositories that way. + +The obvious fix — rewrite the copyright line to the adopter — is itself a defect. Substantial portions of the template ship verbatim in every adopted repository, and MIT requires its copyright and permission notice to be included with them. Rewriting that line deletes the only copy of the notice from the repository, trading a licensing mistake for a licence violation. + +## Decision + +Bootstrap gains **phase 9 — Licence**, before de-template, forcing an explicit choice: MIT under the adopter's name, a proprietary all-rights-reserved notice, or an explicit defer. + +1. **The phase runs before de-template and is not gated by `--keep-template-docs`.** De-template returns early for three unrelated reasons; folding the licence step into it would let any of them silently swallow the decision. Running first also means an aborted run fails into the safe state. +2. **There is no default, and a closed stdin defers.** `confirm()` treats a closed stdin as "take the default", which here would mean silently shipping the template author's MIT — the original bug with extra steps. +3. **`--yes` never writes a licence.** It defers and files the decision as the first remaining manual step, visually marked. This is the one place where the script declines to act on `--yes`. +4. **Attribution is unconditional.** Every answer that writes `LICENSE` also writes `NOTICE`, carrying the template's MIT notice. The MIT-keep path needs it exactly as much as the proprietary path. +5. **Attribution goes in `NOTICE`, never inside `LICENSE`.** A `LICENSE` containing both an all-rights-reserved notice and a verbatim MIT grant is ambiguous about what a client is receiving, and a client's counsel reads `LICENSE` and nothing else. +6. **The script ships exactly two licence bodies.** Anything else is the defer answer with a pointer to SPDX. +7. **`LICENSE` is regenerated from a seed, not patched with `sed`.** Holder names legitimately contain `&` and `/`, both `sed` replacement metacharacters. + +## Consequences + +- One more interactive prompt in an already long run, and it is the one prompt that cannot be safely skimmed. +- Adopters carry a `NOTICE` file. Licence scanners find it, which is useful when a client runs an open-source audit on delivery. +- The template must keep `TEMPLATE_COPYRIGHT_HOLDER` and `TEMPLATE_COPYRIGHT_YEAR` in sync with its own `LICENSE`. If they drift, the phase stops recognising its own licence and silently does nothing — the original defect, with no symptom. `scripts/check-license-marker.sh` turns that into a red build. +- The proprietary body is an example and says so on its face, with a self-removing trailer the adopter deletes after counsel review. +- The template now takes a position on adopters' licensing. It is a prompt, not a policy: every answer including "leave it alone" is available. + +## Alternatives considered + +- **Rewrite only the copyright line.** Manufactures an MIT violation by deleting the notice. Rejected outright. +- **Delete `LICENSE` during de-templating.** An unlicensed repository is "all rights reserved" by default in most jurisdictions, which is arguably the safest state — but it reads as an oversight rather than a decision, and breaks GitHub's licence detection. Rejected. +- **Ship more licence bodies (Apache-2.0, GPL, MPL).** Kilobytes of legal text the maintainer cannot meaningfully maintain, and any set of three is arbitrary. Rejected in favour of pointing at SPDX. +- **Attribution as a comment block inside the new `LICENSE`.** Creates grant ambiguity in the one file people actually read. Rejected. +- **Attribution in the README.** Does not survive editorial churn, and phase 10 replaces the file wholesale. Rejected. +- **A `docs/template/LICENSE.proprietary.example` file.** Under `--yes` the phase writes nothing and files a manual step, and phase 10 then removes `docs/template/` — deleting the example in the very run that told the adopter to read it. The text lives as a script constant and in `docs/setup/licensing.md` instead. Rejected. +- **Do nothing; document it in the README.** The failure mode is silent and the cost is legal. A paragraph nobody reads is not a mitigation. Rejected. diff --git a/docs/adr/README.md b/docs/adr/README.md index a27cb55..3ec458c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Routine choices (a library patch bump, a wording tweak) do not get ADRs. When in | [ADR-0001](ADR-0001-adopt-adr.md) | Adopt Architecture Decision Records | Accepted | | [ADR-0002](ADR-0002-release-flow.md) | Release flow: release-please with human-gated release PRs | Accepted | | [ADR-0003](ADR-0003-metadata-single-home.md) | Metadata single-home policy | Accepted | +| [ADR-0004](ADR-0004-adopter-licence-choice.md) | The adopter's licence is an explicit bootstrap decision | Accepted | diff --git a/docs/setup/bootstrap.md b/docs/setup/bootstrap.md index e6ac1c3..03a6c84 100644 --- a/docs/setup/bootstrap.md +++ b/docs/setup/bootstrap.md @@ -19,7 +19,8 @@ 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 9); keep `docs/template/` and the starter README. | +| `--license MODE` | Answer phase 9 non-interactively: `mit`, `proprietary` or `defer`. Without it, `--yes` defers and files the decision first. | +| `--keep-template-docs` | Skip de-templating (phase 10); keep `docs/template/` and the starter README. | | `--help` | Show usage and exit. | ## Phases, and their manual equivalent @@ -197,7 +198,53 @@ select `.github/rulesets/main-branch.json`. Review the imported rules (branch deletion/force-push blocked, PR required, `ci` status check required) and click **Create**. -### 9. De-template +### 9. Licence + +**The one phase you cannot safely skim.** Until you answer it, your repository +carries the *template's* licence — MIT, copyright the template author — and +that is almost certainly not what you want. + +MIT is an irrevocable grant: anyone who obtains a copy may use, modify, +publish, distribute, sublicense and **sell** it, and publishing it once cannot +be undone. For client or commissioned work that usually conflicts with your +contract, which typically transfers copyright on final payment — an MIT file in +the delivered repository grants the client, and everyone else, far more than +that, before you have been paid. And even if you do want MIT, the copyright +line has to name you. + +Three answers, with no default — a bare Enter re-asks: + +1. **MIT under your name.** Keeps the MIT terms, rewrites the copyright line. +2. **Proprietary / all rights reserved.** For client, commissioned and + closed-source work. Replaces `LICENSE` with an all-rights-reserved notice + that defers to your commission agreement rather than pretending to be one. +3. **Decide later.** Leaves `LICENSE` untouched and puts the decision at the + **top** of the remaining manual steps, marked `!`. + +Anything else — Apache-2.0, GPL, BUSL — is answer 3: supply the text yourself. +The script ships no other licence bodies. + +**Both writing answers also create `NOTICE`, and this is not optional.** +Substantial parts of this template ship verbatim in your repository +(`scripts/bootstrap.sh` alone is over 900 lines, plus the workflows, the +Makefile, and every skill), and MIT requires its copyright and permission +notice to travel with them. Rewriting `LICENSE` without writing `NOTICE` would +delete the only copy of that notice from your repository — swapping a licensing +mistake for a licence violation. `NOTICE` is where the attribution lives, and +it stays even if you relicense everything else. + +Under `--yes` the phase writes **nothing** and files the decision as the first +manual step, because silently keeping the template author's MIT is the bug this +phase exists to prevent. `--license mit|proprietary|defer` answers it +non-interactively. Re-running after you have decided is a no-op: the phase +recognises that `LICENSE` no longer carries the template's copyright line and +leaves it alone. If `LICENSE` or `NOTICE` have uncommitted changes the phase +skips entirely, even under `--yes`. + +Manual: see `docs/setup/licensing.md`, which carries both file bodies verbatim +and the reasoning behind them. + +### 10. De-template One-time conversion from the template product to your project: diff --git a/docs/setup/licensing.md b/docs/setup/licensing.md new file mode 100644 index 0000000..7b90a34 --- /dev/null +++ b/docs/setup/licensing.md @@ -0,0 +1,211 @@ +# Licensing + +`scripts/bootstrap.sh` phase 9 makes you choose a licence for your repository. +This page is the reasoning behind that prompt, the manual equivalent, and both +file bodies verbatim. + +**Not legal advice.** Everything here is a starting point written by the +template author, who is not a lawyer. See the last section. + +## The template's licence is not your licence + +A repository created from this template starts out carrying the *template's* +`LICENSE`: MIT, copyright the template author. Nothing about creating a +repository changes that, and until phase 9 existed nothing in bootstrap did +either. + +That is wrong for essentially everyone: + +- If you want MIT, the copyright line still has to name **you**. +- If you do not want MIT, you have shipped an irrevocable grant by accident. + +## Client and commissioned work: why MIT is the wrong default + +MIT grants anyone who obtains a copy the right to use, modify, publish, +distribute, sublicense and **sell** the work. It is written, irrevocable, and +takes effect on receipt. + +A commission or work-for-hire agreement normally says something like +"copyright transfers to the client on final payment". An MIT file in the +delivered repository is a much broader grant than that, made to the whole +world rather than the client, and made *before* payment. It does not just +leak rights — it removes the leverage the payment clause was built on. + +This is not hypothetical. The report that produced this phase came from an +adopter who shipped four private client repositories carrying the template +author's MIT licence and only noticed afterwards. + +## The three answers + +| Answer | `LICENSE` becomes | `NOTICE` | +| --- | --- | --- | +| 1. MIT under your name | MIT, copyright you | created | +| 2. Proprietary / all rights reserved | the notice below | created | +| 3. Decide later | untouched | not created | + +There is no default. A bare Enter re-asks, and a closed stdin resolves to +answer 3 — the phase would rather write nothing than guess. Under `--yes` it +always takes answer 3 and files the decision as the first remaining manual +step. `--license mit|proprietary|defer` answers it non-interactively. + +Choosing anything else — Apache-2.0, GPL, MPL, BUSL — is answer 3. Take the +text from [SPDX](https://spdx.org/licenses/) or +[choosealicense.com](https://choosealicense.com/), and still write `NOTICE`. + +## Attribution: what you must keep even if you relicense + +Substantial parts of this template ship verbatim in your repository — the +GitHub Actions workflows, the issue and pull request templates, the `Makefile`, +`scripts/` (over 900 lines in `bootstrap.sh` alone), `skills/`, and the `docs/` +structure. MIT says: + +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. + +So relicensing your repository is fine — MIT permits sublicensing, and your own +code is yours — but **you may not drop the template's notice from the +scaffolding**. Rewriting `LICENSE`'s copyright line to your name deletes the +only copy of that notice in the repository, which is why phase 9 writes +`NOTICE` on every answer that touches `LICENSE`, including the MIT one. + +`NOTICE` rather than a comment inside `LICENSE`, deliberately: a `LICENSE` file +containing both an all-rights-reserved notice and a verbatim MIT grant is +genuinely ambiguous about what a client is receiving, and a client's counsel +reads `LICENSE` and nothing else. `NOTICE` is also where licence scanners look, +which matters when a client runs an open-source audit on delivery. + +## Proprietary notice (the text bootstrap writes) + +`__YEAR__` and `__HOLDER__` are substituted with the current year and the +copyright holder you give the prompt. + +```text +PROPRIETARY SOFTWARE -- ALL RIGHTS RESERVED + +Copyright (c) __YEAR__ __HOLDER__. All rights reserved. + +1. No licence granted + + This repository and its contents (the "Work") are proprietary and + confidential. No licence, express or implied, is granted by this file. You + may not use, copy, modify, merge, publish, distribute, sublicense, sell, or + create derivative works of the Work, in whole or in part, except under a + separate written agreement signed by the copyright holder named above. + +2. Commissioned work + + If the Work was produced under a commission, services, or work-for-hire + agreement, that agreement -- not this file -- determines who owns the Work + and when ownership or a licence passes to the commissioning party (commonly + on final payment). Until the conditions of that agreement are met, all + rights remain with the copyright holder named above. This file does not + transfer, assign, or waive anything, and it does not modify that agreement. + +3. Third-party components + + The scaffolding in this repository derives from third-party open-source + software, which remains under its own licence. See the NOTICE file. + Sections 1 and 2 do not apply to those components, and nothing in NOTICE + grants any right in the rest of the Work. + +4. No warranty + + THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. + +-------------------------------------------------------------------------- +Template-generated example -- NOT LEGAL ADVICE. + +This file was written into your repository by scripts/bootstrap.sh from a +generic example shipped with a project template. It has not been reviewed by a +lawyer, it is not tailored to your jurisdiction, your business, or your +contract, and a notice file cannot override, replace, or complete the terms of +a signed agreement. Have your own counsel review it. Once they have, delete +this trailer. +-------------------------------------------------------------------------- +``` + +## NOTICE (the text bootstrap writes) + +```text +NOTICE — third-party attribution + +This repository's scaffolding — the GitHub Actions workflows, issue and pull +request templates, Makefile, scripts/, skills/, and the docs/ structure — +derives from GitHub Project OS and is used under the MIT Licence. + +The MIT Licence requires that its copyright notice and permission notice be +included in all copies or substantial portions of that software. They are +reproduced below for that purpose, and must be kept in this repository even if +the rest of it is relicensed. + +The terms below apply ONLY to that scaffolding. They grant no rights in any +other part of this repository; see LICENSE for those. + +-------------------------------------------------------------------------- +GitHub Project OS — https://github.com/TzuH-Hsu/github-project-os + +MIT License + +Copyright (c) __YEAR__ __HOLDER__ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------- +``` + +## Manual equivalent + +If you are not running bootstrap, do the same thing by hand: + +```bash +# 1. Replace LICENSE with your chosen licence, naming YOU as the holder. +# (For MIT, keep the body and change only the copyright line.) + +# 2. Create NOTICE with the block above, so the template's MIT notice survives. + +# 3. If your project is not MIT, update README.md -- the starter README's +# License section and any licence badge still point at MIT. +``` + +## Not legal advice + +The mechanics above are the defensible parts: MIT requires notice retention, +keeping that notice in `NOTICE` is standard practice, and relicensing a +combined work is permitted. The following are **not** things this page can +answer, and a lawyer should: + +1. Whether putting the notice in `NOTICE` rather than `LICENSE` satisfies + "included in all copies" for your situation. +2. Whether an all-rights-reserved notice conflicts with your commission + contract's IP clause — if that clause already assigns copyright on + signature, naming yourself as holder may be wrong from day one. +3. Whether copyright actually transfers on payment in your jurisdiction, or + needs a separate signed assignment. +4. Moral rights, which are inalienable in many jurisdictions and are not + addressed by a blanket "all rights reserved". +5. **The one most likely to bite:** whether your client contract warrants "no + open-source components" or requires a disclosed bill of materials. + MIT-licensed scaffolding, even correctly attributed, can breach such a + clause. `NOTICE` makes that visible rather than creating it — but you have + to go and read your contract. + +## See also + +- `docs/setup/bootstrap.md` — phase 9 and every other bootstrap phase +- `docs/adr/ADR-0004-adopter-licence-choice.md` — why this is a phase at all diff --git a/docs/template/README.starter.md b/docs/template/README.starter.md index 916ff5a..8aa5493 100644 --- a/docs/template/README.starter.md +++ b/docs/template/README.starter.md @@ -24,4 +24,7 @@ make verify # lint + tests — run before every PR ## License - +See `LICENSE` — chosen during bootstrap (phase 9). Third-party attribution, +including the MIT-licensed scaffolding this repository is built on, lives in +`NOTICE` and must be kept even if you relicense. Reasoning and both file +bodies: `docs/setup/licensing.md`. diff --git a/docs/template/architecture.md b/docs/template/architecture.md index 5618ca0..07528d1 100644 --- a/docs/template/architecture.md +++ b/docs/template/architecture.md @@ -20,13 +20,14 @@ Why each piece of this repository exists, and what it costs to keep. A component | `.github/workflows/maintenance.yml` | Weekly drift detectors: external link check + CI tool version check | Near zero | | `.github/workflows/release-please.yml` + configs | Human-gated release automation (ADR-0002) | Action SHA bumps; `release-as` removed after first release | | `.github/rulesets/main-branch.json` | Importable branch protection (PR + green `ci` required) | Near zero | +| `LICENSE` | The template's own licence (MIT); bootstrap phase 9 replaces it with the adopter's choice and moves upstream attribution to `NOTICE` | Near zero — the holder line is asserted against `bootstrap.sh`'s constants by `scripts/check-license-marker.sh` | | `Makefile` | The only executable contract; adopter customization point | Grows with adopter stack, not with the template | | `scripts/bootstrap.sh` | Applies everything a template can't ship as files; idempotent sync | Highest-cost component — E2E-verified each release (below) | -| `scripts/check-*.sh` | Self-consistency: skills index, local-md hygiene | Near zero | +| `scripts/check-*.sh` | Self-consistency: skills index, local-md hygiene, licence marker | Near zero | | `scripts/install-ci-tools.sh` | Checksum-verified CI tool installs; single home for all five tool version pins, shared by `ci.yml` and `maintenance.yml` via `make ci-tools` | Hand-bump a pin when the drift check flags it | | `scripts/check-tool-versions.sh` | Diffs those pins against upstream weekly and fails on drift — Dependabot cannot see them, so nothing else would | Near zero; add a row when a tool is added | | `docs/adr/` | Decision records; the "why" layer | Grows slowly by trigger criteria | -| `docs/setup/` | Bootstrap reference + manual fallback | Update alongside `bootstrap.sh` | +| `docs/setup/` | Bootstrap + licensing reference and manual fallback | Update alongside `bootstrap.sh` | | `docs/template/` | Template-product meta (this dir); deleted on adoption | Only exists upstream | ## Release exit checklist (template releases) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 197ea8b..fceb1eb 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -11,7 +11,7 @@ # updated — delete the ruleset on GitHub and re-run to pick up changes to # .github/rulesets/main-branch.json. # -# Usage: scripts/bootstrap.sh [--dry-run] [--yes] [--prune] +# Usage: scripts/bootstrap.sh [--dry-run] [--yes] [--prune] [--license MODE] # [--skip-project] [--keep-template-docs] [--help] # # bash 3.2 portable (macOS default /bin/bash). No arrays-of-arrays, no @@ -25,6 +25,9 @@ ASSUME_YES=0 PRUNE_LABELS=0 SKIP_PROJECT=0 KEEP_TEMPLATE_DOCS=0 +LICENSE_MODE="" +LICENSE_CHOICE="" +LICENSE_HOLDER="" REPO="" # owner/name OWNER="" REPO_NAME="" @@ -34,6 +37,12 @@ REPO_NAME="" PHASE_NAMES="" PHASE_RESULTS="" MANUAL_STEPS="" +# Manual steps that are unsafe to defer. Rendered ABOVE MANUAL_STEPS in the +# summary regardless of which phase recorded them, because MANUAL_STEPS is +# appended in phase order and the licence decision runs second-to-last. +# Reserved for steps where shipping without doing them is a defect, not an +# inconvenience. +MANUAL_URGENT="" # --- colors (disabled when not a tty) --- if [ -t 1 ]; then @@ -54,6 +63,8 @@ warn() { printf '%sWARN%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; } fail() { printf '%sFAIL%s %s\n' "$C_RED" "$C_RESET" "$1" >&2; } manual() { printf '%sMANUAL%s %s\n' "$C_BOLD" "$C_RESET" "$1"; MANUAL_STEPS="${MANUAL_STEPS}- ${1} "; } +manual_urgent() { printf '%sMANUAL (do this first)%s %s\n' "$C_RED" "$C_RESET" "$1"; MANUAL_URGENT="${MANUAL_URGENT}- ${1} +"; } record_phase() { # record_phase @@ -87,7 +98,9 @@ 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 9 (de-templating); keep docs/template/ and + --license MODE Choose the licence non-interactively: mit | proprietary | defer. + Without it, --yes defers and files the decision first. + --keep-template-docs Skip phase 10 (de-templating); keep docs/template/ and the starter README in place. --help Show this help and exit. @@ -101,7 +114,8 @@ Phases: 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) + 9. Licence choose YOUR licence; write NOTICE attribution + 10. 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 @@ -115,6 +129,17 @@ while [ $# -gt 0 ]; do --prune) PRUNE_LABELS=1 ;; --skip-project) SKIP_PROJECT=1 ;; --keep-template-docs) KEEP_TEMPLATE_DOCS=1 ;; + --license) + shift + if [ $# -eq 0 ]; then + fail "--license requires a value: mit | proprietary | defer" + exit 1 + fi + case "$1" in + mit|proprietary|defer) LICENSE_MODE="$1" ;; + *) fail "--license: unknown value '$1' (use mit, proprietary, or defer)"; exit 1 ;; + esac + ;; --help|-h) usage; exit 0 ;; *) fail "unknown option: $1" @@ -913,7 +938,313 @@ phase_ruleset() { record_phase "8. Ruleset" "ok" } -# --- Phase 9 — De-template --- +# --- Phase 9 — Licence --- + +# Identity of the TEMPLATE this repository was created from. These must match +# LICENSE; scripts/check-license-marker.sh asserts the first two on every +# `make check`. Anyone forking this template into a template of their own MUST +# update them together -- if they drift, phase 9 stops recognising its own +# licence and silently does nothing, which is the exact defect it exists to +# prevent. +TEMPLATE_COPYRIGHT_HOLDER="TzuH-Hsu" +TEMPLATE_COPYRIGHT_YEAR="2026" +TEMPLATE_NAME="GitHub Project OS" +TEMPLATE_URL="https://github.com/TzuH-Hsu/github-project-os" + +# __YEAR__ / __HOLDER__ are substituted with bash parameter expansion, NOT sed: +# holder names legitimately contain & and /, both sed replacement +# metacharacters. The MIT body lives here exactly once and is reused for both +# the adopter's LICENSE (answer 1) and the upstream attribution in NOTICE +# (every writing answer). +LICENSE_MIT_SEED='MIT License + +Copyright (c) __YEAR__ __HOLDER__ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +' + +# Deliberately NOT shipped as docs/template/LICENSE.proprietary.example: under +# --yes this phase writes nothing and files a manual step, and phase 10 then +# removes docs/template/ -- deleting the example in the very run that told the +# adopter to go read it. It is reproduced in docs/setup/licensing.md, which +# survives de-templating. +LICENSE_PROPRIETARY_SEED='PROPRIETARY SOFTWARE -- ALL RIGHTS RESERVED + +Copyright (c) __YEAR__ __HOLDER__. All rights reserved. + +1. No licence granted + + This repository and its contents (the "Work") are proprietary and + confidential. No licence, express or implied, is granted by this file. You + may not use, copy, modify, merge, publish, distribute, sublicense, sell, or + create derivative works of the Work, in whole or in part, except under a + separate written agreement signed by the copyright holder named above. + +2. Commissioned work + + If the Work was produced under a commission, services, or work-for-hire + agreement, that agreement -- not this file -- determines who owns the Work + and when ownership or a licence passes to the commissioning party (commonly + on final payment). Until the conditions of that agreement are met, all + rights remain with the copyright holder named above. This file does not + transfer, assign, or waive anything, and it does not modify that agreement. + +3. Third-party components + + The scaffolding in this repository derives from third-party open-source + software, which remains under its own licence. See the NOTICE file. + Sections 1 and 2 do not apply to those components, and nothing in NOTICE + grants any right in the rest of the Work. + +4. No warranty + + THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. + +-------------------------------------------------------------------------- +Template-generated example -- NOT LEGAL ADVICE. + +This file was written into your repository by scripts/bootstrap.sh from a +generic example shipped with a project template. It has not been reviewed by a +lawyer, it is not tailored to your jurisdiction, your business, or your +contract, and a notice file cannot override, replace, or complete the terms of +a signed agreement. Have your own counsel review it. Once they have, delete +this trailer. +-------------------------------------------------------------------------- +' + +license_explain() { + cat <<'EOF' + +This repository still carries the TEMPLATE's licence: MIT, copyright the +template author. That is almost certainly not what you want. + + - MIT is an irrevocable grant. Anyone who obtains a copy may use, modify, + publish, distribute, sublicense and SELL it. Publishing it once cannot be + undone. + - For client or commissioned work this is usually wrong, and can conflict + with your contract: a commission agreement normally transfers copyright on + final payment, while an MIT file in the delivered repo grants the client + (and everyone else) far more than that -- before you have been paid. + - Even if you do want MIT, the copyright line must name YOU, not the + template author. + + 1) MIT, under your name + Keeps the MIT terms, rewrites the copyright line to you. + 2) Proprietary / all rights reserved + For client, commissioned and closed-source work. Replaces LICENSE with an + all-rights-reserved notice that defers to your commission agreement. + 3) Decide later + Leaves LICENSE untouched (still the template author's MIT) and puts this + at the TOP of the remaining manual steps. + +Choosing something else entirely (Apache-2.0, GPL, BUSL...) is answer 3: pick +the text yourself. This script ships no other licence bodies. + +Either way, a NOTICE file records that this repository's scaffolding derives +from the template under the MIT licence. That attribution must be kept even if +you relicense -- see docs/setup/licensing.md. +EOF +} + +# Sets LICENSE_CHOICE to mit|proprietary|defer. Deliberately has NO default: a +# bare Enter re-asks. confirm() treats a closed stdin as "take the default", +# which here would mean silently shipping the template author's MIT -- the +# original bug with extra steps. A closed stdin, or three unusable answers, +# resolves to defer, and defer never writes. +prompt_license_choice() { + local reply attempts=0 + while [ "$attempts" -lt 3 ]; do + attempts=$((attempts + 1)) + printf '\nChoose 1, 2 or 3: ' + if ! read -r reply; then + printf '\n' + warn "stdin closed — deferring the licence decision" + LICENSE_CHOICE="defer" + return 0 + fi + case "$reply" in + 1|mit|MIT) LICENSE_CHOICE="mit"; return 0 ;; + 2|proprietary) LICENSE_CHOICE="proprietary"; return 0 ;; + 3|defer|later) LICENSE_CHOICE="defer"; return 0 ;; + '') printf 'No default here — type 1, 2 or 3.\n' ;; + *) printf 'Please answer 1, 2 or 3.\n' ;; + esac + done + warn "no valid answer after 3 attempts — deferring the licence decision" + LICENSE_CHOICE="defer" +} + +# Sets LICENSE_HOLDER. Preference: an interactive answer, then +# `git config user.name`, then the GitHub owner login. The login is a last +# resort and gets a WARN, because a handle is not the legal entity a copyright +# line should name (in this very repo the two differ). +prompt_license_holder() { + local default_holder reply + default_holder="$(git config user.name 2>/dev/null || true)" + if [ -z "$default_holder" ]; then + default_holder="$OWNER" + warn "git config user.name is unset — defaulting to the repo owner login '${OWNER}'" + fi + + if [ "$ASSUME_YES" -eq 1 ] || [ -n "$LICENSE_MODE" ]; then + LICENSE_HOLDER="$default_holder" + ok "copyright holder: ${LICENSE_HOLDER} (non-interactive)" + manual "Confirm the copyright holder in LICENSE is your correct legal name or company — bootstrap used '${LICENSE_HOLDER}' without asking" + return 0 + fi + + printf 'Copyright holder (your legal name or company) [%s]: ' "$default_holder" + if ! read -r reply; then reply=""; printf '\n'; fi + [ -n "$reply" ] || reply="$default_holder" + LICENSE_HOLDER="$reply" +} + +# NOTICE carries the TEMPLATE's MIT notice, which the adopter must retain even +# after relicensing -- rewriting LICENSE's copyright line would otherwise delete +# the only copy of it in the repository, which MIT forbids. Created only when +# absent: an adopter who has added their own third-party sections keeps them. +write_notice() { + if [ -f NOTICE ]; then + ok "NOTICE already exists — not overwriting" + return 0 + fi + + local mit_upstream body + mit_upstream="${LICENSE_MIT_SEED//__YEAR__/$TEMPLATE_COPYRIGHT_YEAR}" + mit_upstream="${mit_upstream//__HOLDER__/$TEMPLATE_COPYRIGHT_HOLDER}" + + body="NOTICE — third-party attribution + +This repository's scaffolding — the GitHub Actions workflows, issue and pull +request templates, Makefile, scripts/, skills/, and the docs/ structure — +derives from ${TEMPLATE_NAME} and is used under the MIT Licence. + +The MIT Licence requires that its copyright notice and permission notice be +included in all copies or substantial portions of that software. They are +reproduced below for that purpose, and must be kept in this repository even if +the rest of it is relicensed. + +The terms below apply ONLY to that scaffolding. They grant no rights in any +other part of this repository; see LICENSE for those. + +-------------------------------------------------------------------------- +${TEMPLATE_NAME} — ${TEMPLATE_URL} + +${mit_upstream}-------------------------------------------------------------------------- +" + + if [ "$DRY_RUN" -eq 1 ]; then + printf '%s[dry-run]%s would create NOTICE (MIT attribution for %s)\n' "$C_YELLOW" "$C_RESET" "$TEMPLATE_NAME" + return 0 + fi + printf '%s' "$body" > NOTICE || { fail "writing NOTICE failed"; return 1; } + ok "NOTICE created (MIT attribution for ${TEMPLATE_NAME})" +} + +phase_license() { + doing "Phase 9: licence" + + if [ ! -f LICENSE ]; then + warn "no LICENSE file in this repo" + manual_urgent "This repository has no LICENSE. Add one before publishing it or delivering it to anyone — see docs/setup/licensing.md" + record_phase "9. Licence" "warn" + return + fi + + # Guard exactly the paths this phase writes, same contract as phase 10's + # guard and equally not bypassed by --yes. LICENSE must NOT be added to + # phase 10's list: writing it here would dirty the worktree and make + # de-template skip itself on every run. + local dirty_paths + dirty_paths="$(git status --porcelain -- LICENSE NOTICE 2>/dev/null || true)" + if [ -n "$dirty_paths" ]; then + warn "licence step skipped: LICENSE/NOTICE have uncommitted changes — commit or stash first" + printf '%s\n' "$dirty_paths" | sed 's/^/ /' + manual_urgent "Bootstrap did not touch LICENSE (uncommitted changes present). Confirm it names YOU, not the template author, before publishing or delivering this repository — docs/setup/licensing.md" + record_phase "9. Licence" "skip" + return + fi + + # Fixed-string whole-line match avoids regex-escaping "(c)". A LICENSE that + # no longer carries the template's line was already decided by the adopter. + local template_line="Copyright (c) ${TEMPLATE_COPYRIGHT_YEAR} ${TEMPLATE_COPYRIGHT_HOLDER}" + if ! grep -qxF "$template_line" LICENSE; then + ok "LICENSE no longer carries the template's copyright line — leaving it alone" + record_phase "9. Licence" "skip" + return + fi + + if [ -n "$LICENSE_MODE" ]; then + LICENSE_CHOICE="$LICENSE_MODE" + ok "licence choice from --license: ${LICENSE_CHOICE}" + elif [ "$ASSUME_YES" -eq 1 ]; then + LICENSE_CHOICE="defer" + else + license_explain + prompt_license_choice + fi + + if [ "$LICENSE_CHOICE" = "defer" ]; then + skip "licence decision deferred — LICENSE still carries the template author's MIT" + manual_urgent "LICENSE still carries the TEMPLATE author's MIT copyright. Decide your licence BEFORE publishing this repository or delivering it to a client — MIT under your own name, proprietary/all-rights-reserved, or another licence — and record the scaffolding attribution in NOTICE. Both files are ready to copy in docs/setup/licensing.md" + record_phase "9. Licence" "warn" + return + fi + + prompt_license_holder + + local year rendered + year="$(date +%Y)" + case "$LICENSE_CHOICE" in + mit) rendered="$LICENSE_MIT_SEED" ;; + proprietary) rendered="$LICENSE_PROPRIETARY_SEED" ;; + *) fail "unreachable licence choice '${LICENSE_CHOICE}'"; record_phase "9. Licence" "fail"; return 1 ;; + esac + rendered="${rendered//__YEAR__/$year}" + rendered="${rendered//__HOLDER__/$LICENSE_HOLDER}" + + # A plain redirect, not run_or_dry: that helper is the choke point for + # mutating `gh` calls, and the CHANGELOG/manifest writes in phase 10 branch + # on DRY_RUN inline the same way. + if [ "$DRY_RUN" -eq 1 ]; then + printf '%s[dry-run]%s would write LICENSE (%s, copyright %s %s)\n' \ + "$C_YELLOW" "$C_RESET" "$LICENSE_CHOICE" "$year" "$LICENSE_HOLDER" + else + printf '%s' "$rendered" > LICENSE \ + || { fail "writing LICENSE failed"; record_phase "9. Licence" "fail"; return 1; } + fi + ok "LICENSE written (${LICENSE_CHOICE}, copyright ${year} ${LICENSE_HOLDER})" + + write_notice || { record_phase "9. Licence" "fail"; return 1; } + + if [ "$LICENSE_CHOICE" = "proprietary" ]; then + manual "Have counsel review LICENSE — it is a template-generated example — then delete the 'Template-generated example' trailer at the bottom of the file" + fi + if [ "$KEEP_TEMPLATE_DOCS" -eq 1 ] && [ "$LICENSE_CHOICE" != "mit" ]; then + manual "README.md still shows the template's MIT badge and 'License: MIT' link (you kept it via --keep-template-docs) — update both to match your new LICENSE" + fi + + record_phase "9. Licence" "ok" +} + +# --- Phase 10 — De-template --- CHANGELOG_SEED='# Changelog @@ -927,16 +1258,16 @@ No entries yet. phase_detemplate() { if [ "$KEEP_TEMPLATE_DOCS" -eq 1 ]; then - skip "Phase 9: de-template (--keep-template-docs)" - record_phase "9. De-template" "skip" + skip "Phase 10: de-template (--keep-template-docs)" + record_phase "10. De-template" "skip" return fi - doing "Phase 9: de-template" + doing "Phase 10: de-template" if [ ! -d "docs/template" ]; then ok "docs/template/ absent — repo is already de-templated, nothing to do" - record_phase "9. De-template" "skip" + record_phase "10. De-template" "skip" return fi @@ -951,7 +1282,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 "9. De-template" "skip" + record_phase "10. De-template" "skip" return fi @@ -970,7 +1301,7 @@ EOF if [ "$do_detemplate" -eq 0 ]; then skip "de-templating" - record_phase "9. De-template" "skip" + record_phase "10. De-template" "skip" return fi @@ -992,12 +1323,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 "9. De-template" "fail" + record_phase "10. De-template" "fail" return 1 fi else fail "mv ${readme_source} README.md failed — refusing to remove docs/template/" - record_phase "9. De-template" "fail" + record_phase "10. De-template" "fail" return 1 fi else @@ -1007,19 +1338,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 "9. De-template" "fail" + record_phase "10. De-template" "fail" return 1 fi run_or_dry rm -rf docs/template \ - || { fail "rm -rf docs/template failed"; record_phase "9. De-template" "fail"; return 1; } + || { fail "rm -rf docs/template failed"; record_phase "10. 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 "9. De-template" "fail"; return 1; } + || { fail "writing CHANGELOG.md failed"; record_phase "10. De-template" "fail"; return 1; } fi ok "CHANGELOG.md reset to seed" @@ -1036,7 +1367,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 "9. De-template" "fail"; return 1; } + || { fail "writing ${manifest} failed"; record_phase "10. De-template" "fail"; return 1; } fi ok "${manifest} rewritten to {\".\": \"0.0.0\"}" fi @@ -1049,7 +1380,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 "9. De-template" "ok" + record_phase "10. De-template" "ok" } # --- Summary --- @@ -1075,8 +1406,9 @@ print_summary() { esac done - if [ -n "$MANUAL_STEPS" ]; then + if [ -n "$MANUAL_URGENT" ] || [ -n "$MANUAL_STEPS" ]; then printf '\n%sRemaining MANUAL steps:%s\n' "$C_BOLD" "$C_RESET" + printf '%s' "$MANUAL_URGENT" | sed '/^$/d' | sed 's/^- / [ ] ! /' printf '%s' "$MANUAL_STEPS" | sed '/^$/d' | sed 's/^- / [ ] /' fi @@ -1115,7 +1447,7 @@ run_phase() { main() { phase_preflight - # Phases 1-9: failures are collected, not fatal — preflight is the only + # Phases 1-10: 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" @@ -1125,9 +1457,16 @@ main() { 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" + run_phase phase_license "9. Licence" + run_phase phase_detemplate "10. De-template" print_summary } -main "$@" +# Guarded so the file can be sourced to get its functions without running them. +# Nothing in `make verify` covers this script, so being able to exercise a +# single phase in isolation is the only unit-test surface it has. +# `bash scripts/bootstrap.sh` is unaffected. +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/scripts/check-license-marker.sh b/scripts/check-license-marker.sh new file mode 100755 index 0000000..6a4d0a8 --- /dev/null +++ b/scripts/check-license-marker.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# check-license-marker.sh — asserts bootstrap.sh's template-copyright constants +# still match LICENSE. +# +# Phase 9 decides "is this still the template's licence?" by looking for the +# exact line `Copyright (c) $TEMPLATE_COPYRIGHT_YEAR $TEMPLATE_COPYRIGHT_HOLDER` +# in LICENSE. If LICENSE is re-dated, or this template is forked and only one of +# the two files is updated, that test silently stops matching and phase 9 becomes +# a no-op for every adopter -- the original defect, with no symptom. This turns +# that drift into a red build within one PR. +# +# Template-only: an adopted repo has legitimately changed LICENSE, so the check +# no-ops there. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -d "docs/template" ] || [ -f "NOTICE" ]; then + echo "SKIP: repo has been adopted — this check applies only to the template itself" + exit 0 +fi + +read_const() { + sed -n "s/^${1}=\"\(.*\)\"\$/\1/p" scripts/bootstrap.sh | head -n1 +} + +holder="$(read_const TEMPLATE_COPYRIGHT_HOLDER)" +year="$(read_const TEMPLATE_COPYRIGHT_YEAR)" + +if [ -z "$holder" ] || [ -z "$year" ]; then + echo "FAIL: could not read TEMPLATE_COPYRIGHT_HOLDER / TEMPLATE_COPYRIGHT_YEAR from scripts/bootstrap.sh" + exit 1 +fi + +expected="Copyright (c) ${year} ${holder}" +if grep -qxF "$expected" LICENSE; then + echo "PASS: LICENSE carries '${expected}' (matches scripts/bootstrap.sh)" + exit 0 +fi + +echo "FAIL: LICENSE has no line '${expected}'" +echo " bootstrap phase 9 would not recognise its own template licence, and would" +echo " silently leave every adopter shipping the template author's copyright." +echo " Fix: update TEMPLATE_COPYRIGHT_* in scripts/bootstrap.sh, or LICENSE, so they agree." +exit 1 diff --git a/skills/anti-patterns/SKILL.md b/skills/anti-patterns/SKILL.md index e510b20..e0df43c 100644 --- a/skills/anti-patterns/SKILL.md +++ b/skills/anti-patterns/SKILL.md @@ -34,6 +34,7 @@ Each entry: symptom → why it happens → the fix (this repo's mechanism). | Silent validation skip | "tests didn't apply" said nowhere | Skipping is quiet; declaring feels like admitting fault | `RISK:` line convention — every skipped level is stated (`validation-ladder`) | | Deadline refactor cramming | Big refactor jammed into a release under pressure | "While we're in here" scope-creep near a cut | Refactors get their own milestone; timebox kills scope, not the deadline (`milestone-planning`) | | Agent over-trust | Merging agent PRs on the agent's own success claim | Green-looking summary reads like proof | `by-agent` label + claims-are-claims review + cross-family review (`agent-workflow`) | +| Inherited-licence leak | The delivered repo's `LICENSE` still names the template author | Templates ship a licence; setup scripts rewrite docs and never touch it | Bootstrap phase 9 forces an explicit choice; `--yes` files it as the first MANUAL step; attribution moves to `NOTICE` (ADR-0004) | Reading an entry in the wild: @@ -70,6 +71,7 @@ template ships opinionated defaults instead of leaving them to taste. - `` `.github/PROJECT_FIELDS.md` `` — the single-home authority map - `` `docs/adr/ADR-0003-metadata-single-home.md` `` — dual-home rationale - `` `skills/labels-and-taxonomy/SKILL.md` `` — label budget and retire ritual +- `` `docs/setup/licensing.md` `` — the inherited-licence failure in full, and the three answers bootstrap offers - `` `skills/github-actions-hygiene/SKILL.md` `` — workflow-count budget - `` `skills/docs-hygiene/SKILL.md` `` — documentation-drift prevention - `` `skills/validation-ladder/SKILL.md` `` — the `RISK:` convention diff --git a/skills/release-management/SKILL.md b/skills/release-management/SKILL.md index 3bfea79..89e98d9 100644 --- a/skills/release-management/SKILL.md +++ b/skills/release-management/SKILL.md @@ -64,6 +64,7 @@ gh release edit v0.2.0 --notes "TLDR: ...\n\n$(gh release view v0.2.0 --json bod ## Pitfalls +- Publishing or delivering a repository whose `LICENSE` still names the upstream template author — `head -3 LICENSE` before anything leaves the building. For client work an inherited MIT grants the client, and everyone else, far more than the commission contract does, and it cannot be withdrawn (`docs/setup/licensing.md`). - Enabling auto-merge on the release-please PR "to save a click" — this defeats the entire point of the human gate described in ADR-0002; the `push:main` race is only closed because a human reviews before merge. - Shipping release notes with only the generated Conventional Commit list and no TLDR — accurate for engineers, meaningless for the actual audience of a release announcement. - Running both release-please and the manual tag-first flow at once — pick one per repo; running both produces duplicate or conflicting tags. From a4fbc919c0a7b2ed1fbf9d6c61405f4dcf036b5a Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:11:07 +0800 Subject: [PATCH 4/7] fix: three P1 licence-phase defects from Codex review 1. An ampersand in the copyright holder corrupted LICENSE on bash >= 5.2. ${var//pat/repl} expands an unescaped & in the REPLACEMENT to the matched text when patsub_replacement is on, which is the default from 5.2. A holder of "Smith & Jones a/s" rendered as "Smith __HOLDER__ Jones a/s" while the phase reported success. My earlier test missed it because macOS /bin/bash is 3.2, where the option does not exist -- and escaping as \& is itself literal on 3.2, so no single expansion is correct on both. Replaced with subst_all, a literal prefix/suffix-removal helper that never interprets the replacement. Verified identical output on 3.2 and 5.3. 2. write_notice returned early whenever a NOTICE file existed, treating mere existence as proof the upstream notice was present. An adopter with their own NOTICE for other dependencies would have had LICENSE replaced while the only copy of the template's MIT notice was silently dropped -- the exact violation this phase exists to prevent. It now looks for the upstream copyright line, appends when absent, and keeps existing content. 3. docs/setup/licensing.md's NOTICE block shipped literal __YEAR__ __HOLDER__ placeholders, so anyone following the documented manual path produced a NOTICE with no attribution in it. It now carries the template's real copyright line, with a sentence explaining that this one is deliberately not the adopter's identity. Fixing (1) introduced a regression that the doc-vs-output diff then caught: command substitution strips trailing newlines, so LICENSE lost its final newline and the NOTICE closing rule ran into "SOFTWARE.". Both fixed, the constraint is documented on subst_all, and the doc block is now byte-identical to what the script writes. Co-Authored-By: Claude Opus 5 --- docs/setup/licensing.md | 5 ++- scripts/bootstrap.sh | 76 +++++++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/docs/setup/licensing.md b/docs/setup/licensing.md index 7b90a34..85500a8 100644 --- a/docs/setup/licensing.md +++ b/docs/setup/licensing.md @@ -127,6 +127,9 @@ this trailer. ## NOTICE (the text bootstrap writes) +The copyright line here is the **template's**, not yours, and that is the whole +point — this file exists to carry the upstream notice. Do not substitute your own +name into it; your identity belongs in `LICENSE`. ```text NOTICE — third-party attribution @@ -147,7 +150,7 @@ GitHub Project OS — https://github.com/TzuH-Hsu/github-project-os MIT License -Copyright (c) __YEAR__ __HOLDER__ +Copyright (c) 2026 TzuH-Hsu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index fceb1eb..5449f1e 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1029,6 +1029,38 @@ this trailer. -------------------------------------------------------------------------- ' +# subst_all -- literal, no pattern semantics. +# +# ${var//needle/repl} is NOT usable here. With patsub_replacement (on by +# default in bash 5.2+) an unescaped `&` in the REPLACEMENT expands to the +# matched text, so a holder like "Smith & Jones" renders as +# "Smith __HOLDER__ Jones" -- silently corrupting the copyright line of a +# LICENSE file. Escaping it as \& is itself literal on bash 3.2 (macOS +# /bin/bash), so no single expansion is correct on both. Prefix/suffix removal +# never interprets the replacement at all, on any version. +# +# Callers use $(subst_all ...), and command substitution strips ALL trailing +# newlines -- so a caller writing the result to a file must re-add one +# (printf '%s\\n'), and callers concatenating must not rely on the seed's +# trailing newline surviving. +subst_all() { + local haystack="$1" needle="$2" repl="$3" out="" head + while [ -n "$haystack" ]; do + case "$haystack" in + *"$needle"*) + head="${haystack%%"$needle"*}" + out="${out}${head}${repl}" + haystack="${haystack#*"$needle"}" + ;; + *) + out="${out}${haystack}" + haystack="" + ;; + esac + done + printf '%s' "$out" +} + license_explain() { cat <<'EOF' @@ -1121,14 +1153,25 @@ prompt_license_holder() { # the only copy of it in the repository, which MIT forbids. Created only when # absent: an adopter who has added their own third-party sections keeps them. write_notice() { + # File existence is NOT proof the template's notice is present: an adopter may + # already keep a NOTICE for their own dependencies. Returning early there would + # replace LICENSE while dropping the only copy of the upstream MIT notice -- + # exactly the violation this phase exists to prevent. Check for the notice + # itself, and append rather than overwrite. + local upstream_line="Copyright (c) ${TEMPLATE_COPYRIGHT_YEAR} ${TEMPLATE_COPYRIGHT_HOLDER}" + local append=0 if [ -f NOTICE ]; then - ok "NOTICE already exists — not overwriting" - return 0 + if grep -qxF "$upstream_line" NOTICE; then + ok "NOTICE already carries the ${TEMPLATE_NAME} attribution — leaving it alone" + return 0 + fi + append=1 + ok "NOTICE exists without the ${TEMPLATE_NAME} attribution — appending, keeping your content" fi local mit_upstream body - mit_upstream="${LICENSE_MIT_SEED//__YEAR__/$TEMPLATE_COPYRIGHT_YEAR}" - mit_upstream="${mit_upstream//__HOLDER__/$TEMPLATE_COPYRIGHT_HOLDER}" + mit_upstream="$(subst_all "$LICENSE_MIT_SEED" "__YEAR__" "$TEMPLATE_COPYRIGHT_YEAR")" + mit_upstream="$(subst_all "$mit_upstream" "__HOLDER__" "$TEMPLATE_COPYRIGHT_HOLDER")" body="NOTICE — third-party attribution @@ -1147,15 +1190,26 @@ other part of this repository; see LICENSE for those. -------------------------------------------------------------------------- ${TEMPLATE_NAME} — ${TEMPLATE_URL} -${mit_upstream}-------------------------------------------------------------------------- +${mit_upstream} +-------------------------------------------------------------------------- " if [ "$DRY_RUN" -eq 1 ]; then - printf '%s[dry-run]%s would create NOTICE (MIT attribution for %s)\n' "$C_YELLOW" "$C_RESET" "$TEMPLATE_NAME" + if [ "$append" -eq 1 ]; then + printf '%s[dry-run]%s would append the %s MIT attribution to the existing NOTICE\n' "$C_YELLOW" "$C_RESET" "$TEMPLATE_NAME" + else + printf '%s[dry-run]%s would create NOTICE (MIT attribution for %s)\n' "$C_YELLOW" "$C_RESET" "$TEMPLATE_NAME" + fi return 0 fi - printf '%s' "$body" > NOTICE || { fail "writing NOTICE failed"; return 1; } - ok "NOTICE created (MIT attribution for ${TEMPLATE_NAME})" + + if [ "$append" -eq 1 ]; then + printf '\n%s' "$body" >> NOTICE || { fail "appending to NOTICE failed"; return 1; } + ok "NOTICE appended (MIT attribution for ${TEMPLATE_NAME}; existing content kept)" + else + printf '%s' "$body" > NOTICE || { fail "writing NOTICE failed"; return 1; } + ok "NOTICE created (MIT attribution for ${TEMPLATE_NAME})" + fi } phase_license() { @@ -1217,8 +1271,8 @@ phase_license() { proprietary) rendered="$LICENSE_PROPRIETARY_SEED" ;; *) fail "unreachable licence choice '${LICENSE_CHOICE}'"; record_phase "9. Licence" "fail"; return 1 ;; esac - rendered="${rendered//__YEAR__/$year}" - rendered="${rendered//__HOLDER__/$LICENSE_HOLDER}" + rendered="$(subst_all "$rendered" "__YEAR__" "$year")" + rendered="$(subst_all "$rendered" "__HOLDER__" "$LICENSE_HOLDER")" # A plain redirect, not run_or_dry: that helper is the choke point for # mutating `gh` calls, and the CHANGELOG/manifest writes in phase 10 branch @@ -1227,7 +1281,7 @@ phase_license() { printf '%s[dry-run]%s would write LICENSE (%s, copyright %s %s)\n' \ "$C_YELLOW" "$C_RESET" "$LICENSE_CHOICE" "$year" "$LICENSE_HOLDER" else - printf '%s' "$rendered" > LICENSE \ + printf '%s\n' "$rendered" > LICENSE \ || { fail "writing LICENSE failed"; record_phase "9. Licence" "fail"; return 1; } fi ok "LICENSE written (${LICENSE_CHOICE}, copyright ${year} ${LICENSE_HOLDER})" From d2f859b2ebd1c0200c9140b007f604304a05a03e Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:11:40 +0800 Subject: [PATCH 5/7] fix: blank line before the NOTICE fence (MD031) Pushed the previous commit before reading the markdownlint result -- my command chained on git add rather than gating on lint. CI would have caught it; that is not the same as checking. Co-Authored-By: Claude Opus 5 --- docs/setup/licensing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/setup/licensing.md b/docs/setup/licensing.md index 85500a8..466411a 100644 --- a/docs/setup/licensing.md +++ b/docs/setup/licensing.md @@ -130,6 +130,7 @@ this trailer. The copyright line here is the **template's**, not yours, and that is the whole point — this file exists to carry the upstream notice. Do not substitute your own name into it; your identity belongs in `LICENSE`. + ```text NOTICE — third-party attribution 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 6/7] 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' From c1938ddf39d1523ad9a081520fdbc6e1c5c95cf3 Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:33:15 +0800 Subject: [PATCH 7/7] fix: two P2s in the licence phase from Codex review 1. LICENSE was replaced before NOTICE was known to be writable. If the NOTICE write then failed -- directory permissions, quota, I/O -- the repository was left with neither the template's original notice nor the promised attribution, a worse state than not running the phase at all. NOTICE is now written first and LICENSE is only touched once it succeeded. Verified by making NOTICE an unwritable path: the phase fails and LICENSE is still the template MIT, byte for byte. 2. The BASH_SOURCE guard covered main but not the top-level argument parser, so sourcing the file from a shell that had positional parameters consumed the caller's arguments and exit 1'd on the first one it did not recognise -- terminating the sourcing shell. Reproduced with `set -- unexpected-arg`. Both the parser and main now sit behind a bootstrap_is_main helper. Sourcing still applies set -euo pipefail to the caller; that is documented rather than silently changed. Co-Authored-By: Claude Opus 5 --- scripts/bootstrap.sh | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 69d0bd5..634b980 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -122,6 +122,17 @@ EOF } # --- arg parsing --- +# True only when this file is executed, not sourced. Sourcing must define +# functions and touch nothing else: without this the top-level argument parser +# below consumes the CALLER's positional parameters, and an ordinary caller +# argument is treated as an unknown bootstrap option and calls exit 1 -- which +# terminates the sourcing shell. +# +# Caveat that remains by design: sourcing still applies `set -euo pipefail` to +# the caller. Source from a subshell if that matters. +bootstrap_is_main() { [ "${BASH_SOURCE[0]}" = "$0" ]; } + +if bootstrap_is_main; then while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1 ;; @@ -149,6 +160,7 @@ while [ $# -gt 0 ]; do esac shift done +fi # confirm → returns 0 for yes, 1 for no. Always # yes under --yes. @@ -1287,6 +1299,13 @@ phase_license() { # A plain redirect, not run_or_dry: that helper is the choke point for # mutating `gh` calls, and the CHANGELOG/manifest writes in phase 10 branch # on DRY_RUN inline the same way. + # NOTICE FIRST, deliberately. If the attribution cannot be written -- directory + # permissions, quota, I/O -- LICENSE must be left exactly as it was. Writing + # LICENSE first and failing here would leave the repository with neither the + # template's original notice nor the promised attribution, which is a worse + # state than not having run the phase at all. + write_notice || { record_phase "9. Licence" "fail"; return 1; } + if [ "$DRY_RUN" -eq 1 ]; then printf '%s[dry-run]%s would write LICENSE (%s, copyright %s %s)\n' \ "$C_YELLOW" "$C_RESET" "$LICENSE_CHOICE" "$year" "$LICENSE_HOLDER" @@ -1296,8 +1315,6 @@ phase_license() { fi ok "LICENSE written (${LICENSE_CHOICE}, copyright ${year} ${LICENSE_HOLDER})" - write_notice || { record_phase "9. Licence" "fail"; return 1; } - if [ "$LICENSE_CHOICE" = "proprietary" ]; then manual "Have counsel review LICENSE — it is a template-generated example — then delete the 'Template-generated example' trailer at the bottom of the file" fi @@ -1531,6 +1548,6 @@ main() { # Nothing in `make verify` covers this script, so being able to exercise a # single phase in isolation is the only unit-test surface it has. # `bash scripts/bootstrap.sh` is unaffected. -if [ "${BASH_SOURCE[0]}" = "$0" ]; then +if bootstrap_is_main; then main "$@" fi