Skip to content

Commit 16138fd

Browse files
committed
Let the tool runner resolve a tool's own targets
Four rules the bundled skills spelled out in prose now hold in code: a bare `repomatic run <tool>` builds the invocation CI performs, a tool with no matching file is skipped rather than handed the whole tree in write mode, `--verify` answers what a formatter would rewrite, and `cancel-runs` cannot kill a release matrix. `tests/test_claude_assets.py` pins the facts the skills still quote to their source, which is how the `repomatic-audit` snake_case config keys surfaced.
1 parent f458e50 commit 16138fd

16 files changed

Lines changed: 800 additions & 105 deletions

File tree

.claude/skills/repomatic-audit/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ The header (name, `on:`, `concurrency:`) is synced automatically, but custom job
6868

6969
Header-only sync inherits the canonical `paths:` filter verbatim (after `repomatic/**` substitution). When the project's filesystem layout doesn't match, two outcomes are possible:
7070

71-
- **Inherited entries that don't exist locally** (e.g., `tests/**`, `uv.lock` in a non-Python repo): the trigger never fires for them. Coverage is missing, not noisy. Recommend `[tool.repomatic.workflow.ignore_paths]` to drop them.
72-
- **Locally relevant paths not in the canonical filter** (e.g., `install.sh`, `dotfiles/**` in a config repo): the trigger silently skips PRs that should run CI. Recommend `[tool.repomatic.workflow.extra_paths]` to append them globally, or `[tool.repomatic.workflow.paths]` keyed by filename for a per-workflow wholesale replacement.
71+
- **Inherited entries that don't exist locally** (e.g., `tests/**`, `uv.lock` in a non-Python repo): the trigger never fires for them. Coverage is missing, not noisy. Recommend `[tool.repomatic.workflow.ignore-paths]` to drop them.
72+
- **Locally relevant paths not in the canonical filter** (e.g., `install.sh`, `dotfiles/**` in a config repo): the trigger silently skips PRs that should run CI. Recommend `[tool.repomatic.workflow.extra-paths]` to append them globally, or `[tool.repomatic.workflow.paths]` keyed by filename for a per-workflow wholesale replacement.
7373

7474
The relevant config schema lives in `WorkflowConfig` (`repomatic/config.py`): `source_paths`, `extra_paths`, `ignore_paths`, and `paths` (per-workflow override dict, keyed by workflow filename). Per-workflow override is authoritative — it replaces the entire `paths:` list and ignores the other knobs.
7575

