diff --git a/.github/PROJECT_FIELDS.md b/.github/PROJECT_FIELDS.md index c4a3cbc..fe38ccf 100644 --- a/.github/PROJECT_FIELDS.md +++ b/.github/PROJECT_FIELDS.md @@ -6,7 +6,7 @@ Every issue/PR attribute lives in **exactly one place**. Never dual-write the sa | Attribute | Home | Values / format | | --- | --- | --- | -| Type (coarse) | **Native issue type** | `Bug` / `Feature` / `Task` — set by the issue form | +| Type (coarse) | **Native issue type** | `Bug` / `Feature` / `Task` — set by the issue form (no organization? see "Personal accounts") | | Type (subtype) | `type:*` **labels** | `chore` / `ops` / `docs` / `security` — Task subtypes only | | Priority | `priority:*` **labels** | `p0` critical / `p1` milestone-blocking / `p2` important / `p3` polish | | Area | `area:*` **labels** | starter set: `docs`, `skills`, `ci`, `governance` — rename to your domains | @@ -18,16 +18,48 @@ Every issue/PR attribute lives in **exactly one place**. Never dual-write the sa | Dependencies | **Native issue relationships** | GitHub blocked-by / blocking | | Epic membership | **Native sub-issues** | parent issue with sub-issues; no `epic:*` labels | -## Personal accounts +## When native issue types are unavailable -Native issue types require an organization — on personal-account repos the -coarse Type row above has no native home (`repos/{repo}/issue-types` 404s). -The issue form used at creation (bug/feature/task) still captures intent, -but GitHub silently ignores its `type:` key with no org to back it. Adopters -on personal accounts who need queryable coarse type may extend the `type:*` -labels with `bug`/`feature` as the Type home instead — this is still -single-home: labels become the *one* home for Type when native types are -unavailable, never both at once. +Native issue types began as an organization-only feature and have since been +rolled out to personal accounts as well, so **check rather than assume**: + +```bash +gh api repos/{owner}/{repo}/issue-types --jq '.[].name' +``` + +If that 404s or comes back empty, the issue forms' top-level `type:` key is +silently ignored — the form still captures intent at creation, but nothing +stores it. Bootstrap phase 2 runs this check for you and says which case you +are in. + +The coarse Type row above therefore has **no home by default** on a personal +account. There are two supported resolutions, and you pick exactly one: + +1. **Accept no coarse Type** (the default; nothing to configure). Task + subtypes, priority, area, status and milestone all still work — you simply + cannot filter by Bug vs Feature. +2. **Adopt the label fallback.** Uncomment the `type:bug` / `type:feature` + block in `.github/labels.yml` and re-run `scripts/bootstrap.sh`. Labels + become the *one* home for coarse Type, and the authority-map row above reads + `type:bug` / `type:feature` **labels** instead of Native issue type. + +This is still single-home, and it is enforced in both directions: + +- The two labels ship **commented out**, never active, so an organization repo + cannot end up holding a native `Bug` type *and* a `type:bug` label. +- Bootstrap phase 2 checks both directions. On a 404 it reports whether the + fallback is in use; when native types *are* available and the fallback is + also declared, it reports a single-home violation and tells you to re-comment + the block. +- If you later transfer the repo into an organization, removing those two + entries is part of enabling native types — not optional cleanup. +- The labels are applied **by hand**. `.github/workflows/issue-labeler.yml` + never adds or removes them: the coarse Type comes from the form's top-level + `type:` key, which is not part of the issue body the labeler parses. +- There is no `type:task`. An issue with neither label is a Task, and every + Task already carries exactly one required + `type:chore|ops|docs|security` subtype — so the Task query stays a single + positive predicate rather than a negation. ## Rules diff --git a/.github/labels.yml b/.github/labels.yml index 8f2af24..fb6488b 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -10,6 +10,14 @@ # `description:` entries only, in that order. No YAML quoting, no # multi-line block scalars, no inline comments after values. # +# Whole-line comments are fine, and are load-bearing here: the coarse-Type +# fallback block below ships commented out on purpose. Comments *after* a value +# on the same line are still unsupported. +# +# Descriptions must not contain a ": " sequence — `make lint-docs` runs +# `yamllint .` over this file, so `description: Coarse Type: Bug` is invalid +# YAML even though the awk parser would happily accept it. +# labels: - name: type:chore @@ -28,6 +36,40 @@ labels: color: d93f0b description: Security subtype (native type stays Task) + # --- Coarse Type fallback (personal accounts only) ------------------------- + # Uncomment ONLY if `gh api repos/{owner}/{repo}/issue-types` 404s or is empty + # for this repo. When it does, the coarse Type (Bug/Feature/Task) has no native + # home and the issue forms' top-level `type:` key is silently ignored. + # + # Native issue types began as an organization-only feature and have since been + # rolled out to personal accounts too, so check the endpoint rather than + # assuming from your account type -- bootstrap phase 2 checks it for you. + # + # Uncomment the two entries below ONLY on a personal-account repo where you + # want a queryable coarse Type. Labels then become the *one* home for it — see + # "Personal accounts" in .github/PROJECT_FIELDS.md. + # + # On an ORGANIZATION repo, leave them commented out. Native issue types are + # the home there, and activating these would give coarse Type two homes — the + # dual-write ADR-0003 forbids. Bootstrap phase 2 checks both directions and + # warns if it sees the wrong one. + # + # Task needs no label: an issue with neither of these is a Task, and every + # Task already carries exactly one required type:chore|ops|docs|security + # subtype, so the Task query stays a positive predicate. + # + # These are applied BY HAND. The labeler workflow never adds or removes them: + # the coarse Type comes from the form's top-level `type:` key, which is not + # part of the issue body it parses. + # + # - name: type:bug + # color: d73a4a + # description: Coarse Type Bug — label fallback where native issue types are unavailable + # + # - name: type:feature + # color: a2eeef + # description: Coarse Type Feature — label fallback where native issue types are unavailable + - name: priority:p0 color: b60205 description: Critical — drop everything diff --git a/docs/adr/ADR-0006-coarse-type-fallback.md b/docs/adr/ADR-0006-coarse-type-fallback.md new file mode 100644 index 0000000..cb50c78 --- /dev/null +++ b/docs/adr/ADR-0006-coarse-type-fallback.md @@ -0,0 +1,38 @@ +# ADR-0006: Coarse Type on accounts without native issue types + +- **Status**: Accepted +- **Date**: 2026-09-02 + +## Context + +The metadata contract (ADR-0003) gives coarse Type — Bug, Feature, Task — exactly one home: GitHub's native issue type, set by the issue form. Native issue types launched as an **organization-only** feature: on a personal-account repository `repos/{repo}/issue-types` returned 404 and the form's top-level `type:` key was silently ignored, leaving coarse Type with no home at all. GitHub has since rolled them out to user accounts — verified on this repository, a `User`-owned repo whose issue types were created 2026-08-21 — so the gap is narrower than when it was first reported, but it has not closed: the endpoint can still be unavailable, and the template must degrade correctly when it is. The condition to test is the endpoint, not the account type. + +`.github/PROJECT_FIELDS.md` already allowed `type:bug` / `type:feature` labels as a fallback, but `.github/labels.yml` shipped no such labels. An adopter following that advice had to invent names, colours and descriptions and re-derive the single-home reasoning. This is the second pass at the same gap — commit `0f912c0` documented it once already. + +The obvious fix, shipping the two labels active, is worse than it looks. An organization repo has native issue types; an active `type:bug` label would give coarse Type two homes, the exact dual-write ADR-0003 forbids. And an org adopter cannot undo it by deleting the label on GitHub, because the next `scripts/bootstrap.sh` sync recreates anything `labels.yml` still declares. The only durable fix is editing `labels.yml` — so the adopter edits that file either way. + +## Decision + +The two labels ship **commented out** in `.github/labels.yml`, with the reasoning inline, and bootstrap phase 2 enforces the contract in both directions. + +1. **Commented, not active.** The adopter edits `labels.yml` regardless; the edit should fall on the account type that *wants* the label, not the one that must not have it. It also keeps the label budget in `skills/labels-and-taxonomy/SKILL.md` literally true, and makes activation a deliberate, reviewable act. +2. **Phase 2 checks both directions.** On the 404 path it reports whether the fallback is in use and offers it if not. On the success path — native types available *and* the fallback declared — it reports a single-home violation. That second half is what a "ship them active" design cannot provide at all. +3. **Phase 2 stays read-only.** It creates no labels and can still never `fail`; it is advisory. +4. **The check reads the declared set, not the live repo.** `parse_labels_yml` on `labels.yml` keeps `--dry-run` honest: a dry run creates nothing, so a live-repo query would report "no fallback" for a repo that is about to get one. +5. **No `type:task`.** An issue with neither label is a Task, and every Task already carries exactly one required `type:chore|ops|docs|security` subtype, so the Task query stays a positive predicate rather than a negation. +6. **The labels are applied by hand.** The labeler workflow never touches them. + +## Consequences + +- Personal-account adopters get correct names, colours, descriptions and reasoning, and opt in with one edit. +- Organization adopters do nothing and cannot accidentally acquire a second Type home. +- `.github/labels.yml` now depends on whole-line comments being invisible to its awk parser. That is verified — `/^[[:space:]]*-[[:space:]]*name:/` cannot match a `#`-led line — and the file header now says the comments are load-bearing so nobody "tidies" them into active entries. +- Bootstrap phase 2 grows a second failure mode to report, and the org branch of it cannot be exercised from a personal-account maintainer's own E2E. +- This does **not** supersede ADR-0003. It implements the escape hatch ADR-0003's authority map already allowed, and keeps single-home intact by making the two homes mutually exclusive rather than concurrent. + +## Alternatives considered + +- **Ship the labels active with a "delete these on org accounts" comment.** Rejected: a comment is not an enforcement mechanism, org repos get the dual-write by default, and deleting the label on GitHub is undone by the next sync. +- **Have phase 2 create the labels on the 404 path.** Rejected on mechanism, not principle: `main()` runs `phase_labels` before `phase_issue_types`, so the next run's phase 1 sees an undeclared label and prunes it (the default answer is yes), phase 2 recreates it, forever. Avoiding the loop would need a second `autorelease:*`-style prune exclusion for a class of labels no file declares, and it breaks `labels.yml`'s single-source-of-truth claim. +- **A Project `Type` single-select field.** Rejected: `docs/setup/project-views.md` caps the board at two custom fields, and a Project field is not queryable from `gh issue list`, which is the actual requirement. +- **Teach the labeler to apply them.** Deferred, not rejected. The coarse Type comes from the form's top-level `type:` key, which never appears in the issue body, and `context.payload.issue.type` is null on personal accounts. The only body-derived signal is heading-sniffing, which breaks the moment an adopter renames a form field — and adopters are told to customise these forms. Doing it properly needs a form-identity marker in all three forms plus labeler logic to read it. diff --git a/docs/adr/README.md b/docs/adr/README.md index 908183d..8d67ad7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,3 +27,4 @@ Routine choices (a library patch bump, a wording tweak) do not get ADRs. When in | [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 | | [ADR-0005](ADR-0005-runner-selection-variable.md) | Runner selection is an adopter variable, not a workflow edit | Accepted | +| [ADR-0006](ADR-0006-coarse-type-fallback.md) | Coarse Type on accounts without native issue types | Accepted | diff --git a/docs/setup/bootstrap.md b/docs/setup/bootstrap.md index 86c92c8..af4b4e4 100644 --- a/docs/setup/bootstrap.md +++ b/docs/setup/bootstrap.md @@ -98,12 +98,26 @@ Checks whether native issue types (`Bug`/`Feature`/`Task`) are available via `gh api repos/{repo}/issue-types`. This endpoint isn't available on every plan/org configuration. -Manual: **Organization settings → Repository → Issue types** (an -organization account is required — personal accounts don't expose native -issue types). If unavailable, the issue forms' `type:` key is silently -ignored by GitHub; the form still works, it just won't set a native type. -On personal-account repos, see the "Personal accounts" note in -`.github/PROJECT_FIELDS.md` for the label-based fallback. +Manual: **Organization settings → Repository → Issue types** on an org repo, or +**Settings → Issue types** on a personal account — these began as an +organization-only feature and have since been rolled out to user accounts, so +check the endpoint rather than assuming from your account type. If they really +are unavailable, the issue forms' `type:` key is silently ignored by GitHub; the +form still works, it just won't set a native type. +On a personal account you then choose one of two things, and the phase tells +you which you currently have: + +1. **Accept no coarse Type.** Subtypes, priority, area, status and milestone all + still work; you just cannot filter Bug vs Feature. Nothing to configure. +2. **Adopt the label fallback.** Uncomment the `type:bug` / `type:feature` block + in `.github/labels.yml` and re-run. Labels become the *one* home for coarse + Type. + +They ship commented out so an organization repo — which already has native +issue types — cannot end up holding both homes at once. The phase checks that +direction too: with native types available *and* the fallback declared, it +reports a single-home violation. See "Personal accounts" in +`.github/PROJECT_FIELDS.md`. ### 3. Milestone @@ -374,11 +388,14 @@ admin, or set the four toggles by hand in **Settings → Advanced Security**. doesn't request the `project` scope. Fix: `gh auth refresh -s project`, then re-run. -**`issue-types` endpoint returns 404 / empty** — native issue types are an -organization-account feature; personal-account repos and some plans don't -expose them. The issue forms still work, but their `type:` key has no -effect until issue types are enabled at the org level (or the repo is -transferred into an org that has them). +**`issue-types` endpoint returns 404 / empty** — the repo has no native issue +types, so the issue forms' `type:` key has no effect. These began as an +organization-only feature and have since been rolled out to personal accounts +as well, so check **Settings → Issue types** (or the org equivalent) before +concluding you cannot have them. If they genuinely are unavailable and you want +a queryable coarse Type, uncomment the `type:bug` / `type:feature` block in +`.github/labels.yml` and re-run — those labels are applied by hand, never by the +labeler workflow. **Ruleset name conflict** — if a ruleset named `main-branch-protection` already exists, the script skips phase 8 rather than overwriting it (syncing diff --git a/docs/template/architecture.md b/docs/template/architecture.md index 40cf28d..5fb744c 100644 --- a/docs/template/architecture.md +++ b/docs/template/architecture.md @@ -12,7 +12,7 @@ Why each piece of this repository exists, and what it costs to keep. A component | `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md` | Per-agent entry points (≤10 lines each) | Near zero — pointers only | | `skills/` (15 modules) | On-demand knowledge for humans + agents | Content reviews; consistency enforced by `scripts/check-skills.sh` | | `.github/PROJECT_FIELDS.md` | Metadata single-home authority map | Update only when taxonomy changes (rare, ADR-worthy) | -| `.github/labels.yml` | Labels as code; bootstrap re-run = sync | Edit alongside label changes; allowlist in `issue-labeler.yml` must match | +| `.github/labels.yml` | Labels as code; bootstrap re-run = sync; carries the commented-out coarse-Type fallback (ADR-0006) | Edit alongside label changes; `FORM_MANAGED_TYPES` in `issue-labeler.yml` must match, and the commented block must stay commented | | `.github/ISSUE_TEMPLATE/` (3 forms) | Set native types; feed the labeler | Sync option lists with `labels.yml` | | `.github/PULL_REQUEST_TEMPLATE.md` | Validation ladder + RISK convention at point of use | Near zero | | `.github/workflows/ci.yml` | L0 gate; installs tools via `make ci-tools`, runs `make ci-pr`. The only required status check — see its header before touching `on:` or `runs-on:` | SHA-pin bumps via Dependabot | diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 634b980..e906ce4 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -408,6 +408,38 @@ EOF # --- Phase 2 — Issue types --- +# True when the coarse-Type label fallback is active. Reads the DECLARED set +# from labels.yml rather than querying the live repo: phase 1 has already synced +# the declaration to GitHub, and reading the file keeps --dry-run honest -- a dry +# run creates nothing, so a live-repo query would report "no fallback" on a repo +# that is about to get one. +# Echoes: none | partial | both. Checking only type:bug would misreport a +# half-uncommented pair in BOTH directions -- a repo with only type:feature +# would pass the org single-home check silently, and a repo with only type:bug +# would be told both labels are in use. +# +# Reads the DECLARED set from labels.yml rather than querying the live repo: +# phase 1 has already synced the declaration, and reading the file keeps +# --dry-run honest (a dry run creates nothing, so a live query would report "no +# fallback" for a repo that is about to get one). +coarse_type_fallback_state() { + local labels_file=".github/labels.yml" declared bug=0 feature=0 + if [ ! -f "$labels_file" ]; then + printf 'none' + return 0 + fi + declared="$(parse_labels_yml "$labels_file" | cut -f1)" + if printf '%s\n' "$declared" | grep -qxF "type:bug"; then bug=1; fi + if printf '%s\n' "$declared" | grep -qxF "type:feature"; then feature=1; fi + if [ "$bug" -eq 1 ] && [ "$feature" -eq 1 ]; then + printf 'both' + elif [ "$bug" -eq 1 ] || [ "$feature" -eq 1 ]; then + printf 'partial' + else + printf 'none' + fi +} + phase_issue_types() { doing "Phase 2: native issue types" @@ -424,11 +456,25 @@ phase_issue_types() { if [ -z "$types" ]; then warn "repos/${REPO}/issue-types returned 404/empty" - warn "native issue types are an ORGANIZATION-only GitHub feature:" - warn " - on an org repo: enable/verify Bug/Feature/Task in org settings (Organization settings -> Repository -> Issue types)" - warn " - on a PERSONAL account: this feature does not exist — issue forms' 'type:' key is silently ignored" + warn "native issue types are unavailable on this repo, so the issue forms' 'type:' key is silently ignored:" + warn " - org repo: enable/verify Bug/Feature/Task in Organization settings -> Repository -> Issue types" + warn " - personal account: GitHub rolled these out to user accounts too, so check Settings -> Issue types before assuming you cannot have them" warn " see .github/PROJECT_FIELDS.md for the documented fallback on personal accounts" - manual "Org repos: enable/verify native Bug/Feature/Task issue types in org settings. Personal accounts: the feature does not exist — see the 'Personal accounts' note in .github/PROJECT_FIELDS.md for the fallback" + manual "Enable native issue types if you can — org repos in Organization settings, personal accounts in Settings → Issue types. If they are genuinely unavailable, see 'When native issue types are unavailable' in .github/PROJECT_FIELDS.md for the label fallback" + case "$(coarse_type_fallback_state)" in + both) + ok "label fallback in use — coarse Type home is type:bug / type:feature (see .github/PROJECT_FIELDS.md)" + ;; + partial) + warn " label fallback is HALF declared — exactly one of type:bug / type:feature is uncommented in .github/labels.yml" + warn " the other coarse type has no home, so the pair is not a usable Type field" + manual "Uncomment BOTH type:bug and type:feature in .github/labels.yml (or neither) and re-run bootstrap — a half-declared fallback leaves one coarse type homeless" + ;; + *) + warn " label fallback NOT in use — this repo currently has no home for coarse Type" + manual "No coarse Type home. Either accept that (subtypes, priority and area still work), or uncomment the type:bug / type:feature block in .github/labels.yml and re-run bootstrap — see 'When native issue types are unavailable' in .github/PROJECT_FIELDS.md" + ;; + esac record_phase "2. Issue types" "warn" return fi @@ -442,9 +488,23 @@ phase_issue_types() { fi done + # Enforcement in the other direction: native types are available here, so a + # declared label fallback would give coarse Type two homes (ADR-0003). This is + # the half a "ship them active" design cannot provide at all. + local violation=0 + if [ "$(coarse_type_fallback_state)" != "none" ]; then + violation=1 + warn "single-home violation: native issue types are available AND the type:bug/type:feature fallback is declared in .github/labels.yml" + warn " coarse Type now has two homes; they will drift (ADR-0003)" + manual "Re-comment or delete the type:bug / type:feature entries in .github/labels.yml and re-run bootstrap — with native issue types available, the native type is the single home for coarse Type" + fi + if [ -n "$missing" ]; then warn "missing native issue type(s): $missing" manual "Add missing native issue type(s) in org/repo settings: $missing" + fi + + if [ -n "$missing" ] || [ "$violation" -eq 1 ]; then record_phase "2. Issue types" "warn" else record_phase "2. Issue types" "ok" diff --git a/skills/labels-and-taxonomy/SKILL.md b/skills/labels-and-taxonomy/SKILL.md index 906cfc8..5717171 100644 --- a/skills/labels-and-taxonomy/SKILL.md +++ b/skills/labels-and-taxonomy/SKILL.md @@ -24,7 +24,8 @@ Labels are cheap to create and expensive to keep meaningful — every unused or 4. **Add a label only if you will filter or automate on it.** If you can't name the `gh issue list --label` query or the workflow condition that would use it, it's documentation dressed up as taxonomy — write a sentence in the issue instead. 5. **Retiring a label is three steps, not one**: remove it from `.github/labels.yml`, delete it on GitHub (via the next `scripts/bootstrap.sh` sync or `gh label delete`), and note the removal in the PR description so the history is discoverable later. 6. **`area:*` labels are the intended adopter customization point.** The starter set (`area:docs`, `area:skills`, `area:ci`, `area:governance`) exists to be renamed to the adopter's real domains — keep the `area:` prefix so tooling and queries keep working, but the values are meant to change. -7. **Never encode workflow status in a label.** `Backlog`/`Ready`/`In Progress`/`In Review`/`Blocked`/`Done` belongs to the Project `Status` field — that's a single field with defined transitions; N status labels would be N independently-driftable booleans on the same fact. +7. **Environment-conditional labels ship commented out, never active.** A label that is correct on one account type and a contract violation on another cannot ship enabled — and "delete it on GitHub" is not a fix, because the next `scripts/bootstrap.sh` sync recreates anything `labels.yml` still declares. Ship it commented, with the reasoning inline, so activating it is a deliberate edit by the adopter who actually wants it. The coarse-Type fallback (`type:bug` / `type:feature`) is the worked example. +8. **Never encode workflow status in a label.** `Backlog`/`Ready`/`In Progress`/`In Review`/`Blocked`/`Done` belongs to the Project `Status` field — that's a single field with defined transitions; N status labels would be N independently-driftable booleans on the same fact. ## How @@ -70,6 +71,7 @@ grep -i "" .github/PROJECT_FIELDS.md - A second `priority` or `type` field appearing on the Project board that mirrors the labels — this is the exact ADR-0003 violation the contract file exists to catch in review. - Letting `area:*` sprawl past a handful of values — if every PR needs a new `area:*` label, the domains are sliced too fine; consolidate. - Deleting a label from GitHub without removing it from `.github/labels.yml` first — the next bootstrap run silently recreates it. +- Uncommenting the coarse-Type fallback on an organization repo — native issue types are already the home for coarse Type there, so this is the dual-write ADR-0003 forbids. Bootstrap phase 2 flags it. ## Related