Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion .github/workflows/commit-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
3 changes: 3 additions & 0 deletions lib/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions runbooks/labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 53 additions & 3 deletions scripts/test-commit-lint-policy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ---------------------------------

Expand Down Expand Up @@ -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 <label> <pattern> <subject> <expect: match|no-match>
Expand Down Expand Up @@ -162,6 +192,23 @@ check_conventional_cases() {
assert_match subject "$p" "fix: $(printf 'x%.0s' $(seq 1 73))" no-match
}

check_issue_reference_cases() {
local p=$ISSUE_REFERENCE_PATTERN
assert_match "issue ref" "$p" 'Closes #595' match
assert_match "issue ref" "$p" 'Refs #12, and see #34' match
assert_match "issue ref" "$p" 'Fixes https://github.com/z-shell/zi/issues/486' match
assert_match "issue ref" "$p" 'follows z-shell/.github#590 for context' match
assert_match "issue ref" "$p" 'see z-shell/zi#487' match

# A body with no work item at all is the case ADR-0022 exists to catch: 10
# of the last 60 merged pull requests looked like this.
assert_match "issue ref" "$p" 'Tidies up the release script.' no-match
assert_match "issue ref" "$p" '' no-match
# A bare hash with no number, or an anchor-looking token, is not a reference.
assert_match "issue ref" "$p" 'see section #overview' no-match
assert_match "issue ref" "$p" 'issue #0 does not exist' no-match
}

# The failure mode behind #586: an empty pattern matches every line. If a
# regression ever lets an empty value reach grep, these prove it is caught.
check_empty_pattern_is_never_harmless() {
Expand All @@ -175,9 +222,12 @@ check_empty_pattern_is_never_harmless() {
check_no_grep_q_on_trailer
check_fallbacks_present
check_no_input_defaults
check_exempt_label_is_canonical
check_prefixes_not_duplicated
check_branch_cases
check_trailer_cases
check_conventional_cases
check_issue_reference_cases
check_empty_pattern_is_never_harmless

if [ "$failures" -gt 0 ]; then
Expand Down
Loading