.github/workflows/autofix.yaml

Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,10 @@ jobs:
143143
if: fromJSON(needs.metadata.outputs.metadata).python_files
144144
# Ruff is not wrapping comments: https://github.com/astral-sh/ruff/issues/7414
145145
# We use autopep8 to only wrap long-line comments.
146-
# Explicit list of files is provided, as autopep8 is not able to handle find files in ".github" subdirectory.
147-
env:
148-
PYTHON_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).python_files }}
149-
run: >
150-
echo "${PYTHON_FILES}" | xargs uv --no-progress run --frozen -- repomatic
151-
run autopep8 --
146+
# An explicit file list is required, as autopep8 cannot find files in the
147+
# ".github" subdirectory on its own: the tool runner resolves it. See
148+
# `ToolSpec.default_paths` in repomatic/tool_registry.py.
149+
run: uv --no-progress run --frozen -- repomatic run autopep8
152150
# XXX Ruff is planning to support linting and formatting in one unified command at one point.
153151
# See: https://github.com/astral-sh/ruff/issues/8232
154152
- name: Run Ruff
@@ -183,17 +181,15 @@ jobs:
183181
with:
184182
version: "0.12.2"
185183
- name: Run pyproject-fmt
186-
# pyproject-fmt returns exit code 1 when it reformats the file, but
187-
# xargs translates any non-zero child exit to 123. Accept both 0 and
188-
# 123 since changes are expected in an autofix workflow.
189-
env:
190-
PYPROJECT_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).pyproject_files }}
184+
# pyproject-fmt returns exit code 1 when it reformats the file, which is
185+
# the expected outcome in an autofix workflow. No file list: the tool
186+
# runner resolves it, which also drops the xargs that used to translate
187+
# that 1 into a 123. See `ToolSpec.default_paths` in
188+
# repomatic/tool_registry.py.
191189
run: |
192190
rc=0
193-
echo "${PYPROJECT_FILES}" \
194-
| xargs uv --no-progress run --frozen -- repomatic \
195-
run pyproject-fmt -- || rc=$?
196-
[ "$rc" -eq 0 ] || [ "$rc" -eq 123 ] || exit "$rc"
191+
uv --no-progress run --frozen -- repomatic run pyproject-fmt || rc=$?
192+
[ "$rc" -eq 0 ] || [ "$rc" -eq 1 ] || exit "$rc"
197193
- name: Sync pull request
198194
env:
199195
GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }}
@@ -233,11 +229,10 @@ jobs:
233229
restore-keys: |
234230
repomatic-${{ runner.os }}-${{ runner.arch }}-shfmt-
235231
- name: Auto-format Markdown
236-
env:
237-
MARKDOWN_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).markdown_files }}
238-
run: >
239-
echo "${MARKDOWN_FILES}" | xargs -n1
240-
uv --no-progress run --frozen -- repomatic run mdformat --
232+
# No file list: the tool runner resolves mdformat's targets and runs it
233+
# once per file, the way `xargs -n1` used to here. See
234+
# `ToolSpec.default_paths` in repomatic/tool_registry.py.
235+
run: uv --no-progress run --frozen -- repomatic run mdformat
241236
# Runs inside this job, not its own: it corrects what mdformat just wrote,
242237
# so a separate job would fight this one across two PRs. See
243238
# repomatic/awesome_toc.py for rationale.
@@ -273,11 +268,11 @@ jobs:
273268
restore-keys: |
274269
repomatic-${{ runner.os }}-${{ runner.arch }}-shfmt-
275270
- name: Format shell scripts
276-
env:
277-
SHELL_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).shfmt_files }}
278-
run: >
279-
echo "${SHELL_FILES}" | xargs uv --no-progress run --frozen -- repomatic
280-
run shfmt --
271+
# No file list: the tool runner resolves shfmt's targets, and skips the
272+
# tool outright when there are none. A pathless shfmt reads stdin, and
273+
# an empty `xargs` list used to hand it exactly that. See
274+
# `ToolSpec.default_paths` in repomatic/tool_registry.py.
275+
run: uv --no-progress run --frozen -- repomatic run shfmt
281276
- name: Sync pull request
282277
env:
283278
GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }}
@@ -307,15 +302,10 @@ jobs:
307302
restore-keys: |
308303
repomatic-${{ runner.os }}-${{ runner.arch }}-biome-
309304
- name: Format JSON
310-
# Allow comments and trailing commas for JSONC files.
311-
# Biome auto-detects well-known JSONC files (tsconfig.json, etc.).
312-
env:
313-
JSON_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).json_files }}
314-
run: |
315-
echo "${JSON_FILES}" | xargs \
316-
uv --no-progress run --frozen -- repomatic run biome -- \
317-
format --write --no-errors-on-unmatched \
318-
--json-parse-allow-comments=true --json-parse-allow-trailing-commas=true
305+
# No arguments: the tool runner supplies biome's subcommand, its JSONC
306+
# parse flags and the file list. See `ToolSpec.default_args` in
307+
# repomatic/tool_registry.py.
308+
run: uv --no-progress run --frozen -- repomatic run biome
319309
- name: Sync pull request
320310
env:
321311
GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }}
@@ -735,7 +725,7 @@ jobs:
735725
# against the empty case: with no doc change, an unconditional reformat would open an
736726
# update-docs PR carrying only pre-existing formatting drift, a byte-for-byte duplicate of
737727
# the format-pyproject PR (which owns that drift). Same exit-code handling as that job:
738-
# xargs turns pyproject-fmt's reformat exit 1 into 123.
728+
# pyproject-fmt exits 1 when it reformats.
739729
env:
740730
PYPROJECT_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).pyproject_files }}
741731
run: |
@@ -744,10 +734,8 @@ jobs:
744734
exit 0
745735
fi
746736
rc=0
747-
echo "${PYPROJECT_FILES}" \
748-
| xargs uv --no-progress run --frozen -- repomatic \
749-
run pyproject-fmt -- || rc=$?
750-
[ "$rc" -eq 0 ] || [ "$rc" -eq 123 ] || exit "$rc"
737+
uv --no-progress run --frozen -- repomatic run pyproject-fmt || rc=$?
738+
[ "$rc" -eq 0 ] || [ "$rc" -eq 1 ] || exit "$rc"
751739
- name: Sync pull request
752740
env:
753741
GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }}

.github/workflows/lint.yaml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,10 @@ jobs:
126126
if: fromJSON(needs.metadata.outputs.metadata).is_python_project
127127
run: uv --no-progress sync --frozen --all-extras --all-groups
128128
- name: Run Mypy
129-
env:
130-
PYTHON_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).python_files }}
131-
run: >
132-
echo "${PYTHON_FILES}" | xargs uv --no-progress run --frozen -- repomatic
133-
run mypy --
129+
# No file list: the tool runner resolves mypy's targets from the same
130+
# inventory this workflow's `python_files` comes from. See
131+
# `ToolSpec.default_paths` in repomatic/tool_registry.py.
132+
run: uv --no-progress run --frozen -- repomatic run mypy
134133

