From 07ca6ec188a87b3f810af43391293e457f2d72d5 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Mon, 2 Nov 2026 15:54:34 +0000 Subject: [PATCH] ci(org-lint): every PR names an issue, and none adds CLAUDE.md or .claude Same two jobs as nyuchi/.github (issue attached, no agent-local files). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0114U9k8dcSUU3wAMZxydfmb --- .github/workflows/org-lint.yml | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/.github/workflows/org-lint.yml b/.github/workflows/org-lint.yml index e351cf3..d22c959 100644 --- a/.github/workflows/org-lint.yml +++ b/.github/workflows/org-lint.yml @@ -43,3 +43,82 @@ jobs: json-validity: false vite-plus: uses: nyuchi/.github/.github/workflows/reusable-vite-plus.yml@bb87d28e55c0c3c385121113900b698eb6ce8ab2 # v0.5.0 + + # Every pull request names the issue it serves. A closing keyword (Closes, + # Fixes, Resolves) makes GitHub close the issue when the PR merges into the + # default branch; Refs attaches an issue that stays open (partial work); and + # "No issue: " is the explicit opt-out for trivial changes. Bots are + # exempt. GitHub only acts on a closing keyword when the merge lands on the + # default branch, so a PR into staging closes nothing until the release PR to + # main repeats it: that PR is checked for the lines it is missing. + issue-link: + name: issue attached + if: github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: The PR closes or references an issue, or says why not + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BODY: ${{ github.event.pull_request.body }} + BASE: ${{ github.event.pull_request.base.ref }} + DEFAULT: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + close='(close[sd]?|fix(e[sd])?|resolve[sd]?)[: ]+([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#[0-9]+' + refs='(refs?|related to|part of)[: ]+([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#[0-9]+' + if ! printf '%s' "$BODY" | grep -qiE "\b($close|$refs)|^no issue:[[:space:]]*[^[:space:]]"; then + msg="Add 'Closes #123' to the description, 'Refs #123' for partial work," + msg="$msg or 'No issue: ' for a trivial change." + echo "::error title=No issue attached::$msg" + exit 1 + fi + echo "Issue attached." + if [ "$BASE" = "$DEFAULT" ]; then + have="$(printf '%s' "$BODY" | grep -oiE "$close" | grep -oE '#[0-9]+' | sort -u || true)" + want="$( { gh api "repos/$REPO/compare/$DEFAULT...staging" --jq '.commits[].sha' 2>/dev/null || true; } | + while read -r sha; do gh api "repos/$REPO/commits/$sha/pulls" --jq '.[].body // ""' 2>/dev/null || true; done | + grep -oiE "$close" | grep -oE '#[0-9]+' | sort -u || true)" + missing="$(comm -13 <(printf '%s\n' "$have") <(printf '%s\n' "$want") | grep . || true)" + if [ -n "$missing" ]; then + lines="$(printf '%s\n' "$missing" | sed 's/^/Closes /')" + msg="PRs in this release close issues this description does not repeat," + echo "::warning title=Release is missing closing lines::$msg so they stay open. Add:" + printf '%s\n' "$lines" + fi + fi + + # CLAUDE.md and .claude/ are development tooling. They are never tracked and + # never released, so a pull request must not add or change them (deleting + # them is fine). Only files the PR touches are checked, so a repo that still + # tracks one is not blocked: it is asked to remove it, and nothing new can + # arrive. A release PR into the default branch is a pull request too, so this + # is also what keeps them off main. + agent-files: + name: no agent-local files + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: No CLAUDE.md or .claude/ in the diff + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + touched="$(gh api "repos/$REPO/pulls/$PR/files" --paginate \ + --jq '.[] | select(.status != "removed") | .filename' | + grep -E '(^|/)(CLAUDE(\.local)?\.md|\.claude/)' || true)" + if [ -n "$touched" ]; then + msg="CLAUDE.md and .claude/ are development tooling and never reach main or production." + echo "::error title=Agent-local files::$msg Remove these from the pull request and keep them in .gitignore:" + printf '%s\n' "$touched" + exit 1 + fi + echo "No CLAUDE.md or .claude/ added or changed."