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..11d8042b8 100755 --- a/scripts/test-commit-lint-policy.sh +++ b/scripts/test-commit-lint-policy.sh @@ -29,9 +29,18 @@ fail() { # Pull the single capture of an anchored extraction, failing loudly when the # workflow no longer has the shape this file assumes. +# +# Prettier owns YAML formatting here and rewrites scalar quoting at will, so +# every extraction strips one matching pair of surrounding quotes rather than +# depending on which style it last chose. Patterns inside a run: block scalar +# are untouched by prettier, but the same handling costs nothing. extract() { local label=$1 regex=$2 value value=$(sed -nE "s/$regex/\1/p" "$WORKFLOW") + case $value in + \'*\') value=${value#\'}; value=${value%\'} ;; + '"'*'"') value=${value#'"'}; value=${value%'"'} ;; + esac if [ -z "$value" ]; then printf 'FAIL: could not extract %s from %s\n' "$label" "$WORKFLOW" >&2 printf ' the workflow changed shape; re-point this test\n' >&2 @@ -49,9 +58,13 @@ TRAILER_PATTERN=$(extract "trailer pattern" \ BRANCH_PATTERN=$(extract "branch pattern" \ '^ *: "\$\{BRANCH_PATTERN:=(.*)\}"$') CONVENTIONAL_PATTERN=$(extract "conventional pattern" \ - "^ *CONVENTIONAL_PATTERN='(.*)'$") -PREFIX_PATTERN=$(extract "allowed prefixes" \ - "^ *if echo \"\\\$BRANCH\" \| grep -qE '(.*)' \|\| \\\\$") + "^ *CONVENTIONAL_PATTERN=(.*)$") +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 +100,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