From 567d79d132ff86de446a4c3e5d8ef479327a722e Mon Sep 17 00:00:00 2001 From: Ismar <1242091+ichoosetoaccept@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:52:45 +0200 Subject: [PATCH 1/2] fix(template): stop chmodding scripts the template does not own `_tasks` opened with `chmod +x scripts/*.sh scripts/*.py`, which flipped the mode of every file a downstream project keeps in `scripts/`, not just the one the template ships. Nine shebang-less files went 644 -> 755 on a routine 0.41.10 -> 0.41.12 update, and ruff's EXE002 ("file is executable but no shebang is present") took that repo from lint-clean to nine errors. No `.rej`, no warning. The obvious recovery is a trap too: `chmod 644 scripts/*.py` clears EXE002 and raises EXE001 on the scripts that legitimately have shebangs. The real recovery is `git checkout -- scripts/`, which nothing points you at. Deleted rather than narrowed, because the task was never needed. Copier's `_render_file` already chmods each rendered file to the template's mode, preferring the template's *git-index* exec bit over `stat().st_mode` so a bit committed by the template author survives filesystems that do not carry one (Windows), then syncs the destination repo's index to match. It runs on copy and update alike, and only for files copier actually renders -- so a downstream `scripts/probe.py`, which the template does not own, is never a candidate. Confirmed in copier 9.17.1 (`_main.py:862-907`) and empirically: rendering with `--skip-tasks`, which never ran the chmod, already produces `check-template-update.sh` at 755. That makes the template's committed file mode load-bearing, so both halves are pinned. One test asserts there is no chmod task in copier.yml; the other asserts the shipped script *renders* executable. Without the second, committing that file 644 would silently break `check-shebang-scripts-are-executable` on the first commit of every generated project. Closes DOT-628 --- copier.yml | 18 +++++++++++++++-- tests/test_template.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/copier.yml b/copier.yml index b8bf228..2fa5b60 100644 --- a/copier.yml +++ b/copier.yml @@ -260,9 +260,23 @@ configure_repo_settings: # TASKS -------------------------------- +# +# There is deliberately no `chmod +x` task here. It used to be +# `chmod +x scripts/*.sh scripts/*.py`, which flipped the mode of *every* file +# a downstream project keeps in `scripts/` — including its own shebang-less +# ones, which then failed ruff's `EXE002` ("executable but no shebang") with no +# `.rej` and no warning. The obvious recovery (`chmod 644 scripts/*.py`) then +# trips `EXE001` on the legitimately-executable ones (DOT-628). +# +# It was never needed. Copier's own `_render_file` chmods each rendered file to +# the template's mode, preferring the template's *git-index* exec bit over +# `stat().st_mode` so it survives filesystems that don't carry the bit +# (Windows), then syncs the destination's git index to match. That runs on +# `copy` and `update` alike, and only for files copier actually renders — so +# `scripts/check-template-update.sh` (committed 100755) arrives executable, +# and files the template does not own are never touched. Verified against +# copier 9.17.1. _tasks: - # Always run - - "chmod +x scripts/*.sh scripts/*.py 2>/dev/null || true" # Run `uv sync` in copier's temp render dirs (`old_copy`/`new_copy`) AND on # initial `copy`, but NOT in the real destination during update. Why this # inverted guard (`[ ! -d .git ]`) instead of the more obvious "only sync in diff --git a/tests/test_template.py b/tests/test_template.py index 51ae0a1..2dcfb67 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -6,6 +6,7 @@ import pathlib import re import shutil +import stat import subprocess import tomllib from typing import TYPE_CHECKING, ClassVar @@ -1430,6 +1431,50 @@ def test_uv_sync_task_runs_in_copier_temp_render_dirs(self) -> None: "`[ ! -d .git ]` instead (see DOT-587 / DOT-588)." ) + def test_tasks_do_not_chmod_downstream_scripts(self) -> None: + """`_tasks` must not blanket-chmod `scripts/` (DOT-628). + + The template owns exactly one file under `scripts/`. A downstream project keeps its + own one-off scripts in the same directory, and the template has no business changing + their modes. `chmod +x scripts/*.sh scripts/*.py` did exactly that: every shebang-less + `.py` a project kept there went 644 -> 755 on update, and ruff's `EXE002` turned a + lint-clean repo red with no `.rej` file to explain it. + + Nothing replaces the task, because nothing needs to: copier chmods each rendered file + to the template's git-index mode itself (`_render_file`), on copy and update alike. + See `test_shipped_script_is_rendered_executable` for the other half of this pair. + """ + copier_yml = pathlib.Path(__file__).resolve().parent.parent / "copier.yml" + task_lines = [line for line in copier_yml.read_text().splitlines() if line.lstrip().startswith("- ") and "chmod" in line] + + assert not task_lines, ( + "copier.yml _tasks contains a chmod task:\n" + + "\n".join(task_lines) + + "\n\nThe template must not change modes of files it does not own — a glob over " + "`scripts/` catches the downstream project's own scripts and breaks ruff EXE002 " + "(DOT-628). Copier already propagates the template's exec bits; if a newly " + "shipped script needs +x, commit it 100755 instead." + ) + + def test_shipped_script_is_rendered_executable(self, copier_defaults: dict, project_factory) -> None: + """The one script the template ships must arrive executable, with no chmod task. + + This is the load-bearing half of DOT-628's fix: dropping the chmod task is only safe + because copier propagates the template's own mode. If `check-template-update.sh` were + ever committed 100644, the generated project's `check-shebang-scripts-are-executable` + hook would fail on the very first commit — so pin the rendered mode, not the source's. + """ + project = project_factory(copier_defaults) + + script = project / "scripts" / "check-template-update.sh" + assert script.stat().st_mode & 0o111, ( + f"{script.name} rendered non-executable ({stat.filemode(script.stat().st_mode)}). " + "It has a shebang, so the generated project's `check-shebang-scripts-are-executable` " + "hook will block every commit. Commit `project/scripts/check-template-update.sh` " + "with mode 100755 (`git update-index --chmod=+x`) — do not add a chmod task, which " + "would re-introduce DOT-628." + ) + def test_update_banner_does_not_claim_deps_synced(self) -> None: """The `🎉 Template updated!` banner must not claim "Dependencies synced" (DOT-587 fix follow-up). From 15c02ed3fed4d46b6fee51c513663d52ffeff378 Mon Sep 17 00:00:00 2001 From: Ismar <1242091+ichoosetoaccept@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:13:37 +0200 Subject: [PATCH 2/2] test: parse copier.yml as YAML in the chmod guard The guard filtered raw lines that start with `- ` and contain `chmod`, which is blind to the mapping and folded forms of a copier task: _tasks: - command: >- chmod +x scripts/*.py Copier runs that; the filter sees no `chmod` on the `- ` line and passes. Not hypothetical syntax either -- copier.yml already uses `- command:` with `when:` for two other tasks, so the next chmod could arrive in exactly the shape the guard cannot see. Parses `_tasks` and reads the command out of both shapes instead. Confirmed to discriminate: with a folded chmod task injected, the old line filter matches zero lines and the new assertion fails. Reported by Greptile on #347. --- tests/test_template.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_template.py b/tests/test_template.py index 2dcfb67..d4e808a 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1445,11 +1445,19 @@ def test_tasks_do_not_chmod_downstream_scripts(self) -> None: See `test_shipped_script_is_rendered_executable` for the other half of this pair. """ copier_yml = pathlib.Path(__file__).resolve().parent.parent / "copier.yml" - task_lines = [line for line in copier_yml.read_text().splitlines() if line.lstrip().startswith("- ") and "chmod" in line] + tasks = yaml.safe_load(copier_yml.read_text())["_tasks"] - assert not task_lines, ( + # Parse the YAML rather than grepping lines. A copier task is either a bare command + # string or a mapping with `command` (plus `when`), and either form can put the + # command on a folded continuation line — `- command: >-` with the body indented + # underneath. A line-oriented filter sees no `chmod` on the `- ` line and passes + # while copier happily runs the task. + commands = [task if isinstance(task, str) else task.get("command", "") for task in tasks] + offenders = [command for command in commands if "chmod" in command] + + assert not offenders, ( "copier.yml _tasks contains a chmod task:\n" - + "\n".join(task_lines) + + "\n".join(offenders) + "\n\nThe template must not change modes of files it does not own — a glob over " "`scripts/` catches the downstream project's own scripts and breaks ruff EXE002 " "(DOT-628). Copier already propagates the template's exec bits; if a newly "