Skip to content

fix(template): stop chmodding scripts the template does not own - #347

Open
ichoosetoaccept wants to merge 2 commits into
mainfrom
no-blanket-chmod-scripts
Open

fix(template): stop chmodding scripts the template does not own#347
ichoosetoaccept wants to merge 2 commits into
mainfrom
no-blanket-chmod-scripts

Conversation

@ichoosetoaccept

@ichoosetoaccept ichoosetoaccept commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Deletes the chmod +x scripts/*.sh scripts/*.py task from copier.yml's _tasks. Adds two tests pinning both halves of why that is safe.

Why

The task chmodded 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 — with no .rej and no warning.

The obvious recovery is a trap of its own: 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 is never a candidate.

Confirmed two ways: in copier 9.17.1's source (_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, which is why there are two tests rather than one. Without test_shipped_script_is_rendered_executable, committing that file 644 would silently break check-shebang-scripts-are-executable on the first commit of every generated project.

Test plan

  • test_tasks_do_not_chmod_downstream_scripts — asserts no chmod task in copier.yml.
  • test_shipped_script_is_rendered_executable — asserts the rendered scripts/check-template-update.sh has the exec bit, pinning the copier behaviour now doing the work.
  • Fast suite: 202 passed.

Known gap: no test exercises _tasks end-to-end — every copier invocation in the suite passes --skip-tasks, since running tasks means uv sync --upgrade (network, minutes). The first assertion is therefore structural on copier.yml text; the behavioural claim is covered by the second test plus the source reading above.

Closes DOT-628

Greptile Summary

This change removes the blanket chmod task and strengthens the regression check by parsing Copier task definitions as YAML.

The previously observed script-mode regression was exercised and disproved for this revision: a real Copier update leaves a downstream-owned shebang-less Python script at mode 0644, while the template-owned scripts/check-template-update.sh remains executable at mode 0755.

Confidence Score: 5/5

Safe to merge; no blocking failure remains.

A real Copier copy/update flow confirmed that downstream-owned scripts keep their mode and the template-owned shell script remains executable.

T-Rex T-Rex Logs

What T-Rex did

  • I ran a generated Bash harness with Copier 9.17.1 to create an old-template project, added downstream_owned.py at mode 0644, and ran Copier updates with tasks enabled.
  • The baseline update to 0.41.14 changed the downstream-owned file to mode 0755, reproducing the former blanket-chmod behavior.
  • Updating from 0.41.14 to the revision left downstream_owned.py at 0644 with no Git mode diff, and left scripts/check-template-update.sh at 0755.
  • These results show that the removed task no longer changes downstream-owned script permissions while the template-owned script remains executable.
  • Downstream-owned scripts remained at 0644 and the template-owned script stayed executable after the change, reproducing the baseline conditions described in the validation proof.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (4): Last reviewed commit: "test: parse copier.yml as YAML in the ch..." | Re-trigger Greptile

@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown

DOT-628

@ichoosetoaccept

ichoosetoaccept commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Comment thread tests/test_template.py Outdated
ichoosetoaccept added a commit that referenced this pull request Aug 5, 2026
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.
Base automatically changed from linear-scope-closes-discipline to main August 5, 2026 23:23
ichoosetoaccept added a commit that referenced this pull request Aug 5, 2026
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.
@ichoosetoaccept
ichoosetoaccept changed the base branch from main to sync-with-uv-upstream August 5, 2026 23:34
@ichoosetoaccept
ichoosetoaccept force-pushed the no-blanket-chmod-scripts branch from b61c3ad to 2dbd7aa Compare August 5, 2026 23:34
Base automatically changed from sync-with-uv-upstream to main August 5, 2026 23:42
`_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
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.
@ichoosetoaccept
ichoosetoaccept force-pushed the no-blanket-chmod-scripts branch from 2dbd7aa to 15c02ed Compare August 6, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant