Skip to content

Commit ee69c34

Browse files
uipreligaclaude
andauthored
ci: publish a GitHub Release on every release (#52)
* ci: publish a GitHub Release on every release Releases were tag-only: semantic-release runs with --no-vcs-release, so the repo had zero GitHub Releases. A published Release is what a Marketplace listing is cut from, and it is also where users look for notes. Create the Release explicitly after the tag pushes rather than dropping --no-vcs-release: semantic-release runs --no-push so the commit can be amended (uv.lock + the action.yml pin) and the tag re-pointed, which means the tag is not on the remote at the point semantic-release would publish. Notes come from the CHANGELOG section semantic-release just generated for the version, falling back to GitHub's generated notes with a ::warning:: rather than failing a release that has already pushed main and tags. The step authenticates with the release app token, not GITHUB_TOKEN, so the workflow's contents: read permission stays as documented. Also refresh the action.yml `version:` pin, which was stale at 0.8.6 — the in-release bump step only landed after the last release was cut, so @main consumers were installing 0.8.6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: code review fixes Two findings, each confirmed independently by all three reviewers (gemini-3.1-pro, gpt-5.6-sol, opus). Write the release notes under RUNNER_TEMP instead of the repo root. The step wrote release-notes.md into the working tree two steps before `uv build`, and hatchling has no [tool.hatch.build.targets.sdist] config, so its default file selection swept the file into the published sdist. Verified by building: `coder_eval-0.8.9/release-notes.md` was present in the tarball. Adding the path to .gitignore would not have been a reliable fix -- the sdist already ships evalboard/node_modules/** despite that path being git-ignored. Make the step best-effort. main, the version tag, and the moving major tag are all pushed by the time it runs, so a transient `gh release create` failure aborted the job, skipped `Build wheel + sdist`, and left publish-pypi unrun -- stranding `@vN` on an action.yml pin whose version never reached PyPI. This mirrors the existing precedent in the same file: 522dbc7 ("tag only after PyPI publishes") made the GHCR steps continue-on-error for exactly this reason. Also narrows the header comment, which claimed a broader fallback than the `if m else ""` branch actually provided, and defers an sdist-contents guardrail to .claude/harness-candidates.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address PR #52 review feedback on the release-publishing step Reviewer items acted on: - Move "Publish GitHub Release" after "Build wheel + sdist" and "Upload dist for PyPI publish". Those are the last steps that can still fail for an already-tagged version, so a Release no longer announces a version whose artifacts never built, and a hung `gh` call can no longer eat the 15-minute job budget before the artifacts are safe. - Make the swallowed `continue-on-error` failure loud: a new "Flag missing GitHub Release" step re-raises it as an ::error annotation plus a run-summary block with the by-hand recovery command, without failing the job (which would skip the `needs: release` PyPI publish). - Extract the CHANGELOG notes slicing out of the `run:` heredoc into .github/scripts/release_notes.py, covered by tests/test_release_notes.py. Heredoc code is invisible to ruff, pyright, pytest and coverage, and this regex has three failure modes on a path that runs once per release against production main with no rehearsal (the prerelease dispatch skips the step). Tests pin the load-bearing \b (0.8.1 must not slice the v0.8.10 section), the \Z branch, re.escape, the empty-file fallback contract, and couple the regex to semantic-release's real rendering of CHANGELOG.md. - Pin encoding="utf-8" on every read_text/write_text in workflow-embedded Python (release.yml, publish-testpypi.yml). CHANGELOG.md carries non-ASCII, so a non-UTF-8 locale raised UnicodeDecodeError inside a continue-on-error step: no Release, still green. - Quote the publish-testpypi.yml heredoc delimiter (<<'PY') and read DEV_VERSION from os.environ instead of expanding it into the Python source. - Add tests/test_action_version_pin.py: action.yml's `version:` default must equal pyproject.toml's version, and the `# <-- kept in sync` sed anchor must be present and unique. Nothing detected the 0.8.6-vs-0.8.9 drift this PR hand-fixed, which would have had @v0 consumers installing a version other than the tag they pinned. - Fix the misleading step comment ("two steps below", "publishes to PyPI") and update the workflow header, which still omitted the GitHub Release from both the outputs list and the PRERELEASE exclusion list. - Record the accepted risk on the Release body: notes render squashed PR titles, so a first-party surface carries text reviewed as code, not markdown. Also note that Marketplace listing needs a one-time manual checkbox. - Widen the Makefile lint scope to .github/scripts/ so extracted release tooling is actually linted. Rescoped the deferred sdist note in .claude/harness-candidates.md: it claimed the sdist "already ships evalboard/node_modules/**" to PyPI. Verified false for published artifacts — the 0.8.9 and 0.8.2 sdists on PyPI are ~7.5 MB with zero node_modules entries, because CI never runs pnpm install. It reproduces only in a developed local worktree (135 MB, 8520 files), since hatchling honors just the root .gitignore. The live hazard is untracked files a workflow leaves at the root, which is what the $RUNNER_TEMP note guards. Also recorded CE032/CE033 and actionlint/zizmor as deferred candidates. Declined: swapping the app token for GITHUB_TOKEN + job-level contents: write. actions/checkout already persists the same app token in .git/config for every step in the job, so scoping it out of this one step's env buys no isolation while adding a second write credential. Rationale recorded in the step comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ab1c7cf commit ee69c34

7 files changed

Lines changed: 464 additions & 15 deletions

File tree

.claude/harness-candidates.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,60 @@ Deferred lint/test guardrails surfaced during reviews. Promote to a `CExxx` rule
4343
`test_auto_mixed_pass_stops_ignoring_undecided_distractors` +
4444
`test_mixed_static_arming_pass_stops_ignoring_fail_armed`.
4545

46+
## From 2026-07-24 publish-github-releases review
47+
48+
- [ ] **sdist-contents assertion** — build the sdist and assert it contains only
49+
intended paths. `pyproject.toml` declares no `[tool.hatch.build.targets.sdist]`
50+
section, so hatchling's default selection honors only the **root** `.gitignore`
51+
and sweeps in everything else sitting in the tree at build time. Two distinct
52+
consequences, worth keeping apart:
53+
- **What actually reaches PyPI today: nothing unintended.** A local `uv build`
54+
in a developed worktree produces a 135 MB sdist carrying
55+
`evalboard/node_modules/**` (8520 files) and `evalboard/.next/**` (190),
56+
because `evalboard/.gitignore` is nested and therefore not honored. CI is
57+
spared only incidentally — `release.yml` never runs `npm`/`pnpm install`, so
58+
those paths do not exist on the runner at `uv build` time. Verified against
59+
the published artifacts: the 0.8.9 and 0.8.2 sdists on PyPI are ~7.5 MB /
60+
~550 files with **zero** `node_modules` entries. (A dirty-tree release would
61+
not silently ship JS either — 135 MB exceeds PyPI's 100 MB per-file limit, so
62+
it fails at upload. The real exposure is a broken release, not a stealth one.)
63+
- **The live hazard is untracked files a workflow leaves in the tree**, which
64+
hatchling *does* package: a `release-notes.md` written at the repo root by a
65+
CI step landed in `coder_eval-X.Y.Z/release-notes.md` (verified by building
66+
it). This is why the "Publish GitHub Release" step writes to
67+
`${RUNNER_TEMP}` — a convention no check enforces.
68+
69+
Not cheap: needs a real `uv build` inside the test suite (slow) plus a decision
70+
on whether to add an explicit sdist include/exclude allowlist, which changes
71+
published artifacts. Worth pairing with the allowlist so the contract is
72+
declared rather than inferred from hatchling's defaults — caught in the
73+
2026-07-24 ci/publish-github-releases review.
74+
- [ ] **CE032 — run the existing AST lint rules over Python embedded in
75+
`.github/workflows/*.yml`.** CE008/CE009/CE010 already forbid unencoded
76+
`read_text`/`open`/`subprocess.run`, but `tests/lint/runner.py::check_paths`
77+
walks only `*.py` under `src/`, so Python inside a `run:` heredoc is invisible
78+
to ruff, pyright, pytest, coverage *and* the CE runner. Would have caught the
79+
four unencoded `read_text`/`write_text` calls fixed by hand in this review
80+
(`release.yml` ×2, `publish-testpypi.yml` ×2). Needs a heredoc extractor
81+
(`python3 - <<'PY' … PY` → dedent → `ast.parse`) with line-number mapping back
82+
to the YAML; wire as a `tests/test_custom_lint.py` class like CE027–CE031
83+
rather than a `BaseRule`. Also consider extending CE008 to `write_text` (it
84+
matches only `read_text` today, though `src/` happens to be clean).
85+
- [ ] **CE033 — interpreter heredocs in `.github/workflows/**` must use a quoted
86+
delimiter** (`<<'PY'`, not `<<PY`). With a bare tag the shell expands `$VAR`
87+
into the *program text* before the interpreter parses it, so a value containing
88+
a quote or newline breaks out of the string literal it lands in. Fixed by hand
89+
in `publish-testpypi.yml` in this review (it interpolated `${DEV_VERSION}` into
90+
Python source); regex-detectable in ~10 lines, and CE032's `ast.parse` is only
91+
sound on quoted bodies, so the two ship together.
92+
- [ ] **`actionlint` + `zizmor` over `.github/workflows/**`.** No static analysis
93+
whatsoever runs over workflow YAML today (`make verify` never looks at it), so
94+
every workflow finding in the 2026-07-24 review was caught by a human reading
95+
it. `actionlint` runs shellcheck over `run:` bodies; `zizmor`'s
96+
`excessive-permissions` / `artipacked` / `template-injection` rules cover the
97+
credential-scoping and `${{ }}`-into-`run:` classes reviewed by hand. Subsumes
98+
the CE026 SHA-pinning candidate above. Start as a non-blocking annotation job.
99+
46100
## From 2026-07-03 open-source docs cleanup
47101

48102
- [ ] **Dead-relative-link checker for `docs/**/*.md`** — resolve every relative

.github/scripts/release_notes.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env python3
2+
"""Slice one version's section out of ``CHANGELOG.md`` for a GitHub Release body.
3+
4+
Used by ``.github/workflows/release.yml``'s "Publish GitHub Release" step.
5+
6+
This lives in a real module rather than inline in a ``run:`` heredoc because
7+
code inside a heredoc is structurally invisible to ruff, pyright, pytest and
8+
coverage — and this particular code has three distinct failure modes (version
9+
absent, section is the file's last, version string carrying regex
10+
metacharacters) on a path that executes exactly once per release, against
11+
production ``main``, after the tag is already pushed. The prerelease dispatch
12+
skips the step, so there is no rehearsal; ``tests/test_release_notes.py`` is
13+
what exercises it before it matters.
14+
15+
Usage::
16+
17+
release_notes.py <version> <output-path>
18+
19+
Writes the section body (heading line excluded, stripped) to ``<output-path>``,
20+
or an empty file when no matching section exists — the caller treats an empty
21+
file as "fall back to GitHub's generated notes".
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import re
27+
import sys
28+
from pathlib import Path
29+
30+
31+
_REPO_ROOT = Path(__file__).resolve().parents[2]
32+
33+
# semantic-release's default changelog template renders headings as
34+
# "## v0.8.9 (2026-07-23)". Capture everything after that heading line up to the
35+
# next "## v" heading, or EOF for the newest entry (the common case on a release
36+
# run, since the just-generated section is at the top of the file).
37+
#
38+
# The trailing \b on the version is load-bearing: without it "0.8.1" would match
39+
# the "## v0.8.10" heading and publish the wrong section.
40+
_SECTION_TEMPLATE = r"(?m)^## v{version}\b.*?$(.*?)(?=^## v|\Z)"
41+
42+
43+
def extract_section(changelog_text: str, version: str) -> str:
44+
"""Return the changelog body for ``version``, or ``""`` when absent.
45+
46+
``version`` is regex-escaped, so a PEP 440 local/prerelease version
47+
containing metacharacters (``+``, ``!``) is matched literally.
48+
"""
49+
pattern = _SECTION_TEMPLATE.format(version=re.escape(version))
50+
match = re.search(pattern, changelog_text, re.S)
51+
return match.group(1).strip() if match else ""
52+
53+
54+
def main(argv: list[str]) -> int:
55+
if len(argv) != 3:
56+
print("usage: release_notes.py <version> <output-path>", file=sys.stderr)
57+
return 2
58+
version, out_path = argv[1], argv[2]
59+
# Encoding is pinned rather than left to the ambient locale: CHANGELOG.md
60+
# verifiably carries non-ASCII (arrows, em dashes, check marks), so a
61+
# non-UTF-8 locale would raise UnicodeDecodeError here and — under the
62+
# caller's `continue-on-error: true` — silently publish no Release at all.
63+
text = (_REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
64+
body = extract_section(text, version)
65+
Path(out_path).write_text(body, encoding="utf-8")
66+
if not body:
67+
# Surfaced as a workflow annotation; the caller falls back to
68+
# --generate-notes when the file is empty.
69+
print(f"::warning::no CHANGELOG section found for v{version}; using GitHub's generated notes")
70+
return 0
71+
72+
73+
if __name__ == "__main__":
74+
raise SystemExit(main(sys.argv))

.github/workflows/publish-testpypi.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,14 @@ jobs:
6464
CURRENT_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
6565
# PEP 440 dev release: sorts before the base release, unique per run.
6666
DEV_VERSION="${CURRENT_VERSION}.dev${RUN_NUMBER}"
67-
python3 - <<PY
68-
import re, pathlib
67+
# Exported so the heredoc below can read it from os.environ. The delimiter
68+
# is QUOTED (<<'PY'): with a bare <<PY the shell expands $DEV_VERSION into
69+
# the Python source text, so a value containing a quote or newline would
70+
# break out of the string literal it lands in.
71+
export DEV_VERSION
72+
python3 - <<'PY'
73+
import os, re, pathlib
74+
version = os.environ["DEV_VERSION"]
6975
# pyproject.toml is the canonical source; __init__ is kept in sync.
7076
for path, pat in (
7177
("pyproject.toml", r'(?m)^version\s*=\s*".+?"$'),
@@ -76,10 +82,12 @@ jobs:
7682
# re.subn (not re.sub) so a version-line format drift aborts loudly
7783
# instead of silently no-op'ing (rc=0) and publishing a stale/base
7884
# version that then collides on TestPyPI (masked by skip-existing).
79-
new, n = re.subn(pat, f'{key} = "${DEV_VERSION}"', p.read_text(), count=1)
85+
# encoding pinned rather than left to the ambient locale, which would
86+
# otherwise raise UnicodeDecodeError mid-publish on a non-ASCII source.
87+
new, n = re.subn(pat, f'{key} = "{version}"', p.read_text(encoding="utf-8"), count=1)
8088
if n != 1:
8189
raise SystemExit(f"version pattern did not match {path} (matched {n}); refusing to publish a stale/colliding artifact")
82-
p.write_text(new)
90+
p.write_text(new, encoding="utf-8")
8391
PY
8492
echo "Stamped dev version: ${DEV_VERSION}"
8593

.github/workflows/release.yml

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
name: Release
22

3-
# Manually-triggered release: bump the version, tag, build, and publish to
4-
# public PyPI (wheel) and GHCR (agent image). Merges to main do NOT
5-
# auto-release -- run this workflow from the Actions tab when you want to cut a
6-
# release.
3+
# Manually-triggered release: bump the version, tag, build, publish to public
4+
# PyPI (wheel) and GHCR (agent image), and cut a GitHub Release for the tag.
5+
# Merges to main do NOT auto-release -- run this workflow from the Actions tab
6+
# when you want to cut a release.
77
#
88
# The bump level is CHOSEN at dispatch, not derived from commit messages:
99
# `patch` (default), `minor`, or `major`. A dispatch always cuts a release.
@@ -17,7 +17,8 @@ name: Release
1717
# PRERELEASE mode (dispatched from a NON-main branch): instead of bumping and
1818
# pushing main, stamp a throwaway `<next-patch>rc<run#>` version, then build and
1919
# publish the wheel to public PyPI + a `:<version>` GHCR image. It does NOT move
20-
# `:latest`, tag, commit, or push to main. This lets a branch be dry-run on the
20+
# `:latest`, tag, commit, push to main, or create a GitHub Release (there is no
21+
# tag for a Release to point at). This lets a branch be dry-run on the
2122
# ADO nightly infra before merge (pinned via the pipeline's `coderEvalVersion`).
2223
# The `bump` input is ignored off main. On `main` the behavior is unchanged.
2324

@@ -160,10 +161,12 @@ jobs:
160161
version = os.environ["PRE_VERSION"]
161162
for path, key in (("pyproject.toml", "version"), ("src/coder_eval/__init__.py", "__version__")):
162163
p = pathlib.Path(path)
163-
new, n = re.subn(rf'(?m)^{key}\s*=\s*".+?"$', f'{key} = "{version}"', p.read_text(), count=1)
164+
# encoding pinned, not left to the ambient locale: a non-UTF-8 default
165+
# would raise UnicodeDecodeError mid-release on a non-ASCII source file.
166+
new, n = re.subn(rf'(?m)^{key}\s*=\s*".+?"$', f'{key} = "{version}"', p.read_text(encoding="utf-8"), count=1)
164167
if n != 1:
165168
raise SystemExit(f"version pattern did not match {path} (matched {n})")
166-
p.write_text(new)
169+
p.write_text(new, encoding="utf-8")
167170
print(f"Stamped prerelease version: {version}")
168171
PY
169172
uv lock
@@ -241,6 +244,106 @@ jobs:
241244
path: dist/
242245
if-no-files-found: error
243246

247+
# Publish the GitHub Release for the tag pushed above. semantic-release runs
248+
# with --no-vcs-release because it also runs --no-push (the commit is amended
249+
# and the tag re-pointed first), so it cannot create the release itself -- it
250+
# happens here, once the tag is actually on the remote. A published Release is
251+
# what GitHub Marketplace listings are cut from, so every release needs one.
252+
# (`gh release create` cannot tick the "Publish this Action to the
253+
# Marketplace" checkbox -- that stays a one-time manual step in the GitHub UI
254+
# on the first Release; every subsequent release then lists automatically.)
255+
#
256+
# Deliberately placed AFTER "Build wheel + sdist" and "Upload dist for PyPI
257+
# publish" rather than at the earliest legal point after the tag push: those
258+
# two steps are the last ones that can still fail for an already-tagged
259+
# version, and a Release announcing a version whose artifacts never built is
260+
# worse than a missing Release. This narrows the window rather than closing
261+
# it -- publish-pypi is a separate job, so the actual upload to PyPI still
262+
# happens after this. Running here also keeps a slow/hung `gh` API call from
263+
# eating the 15-minute job budget BEFORE the artifacts are safe, which would
264+
# produce exactly the stranded-tag state the note below warns about.
265+
#
266+
# Notes are the CHANGELOG section semantic-release just generated for this
267+
# version, sliced by .github/scripts/release_notes.py (a real module, so the
268+
# regex is unit-tested -- see tests/test_release_notes.py); an empty result
269+
# falls back to GitHub's generated notes.
270+
#
271+
# ACCEPTED RISK: those notes render commit subjects, i.e. squashed PR titles.
272+
# The Release body is a first-party surface that GitHub also fans out in
273+
# notification emails, so it carries text that was reviewed as *code*, not as
274+
# markdown -- a PR title can land an arbitrary link in it. Bounded to
275+
# content/link spoofing (GitHub strips raw HTML from release bodies) and
276+
# gated by this repo's mandatory PR review. Revisit with `--draft` plus a
277+
# human glance, or link-stripping in release_notes.py, if the repo ever takes
278+
# drive-by contributions.
279+
- name: Publish GitHub Release
280+
id: gh_release
281+
if: steps.mode.outputs.prerelease != 'true' && steps.release.outputs.version != ''
282+
# Best-effort, mirroring the GHCR steps below. main, the version tag, the
283+
# moving major tag, and the dist artifact are all in place by the time this
284+
# runs, so a transient GitHub API failure here must not fail the job: the
285+
# publish-pypi job is `needs: release`, so a failure would SKIP the PyPI
286+
# publish of an already-tagged version and strand `@vN` on an action.yml pin
287+
# whose version was never published. The next step turns the swallowed
288+
# failure into a loud annotation instead of a collapsed step marker.
289+
continue-on-error: true
290+
env:
291+
# The app token, not GITHUB_TOKEN: the workflow's `permissions:` are
292+
# contents: read, and `gh release create` needs contents: write. Granting
293+
# the job contents: write to use GITHUB_TOKEN here would ADD a second
294+
# write credential rather than remove one -- `actions/checkout` above
295+
# already persists this same app token in .git/config for every step in
296+
# the job, so scoping it out of this one step's env buys no isolation.
297+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
298+
# Passed via env (not interpolated into the script) per GitHub's
299+
# injection guidance.
300+
VERSION: ${{ steps.release.outputs.version }}
301+
run: |
302+
set -euo pipefail
303+
# Written under RUNNER_TEMP, never the repo root: hatchling's default sdist
304+
# file selection sweeps in untracked files at the root (verified -- it ships
305+
# even git-ignored paths), so a notes file left in the tree would leak into
306+
# the sdist that the "Build wheel + sdist" step above produced and the
307+
# publish-pypi job uploads.
308+
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
309+
python3 .github/scripts/release_notes.py "$VERSION" "$NOTES_FILE"
310+
# Empty notes file => no CHANGELOG section was found (the script already
311+
# emitted the ::warning::); let GitHub generate the body instead.
312+
if [ -s "$NOTES_FILE" ]; then
313+
NOTES=(--notes-file "$NOTES_FILE")
314+
else
315+
NOTES=(--generate-notes)
316+
fi
317+
gh release create "v${VERSION}" \
318+
--title "v${VERSION}" \
319+
--verify-tag \
320+
--latest \
321+
"${NOTES[@]}"
322+
323+
# `continue-on-error` above hides a failure in a collapsed step marker that
324+
# nobody expands on an otherwise-green release run -- the same silence that
325+
# let "no GitHub Releases at all" go unnoticed until this PR. Re-raise it as
326+
# an ::error annotation plus a run-summary block, WITHOUT failing the job
327+
# (that would skip publish-pypi, see above).
328+
- name: Flag missing GitHub Release
329+
if: always() && steps.gh_release.outcome == 'failure'
330+
env:
331+
VERSION: ${{ steps.release.outputs.version }}
332+
run: |
333+
set -euo pipefail
334+
MAJOR="v${VERSION%%.*}"
335+
echo "::error title=GitHub Release not published::v${VERSION} was tagged and its artifacts built, but 'gh release create' failed. Create the Release by hand so ${MAJOR} and the Marketplace listing resolve."
336+
{
337+
echo "### :x: GitHub Release for \`v${VERSION}\` was NOT created"
338+
echo
339+
echo "The version tag, the moving \`${MAJOR}\` tag, and the PyPI artifacts are unaffected —"
340+
echo "only \`gh release create\` failed. Create it by hand:"
341+
echo
342+
echo '```sh'
343+
echo "gh release create v${VERSION} --title v${VERSION} --verify-tag --latest --generate-notes"
344+
echo '```'
345+
} >> "$GITHUB_STEP_SUMMARY"
346+
244347
# Build + push the agent image HERE, in the same job that produced the
245348
# version, so the `:<version>` tag is built from the correct pyproject (bumped
246349
# by semantic-release on main, or the stamped rc on a prerelease).

Makefile

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ install: ## Install project with dev + uipath dependencies (hash-verified from
1515
uv sync --frozen --extra dev --extra uipath
1616
uv run pre-commit install
1717

18+
# `.github/scripts/` is in scope on purpose: release tooling that lives in a real
19+
# module (rather than inline in a workflow `run:` heredoc) is exactly what ruff,
20+
# pyright and pytest can see — leaving it unlinted would forfeit the reason it was
21+
# extracted.
22+
LINT_PATHS := src/ tests/ .github/scripts/
23+
1824
format: ## Auto-format code with ruff
19-
uv run ruff format src/ tests/
25+
uv run ruff format $(LINT_PATHS)
2026

2127
check: ## Run linting checks
22-
uv run ruff check src/ tests/
28+
uv run ruff check $(LINT_PATHS)
2329

2430
lint: ## Run custom architectural lint rules (CE001+)
2531
uv run pytest tests/test_custom_lint.py -v --tb=short --no-header -p no:warnings
@@ -42,8 +48,8 @@ test-cov: ## Run tests with coverage report
4248

4349

4450
verify: ## Run all verification steps (CI equivalent)
45-
uv run ruff format --check src/ tests/
46-
uv run ruff check src/ tests/
51+
uv run ruff format --check $(LINT_PATHS)
52+
uv run ruff check $(LINT_PATHS)
4753
uv run pyright
4854
uv run pytest tests/test_custom_lint.py -v --tb=short --no-header -p no:warnings
4955
# uv run pip-audit --desc --skip-editable

0 commit comments

Comments
 (0)