From 284ec3e4bfab77874fd7d0797d8fb104d829365e Mon Sep 17 00:00:00 2001 From: Sal Date: Wed, 2 Sep 2026 22:20:36 +0100 Subject: [PATCH 1/2] feat(ci): enforce issue traceability on the pull request ADR-0022 moves traceability off the branch name, where it was a proxy with perfect precision and poor recall, and onto the pull request, where the predicate it stands in for was checked by nothing. Validate Issue Link passes on three outcomes and says which applied, so an exemption is visible in review rather than silent: an automation branch, the new meta:no-issue label, or a reference in the body. It reads only the pull_request event payload, so it needs no token and runs with permissions: {}. Hoist the automation prefixes to workflow-level env. Validate Branch Name and Validate Issue Link must agree on them, and a second inlined copy is how the two silently diverge; the suite now asserts there is only one. Add meta:no-issue to lib/labels.yml and runbooks/labels.md. ADR-0022 makes applying it a maintainer decision, which the runbook records. Steps 1 and 2 of #595. The context is not registered in the ruleset until it has been observed on a real pull request, per runbooks/branch-protection.md, and the branch pattern does not relax until after that. Refs #595 --- .github/workflows/commit-lint.yml | 55 +++++++++++++++++++++++++++++- lib/labels.yml | 3 ++ runbooks/labels.md | 9 +++++ scripts/test-commit-lint-policy.sh | 45 ++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 5f13500d6..3b8e0f038 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -52,6 +52,12 @@ concurrency: permissions: contents: read +# Branch names a coding agent or bot chooses, not the pull-request author. +# Shared by Validate Branch Name and Validate Issue Link, which must agree. +# Workflow-level env is exported into every step, so both read one definition. +env: + AUTOMATION_BRANCH_PATTERN: "^(dependabot|renovate|copilot|codex)/" + jobs: commit-message: name: Validate Commits @@ -142,7 +148,7 @@ jobs: # this job into a silent pass. : "${BRANCH_PATTERN:=^(feature|bug|hotfix)-[1-9][0-9]*(-[a-z0-9]+)*$}" - if echo "$BRANCH" | grep -qE '^(dependabot|renovate|copilot|codex)/' || \ + if echo "$BRANCH" | grep -qE "$AUTOMATION_BRANCH_PATTERN" || \ { [ "$REPOSITORY" = "z-shell/zi" ] && [ "$BRANCH" = "next" ]; }; then echo "✅ OK" exit 0 @@ -152,3 +158,50 @@ jobs: exit 1 fi echo "✅ Branch name valid" + + issue-link: + name: Validate Issue Link + runs-on: ubuntu-latest + permissions: {} + steps: + - name: 🔗 Check the pull request is traceable to an issue + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + BRANCH: ${{ github.head_ref }} + EXEMPT_LABEL: meta:no-issue + run: | + set -euo pipefail + + # decisions/0022-issue-traceability-on-pull-requests.md moves + # traceability off the branch name and onto the pull request. Three + # outcomes pass, and the job says which applied, so an exemption is + # visible in review rather than silent. + # + # Everything here comes from the pull_request event payload, so the + # job needs no token and no permissions. + + if printf '%s\n' "$BRANCH" | grep -qE "$AUTOMATION_BRANCH_PATTERN"; then + echo "✅ Exempt: $BRANCH is an automation branch" + exit 0 + fi + + if printf '%s\n' "$PR_LABELS" | tr ',' '\n' | grep -qxF "$EXEMPT_LABEL"; then + echo "✅ Exempt: labelled $EXEMPT_LABEL" + exit 0 + fi + + # A bare #123, or a full issue or pull-request URL. Either a closing + # keyword or a plain reference satisfies ADR-0022; both land in the + # body, so no API call is needed. + # Three accepted forms: a bare #123, the cross-repository + # owner/repo#123 shorthand, and a full issue or pull-request URL. + ISSUE_REFERENCE_PATTERN='(^|[^A-Za-z0-9_])#[1-9][0-9]*([^0-9]|$)|[A-Za-z0-9._-]+/[A-Za-z0-9._-]+#[1-9][0-9]*|https://github\.com/[^/ ]+/[^/ ]+/(issues|pull)/[1-9][0-9]*' + + if printf '%s\n' "${PR_BODY:-}" | grep -qE "$ISSUE_REFERENCE_PATTERN"; then + echo "✅ The pull request references an issue" + exit 0 + fi + + echo "::error::No issue reference found. Link the owning issue in the pull-request body (Closes #123, or a plain #123 for work an issue tracks but this does not close). If this pull request genuinely has no owning issue, such as gitlink reconciliation or a routine dependency update, a maintainer applies the ${EXEMPT_LABEL} label (decisions/0022-issue-traceability-on-pull-requests.md)." + exit 1 diff --git a/lib/labels.yml b/lib/labels.yml index 93fd59df4..bfb5d4d07 100644 --- a/lib/labels.yml +++ b/lib/labels.yml @@ -112,6 +112,9 @@ labels: - name: meta:org-tracked color: "5319e7" description: Indicates this issue has cross-repository tracking implications (synced to Linear). + - name: meta:no-issue + color: "5319e7" + description: Pull request with no owning issue by design, such as gitlink reconciliation or a routine dependency update (ADR-0022). # External automation labels retained for compatibility. # These are not part of the triage taxonomy but appear in z-shell/.github diff --git a/runbooks/labels.md b/runbooks/labels.md index 42627219e..eb51b8039 100644 --- a/runbooks/labels.md +++ b/runbooks/labels.md @@ -82,6 +82,15 @@ label is `meta:initiative`; follow `runbooks/sub-issues.md` before applying it. - `meta:initiative` - `meta:org-tracked` +- `meta:no-issue` + +`meta:no-issue` is the traceability exemption defined by +`decisions/0022-issue-traceability-on-pull-requests.md`. Apply it only to a +pull request that genuinely has no owning work item, such as gitlink and +submodule reconciliation or a routine dependency update. ADR-0022 makes +applying it a maintainer decision: an agent may propose it and must not apply +it on its own authority. Every other pull request closes or references an +issue instead. ## Retire old labels diff --git a/scripts/test-commit-lint-policy.sh b/scripts/test-commit-lint-policy.sh index b956b4e7a..340f1f226 100755 --- a/scripts/test-commit-lint-policy.sh +++ b/scripts/test-commit-lint-policy.sh @@ -50,8 +50,12 @@ BRANCH_PATTERN=$(extract "branch pattern" \ '^ *: "\$\{BRANCH_PATTERN:=(.*)\}"$') CONVENTIONAL_PATTERN=$(extract "conventional pattern" \ "^ *CONVENTIONAL_PATTERN='(.*)'$") -PREFIX_PATTERN=$(extract "allowed prefixes" \ - "^ *if echo \"\\\$BRANCH\" \| grep -qE '(.*)' \|\| \\\\$") +PREFIX_PATTERN=$(extract "automation prefixes" \ + "^ *AUTOMATION_BRANCH_PATTERN: '(.*)'$") +ISSUE_REFERENCE_PATTERN=$(extract "issue reference pattern" \ + "^ *ISSUE_REFERENCE_PATTERN='(.*)'$") +EXEMPT_LABEL=$(extract "exemption label" \ + '^ *EXEMPT_LABEL: (.*)$') # --- guards on the constructs themselves --------------------------------- @@ -87,6 +91,23 @@ check_no_input_defaults() { fi } +# decisions/0022: the exemption label the workflow honours has to be a label +# the organization actually publishes, or applying it is impossible. +check_exempt_label_is_canonical() { + checks=$((checks + 1)) + grep -q "^ - name: $EXEMPT_LABEL\$" "$ROOT/lib/labels.yml" || + fail "exemption label '$EXEMPT_LABEL' is not declared in lib/labels.yml (ADR-0022)" +} + +# Both jobs must read one definition of the automation prefixes. A second +# inlined copy is how the two silently diverge. +check_prefixes_not_duplicated() { + checks=$((checks + 1)) + if grep -qE "grep -qE '\\^\\(dependabot" "$WORKFLOW"; then + fail "automation prefixes are inlined as well as defined in env; the two copies will drift" + fi +} + # --- table-driven pattern cases ------------------------------------------ # assert_match