feat: bootstrap phase 9 requires an explicit licence choice - #29
Conversation
…efaults 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ca709de87
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| *) fail "unreachable licence choice '${LICENSE_CHOICE}'"; record_phase "9. Licence" "fail"; return 1 ;; | ||
| esac | ||
| rendered="${rendered//__YEAR__/$year}" | ||
| rendered="${rendered//__HOLDER__/$LICENSE_HOLDER}" |
There was a problem hiding this comment.
Escape ampersands before substituting the holder
When the copyright holder contains &, Bash 5.2 with its default patsub_replacement setting expands that character to the matched text. I reproduced git config user.name='Smith & Jones a/s' producing Copyright (c) 2026 Smith __HOLDER__ Jones a/s while the phase reported the correct name and success. Avoid replacement-pattern expansion or escape replacement metacharacters before rendering the licence.
Useful? React with 👍 / 👎.
| if [ -f NOTICE ]; then | ||
| ok "NOTICE already exists — not overwriting" | ||
| return 0 | ||
| fi |
There was a problem hiding this comment.
Append attribution to an existing NOTICE
When an adopter already has a clean, committed NOTICE for other dependencies, this early return treats mere file existence as proof that the template's MIT notice is present. The phase then replaces LICENSE, removing the only actual copy of the template notice while reporting success. Check for the specific upstream notice and append or merge it without discarding the adopter's existing content.
Useful? React with 👍 / 👎.
|
|
||
| MIT License | ||
|
|
||
| Copyright (c) __YEAR__ __HOLDER__ |
There was a problem hiding this comment.
Put the upstream identity in the manual NOTICE
For adopters following the documented manual path, copying this block produces the literal line Copyright (c) __YEAR__ __HOLDER__, even though the section claims to show what bootstrap writes and step 2 directs users to copy it. NOTICE must retain the template's original Copyright (c) 2026 TzuH-Hsu line, not placeholders or the adopter's identity, otherwise the manual fallback fails its attribution purpose.
Useful? React with 👍 / 👎.
| fi | ||
| ok "LICENSE written (${LICENSE_CHOICE}, copyright ${year} ${LICENSE_HOLDER})" | ||
|
|
||
| write_notice || { record_phase "9. Licence" "fail"; return 1; } |
There was a problem hiding this comment.
Preserve LICENSE until NOTICE is safely written
When the existing LICENSE is writable but the directory cannot create NOTICE—for example because of directory permissions, quota, or an I/O failure—the script has already overwritten LICENSE before this call fails. The failure is merely recorded and bootstrap continues, leaving neither the original template notice nor the promised attribution file. Stage and verify NOTICE before replacing LICENSE, or roll back the licence write on failure.
Useful? React with 👍 / 👎.
| if [ "${BASH_SOURCE[0]}" = "$0" ]; then | ||
| main "$@" | ||
| fi |
There was a problem hiding this comment.
Keep argument parsing out of sourced execution
When this file is sourced from a function or script that has positional arguments, the top-level parser runs before this guard and consumes the caller's arguments; an ordinary caller argument is treated as an unknown bootstrap option and invokes exit 1. I reproduced set -- unexpected; source scripts/bootstrap.sh terminating the shell, so the newly advertised source contract only works when the caller happens to have no arguments. Guard the parsing and other executable initialization as well as main.
Useful? React with 👍 / 👎.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
# Conflicts: # docs/setup/bootstrap.md
Codex P1s addressed1.
2. Existing 3. Fixing (1) introduced a regression that a doc-vs-output diff then caught: The two P2s on this PR (write-order, and the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c2ed5024e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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 |
There was a problem hiding this comment.
Create NOTICE when a custom LICENSE is already present
When an adopter commits a new LICENSE before first running bootstrap—for example, after manually replacing the inherited MIT file—this branch treats the licence setup as complete and exits without calling write_notice. The custom licence will commonly have removed the template's MIT permission notice, leaving no upstream attribution anywhere despite phase 9's stated purpose. Preserve the adopter's LICENSE, but still ensure NOTICE contains the template notice before returning.
Useful? React with 👍 / 👎.
| if grep -qxF "$upstream_line" NOTICE; then | ||
| ok "NOTICE already carries the ${TEMPLATE_NAME} attribution — leaving it alone" | ||
| return 0 |
There was a problem hiding this comment.
Verify the full MIT notice before treating NOTICE as complete
When an existing inventory-style NOTICE contains the upstream copyright line but not the MIT permission text, this check returns early and the subsequent LICENSE replacement removes the repository's only complete copy of the MIT notice. Fresh evidence beyond the earlier file-existence issue is that the new guard verifies only one copyright line, even though both the copyright and permission notices must be retained; check for a marker covering the complete attribution block before skipping.
Useful? React with 👍 / 👎.
| 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" |
There was a problem hiding this comment.
Warn about the MIT badge whenever de-templating does not finish
When proprietary is selected but phase 10 is subsequently declined, skipped because a de-template path is dirty, or fails, KEEP_TEMPLATE_DOCS remains zero and this condition emits no remediation step. The root README.md therefore retains its License: MIT badge while linking to the newly proprietary LICENSE, so the repository publicly advertises contradictory terms. Emit the warning whenever the old README survives, rather than only when --keep-template-docs was passed.
Useful? React with 👍 / 👎.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Codex P2s addressed1. Write order. Verified by making 2. Guard scope. Reproduced your case exactly: set -- unexpected-arg; . ./scripts/bootstrap.sh # printed usage, exited the shellThe guard covered One caveat left deliberately, and documented on the helper rather than silently changed: sourcing still applies |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c1938ddf39
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # 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 |
There was a problem hiding this comment.
Confirm the whole template license before replacing it
When a clean, committed custom or composite LICENSE retains the upstream copyright line to satisfy MIT attribution, this single-line test misclassifies it as the untouched template license. A subsequent interactive choice or --license mit|proprietary then replaces the entire file, discarding the adopter's additional license terms; compare against the complete template license (or persist an explicit bootstrap marker) before allowing replacement.
Useful? React with 👍 / 👎.
| set -euo pipefail | ||
| cd "$(dirname "$0")/.." | ||
|
|
||
| if [ ! -d "docs/template" ] || [ -f "NOTICE" ]; then |
There was a problem hiding this comment.
Do not treat every NOTICE file as proof of adoption
When the template repository gains a NOTICE for an unrelated dependency, this condition permanently disables the marker check even though docs/template is still present. A later mismatch between LICENSE and TEMPLATE_COPYRIGHT_* therefore passes make check, defeating the red-build guarantee this script was added to provide; verify that the NOTICE is specifically bootstrap-generated or use a less ambiguous adoption signal.
Useful? React with 👍 / 👎.
# Conflicts: # docs/setup/bootstrap.md # scripts/bootstrap.sh
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa1e9d25ed
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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 |
There was a problem hiding this comment.
Make the starter README truthful after licence deferral
When phase 9 is deferred—explicitly or through --yes—it leaves the inherited LICENSE untouched and does not create NOTICE, but phase 10 still installs this README claiming that the licence was chosen and the attribution lives in NOTICE. I reproduced the documented defer flow resulting in a missing NOTICE and both claims appearing in the final root README; make this wording conditional or tailor the generated README to the phase-9 outcome. skills/docs-hygiene/SKILL.mdL25-L25
Useful? React with 👍 / 👎.
Summary
Adds bootstrap phase 9: forces an explicit licence choice before de-templating, and writes
NOTICEon every answer that touchesLICENSE. De-template renumbers 9 → 10.Related issue
Closes #28
Validation
make test(placeholder), plus nine behavioural tests belowbash -n+shellcheckclean on both scripts; full--dry-runagainst this repoAll nine licence paths were executed against scratch directories, never against this repo's
LICENSE:--license mitLICENSEline 3 = adopter;NOTICEcreated, 39 lines, retains the template's copyright--license proprietaryNOT LEGAL ADVICEtrailer present;NOTICEretains template copyright--license deferLICENSEbyte-identical; noNOTICEWARN stdin closed — deferring;LICENSEunchanged; noNOTICELICENSE no longer carries the template's copyright line — leaving it aloneLICENSELICENSEat allwarn+ urgent step, no crash underset -eSmith & Jones a/ssed--yesRow 4 is the one that matters most:
confirm()treats a closed stdin as "take the default", so reusing it here would have madebootstrap.sh < /dev/nullsilently choose MIT — the original bug with extra steps.scripts/check-license-marker.shwas tested on all four of its paths: drift → exit 1 with remediation; match → exit 0;NOTICEpresent → skip;docs/template/absent → skip. The two skips matter — without them the check would fail in every adopted repo the moment phase 9 does its job.Full dry run against this repository (the dogfooding case):
LICENSEbyte-identical afterwards. Running bootstrap against the template itself can never damage the upstream licence, because defer writes nothing.Risk / rollback
Checklist
*.local.mdfiles committedThe part to review first
Rewriting
LICENSE's copyright line, on its own, is an MIT violation. Substantial portions of this template ship verbatim in every adopted repo —bootstrap.shalone is now ~1,300 lines, plus the workflows,Makefile, 15 skills, issue forms — and MIT requires its copyright and permission notice to be included with them. Rewriting line 3 deletes the only copy of that notice from the repository.So
NOTICEis written on every answer that touchesLICENSE, including the MIT one. Test rows 1 and 2 assert the template's copyright survives in both. This is the load-bearing part of the change, not the prompt.Design decisions worth challenging
NOTICE, not a comment insideLICENSE. ALICENSEcontaining an all-rights-reserved notice and a verbatim MIT grant is genuinely ambiguous about what a client is receiving, and a client's counsel readsLICENSEand nothing else. Naming caveat stated rather than hidden:NOTICEcarries Apache-2.0 §4(d) baggage;THIRD-PARTY-NOTICESis equally defensible and the choice is cosmetic.No
docs/template/LICENSE.proprietary.examplefile. Under--yesthe phase writes nothing and files a manual step — and phase 10 then removesdocs/template/, deleting the example in the very run that told the adopter to go read it. The body is a script constant (mirroringCHANGELOG_SEED) and is reproduced indocs/setup/licensing.md, which survives de-templating.licensing.mdextracts both seeds frombootstrap.shat authoring time so the doc cannot drift from what the script writes.Phase 9 guards only
LICENSEandNOTICE. AddingLICENSEto phase 10's guard list would make de-template skip itself on every run once phase 9 had written. The two guards are deliberately separate;grep -n "git status --porcelain --"shows them.--license MODEis new surface. It is a mild concession against "convention over configuration", and I added it for one reason: without it neither write path can be exercised without a human at a TTY, and there is no test harness. Severable if you disagree — the phase works without it.main "$@"is nowBASH_SOURCE-guarded. Three lines.bash scripts/bootstrap.shis unaffected (verified:--helpstill works). It exists so a single phase can be sourced and called in isolation, which is how the nine rows above were produced. This changes the script's entry contract, so flag it if you would rather not.Not legal advice, and the docs say so
docs/setup/licensing.md(211 lines, of which ~75 are the two verbatim file bodies extracted from the script) ends with five questions it explicitly cannot answer and a lawyer should — including the one most likely to bite: whether your client contract warrants "no open-source components", which MIT-licensed scaffolding can breach even when correctly attributed. The proprietary body carries a self-removing "NOT LEGAL ADVICE / have counsel review, then delete this trailer" footer, and phase 9 emits the matching manual step.I am not a lawyer. The mechanics (MIT requires notice retention;
NOTICEis standard practice; relicensing a combined work is permitted) I am confident about. The contract-specific questions I am not, and the docs say so rather than guessing.