135134
lint-yaml:
136135
name: 📄 Lint YAML
@@ -148,7 +147,7 @@ jobs:
148147
with:
149148
version: "0.12.2"
150149
- name: Run yamllint
151-
run: uv --no-progress run --frozen -- repomatic run yamllint -- .
150+
run: uv --no-progress run --frozen -- repomatic run yamllint
152151

153152
lint-zsh:
154153
name: 🐚 Lint Zsh
@@ -221,7 +220,7 @@ jobs:
221220
with:
222221
version: "0.12.2"
223222
- name: Run zizmor
224-
run: uv --no-progress run --frozen -- repomatic run zizmor -- .
223+
run: uv --no-progress run --frozen -- repomatic run zizmor
225224

226225
lint-awesome:
227226
name: 🌟 Lint awesome

changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
> This version is **not released yet** and is under active development.
77
88
- `lint-repo` now warns when a Sphinx project's GitHub website field differs from the documentation URL declared in `[project.urls]`.
9+
- New `repomatic run <tool> --verify` reporting which files a formatter would rewrite, without touching the working tree.
10+
- `repomatic run` now resolves a tool's targets itself when given no arguments, running the invocation CI performs. A tool with no matching file is skipped instead of invoked pathless.
11+
- `lint-changelog` now warns about a released section holding no entry.
12+
- `cancel-runs` now spares a run whose head commit carries `[changelog] Release`, so a sweep of the default branch cannot kill a release matrix.
13+
- Fix the `[tool.repomatic.workflow]` key names the `repomatic-audit` skill recommends: they are `extra-paths` and `ignore-paths`, not the snake_case attribute names.
914

1015
## [`7.11.0` (2026-08-13)](https://github.com/kdeldycke/repomatic/compare/v7.10.0...v7.11.0)
1116

docs/tests.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@
108108
:undoc-members:
109109
```
110110

111+
## `tests.test_claude_assets` module
112+
113+
```{eval-rst}
114+
.. automodule:: tests.test_claude_assets
115+
:members:
116+
:show-inheritance:
117+
:undoc-members:
118+
```
119+
111120
## `tests.test_config` module
112121

113122
```{eval-rst}

repomatic/changelog.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,51 @@ def warn_on_long_bullets(changelog: Changelog, threshold: int) -> None:
981981
)
982982

983983

984+
def warn_on_empty_sections(changelog: Changelog) -> None:
985+
"""Warn about released sections holding no entry, non-fatally.
986+
987+
A published heading with nothing under it reads as broken to anyone
988+
scanning release notes, and it is not merely cosmetic: the GitHub
989+
release body is rebuilt from this section, so an empty one publishes an
990+
empty release. `claude.md` § Changelog and docs updates gives the fix,
991+
which is to name what actually moved rather than to leave the section
992+
blank.
993+
994+
Only *released* sections are inspected. The unreleased section is
995+
legitimately empty for most of a cycle, since the post-release bump
996+
creates it with no entries, so flagging it would fire on every push in
997+
the hours after a release and train the reader to ignore the check.
998+
That is the mirror of {func}`warn_on_long_bullets`, which inspects the
999+
unreleased section alone because re-flagging immutable history is the
1000+
noise there.
1001+
1002+
Availability, editorial and yanked admonitions live in their own
1003+
{class}`VersionElements` fields, so a section carrying nothing but a
1004+
`[!WARNING]` about missing binaries still counts as empty: an
1005+
admonition explains a caveat, it does not say what changed.
1006+
1007+
:param changelog: The parsed changelog to inspect.
1008+
"""
1009+
for version, _date in sorted(
1010+
changelog.extract_all_releases(), key=lambda r: Version(r[0]), reverse=True
1011+
):
1012+
elements = changelog.decompose_version(version)
1013+
if split_changelog_bullets(elements.changes):
1014+
continue
1015+
logging.warning(
1016+
f"⚠ {version}: released section holds no entry. Add one naming what "
1017+
f"moved, even on a purely mechanical cycle."
1018+
)
1019+
emit_annotation(
1020+
AnnotationLevel.WARNING,
1021+
f"Changelog section for {version} is empty. A published release "
1022+
f"heading with no entries reads as broken, and the GitHub release "
1023+
f"body is rebuilt from it. Name what moved, per "
1024+
f"https://github.com/kdeldycke/repomatic/blob/main/claude.md"
1025+
f"#changelog-and-docs-updates",
1026+
)
1027+
1028+
9841029
@dataclass
9851030
class ReleaseSources:
9861031
"""The external lookups a changelog's dates and availability are checked against.
@@ -1364,6 +1409,10 @@ def lint_changelog_dates(
13641409
GitHub releases, or PyPI packages but have no corresponding changelog
13651410
entry. Orphans are logged as warnings and cause a non-zero exit code.
13661411
1412+
Two non-fatal content checks run first and never affect the exit code:
1413+
{func}`warn_on_long_bullets` over the unreleased section, and
1414+
{func}`warn_on_empty_sections` over the released ones.
1415+
13671416
When `fix` is enabled, date mismatches are corrected in-place and
13681417
admonitions are added to the changelog:
13691418
@@ -1417,6 +1466,7 @@ def lint_changelog_dates(
14171466
content = changelog_path.read_text(encoding="UTF-8")
14181467
changelog = Changelog(content)
14191468
warn_on_long_bullets(changelog, bullet_word_threshold)
1469+
warn_on_empty_sections(changelog)
14201470
releases = changelog.extract_all_releases()
14211471

14221472
if not releases:

repomatic/cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@
236236
from .tool_runner import (
237237
resolve_config_source,
238238
run_tool,
239+
verify_via_write_path,
239240
)
240241
from .version_sync import strip_dev_suffix
241242
from .virustotal import (
@@ -2160,6 +2161,16 @@ def lint_repo(
21602161
@argument("tool_name", required=False, default=None)
21612162
@argument("extra_args", nargs=-1, type=UNPROCESSED)
21622163
@option("--list", "list_tools", is_flag=True, help="List all managed tools.")
2164+
@option(
2165+
"--verify",
2166+
is_flag=True,
2167+
default=False,
2168+
help=(
2169+
"Report which targets the tool would rewrite, without touching them."
2170+
" Runs the write path against throwaway copies, so the answer holds"
2171+
" even for a tool whose own --check mode is unreliable."
2172+
),
2173+
)
21632174
@option(
21642175
"--version",
21652176
"tool_version",
@@ -2189,6 +2200,7 @@ def run_cmd(
21892200
tool_name,
21902201
extra_args,
21912202
list_tools,
2203+
verify,
21922204
tool_version,
21932205
checksum,
21942206
skip_checksum,
@@ -2209,6 +2221,10 @@ def run_cmd(
22092221
repomatic run yamllint -- --strict .
22102222
repomatic run zizmor -- --offline .
22112223
2224+
\b
2225+
Report what a formatter would rewrite, leaving the tree alone:
2226+
repomatic run mdformat --verify -- changelog.md
2227+
22122228
\b
22132229
Override the pinned version:
22142230
repomatic run shfmt --version 3.14.0 --skip-checksum -- .
@@ -2230,6 +2246,19 @@ def run_cmd(
22302246
"Missing argument 'TOOL_NAME'. Use --list to see available tools."
22312247
)
22322248

2249+
if verify:
2250+
exit_code, drifted = verify_via_write_path(
2251+
tool_name,
2252+
extra_args=extra_args,
2253+
version=tool_version,
2254+
checksum=checksum,
2255+
skip_checksum=skip_checksum,
2256+
no_cache=no_cache,
2257+
)
2258+
for path in drifted:
2259+
echo(f"Would rewrite: {path}")
2260+
ctx.exit(exit_code)
2261+
22332262
exit_code = run_tool(
22342263
tool_name,
22352264
extra_args=extra_args,

repomatic/git_ops.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@
100100
`bump-version` template title, and the workflow gates to this prefix.
101101
"""
102102

103+
RELEASE_COMMIT_PREFIX = f"{CHANGELOG_COMMIT_PREFIX}Release"
104+
"""Head-commit-message prefix marking a push that carries the release commit.
105+
106+
The coarser sibling of {data}`RELEASE_COMMIT_PATTERN`: where that one
107+
validates and extracts a version, this is the prefix test every workflow's
108+
`cancel-in-progress` gate performs, so a release run is never cancelled by a
109+
later push entering its concurrency group. A prefix is deliberately weaker
110+
than the full pattern here, because the question is "does this push carry a
111+
release" rather than "which version is it", and answering it must not depend
112+
on the version number parsing.
113+
114+
{func}`repomatic.github.actions.cancel_superseded_runs` applies the same test
115+
from the API side, which is the half GitHub's own concurrency mechanism
116+
cannot cover: a manual sweep of a branch's live runs enters no concurrency
117+
group at all.
118+
"""
119+
103120
RELEASE_COMMIT_PATTERN = re.compile(
104121
r"^\[changelog\] Release v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$"
105122
)

0 commit comments

Comments
 (0)