Skip to content

Pin the one unpinned shebang script, and gate the pin list both ways - #69

Merged
ptr727 merged 2 commits into
developfrom
fix-eol-pins-class
Aug 9, 2026
Merged

Pin the one unpinned shebang script, and gate the pin list both ways#69
ptr727 merged 2 commits into
developfrom
fix-eol-pins-class

Conversation

@ptr727

@ptr727 ptr727 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Found by Copilot review on #68, which flagged that ops/vps-backup-pull is an extensionless shebang script with no LF pin. It is, and the reason it was missed turned out to be the larger half.

The finding

.gitattributes keeps git passive with * -text and then names the files whose line endings are load-bearing. ops/vps-backup-pull carries no extension, so no *.sh or *.py rule reached it. A CRLF checkout hands systemd a broken interpreter line for a script it runs unattended on the backup host.

Why it was invisible

Two pins in that file name files this repository has never carried, in any commit:

Pattern Tracked files matched
deploy/blog-deploy-shell 0
deploy/authorized_keys 0

Both live on the server, described in OPERATIONS.md "Server Hardening". A pin binds nothing for a file that does not exist, and the comment above those two said "The deploy shell is an extensionless shebang script that matches no rule above." So the file read as though the extensionless case was covered, while the one real instance sat unpinned twenty lines up. Both are dropped, with the reason recorded in place so neither returns as an oversight.

deploy/bootstrap.Caddyfile gets a pin it never had. Caddy reads it from the container's config directory and it is the only Caddy file outside the release bundle, so it is the same daemon-parsed class as deploy/Caddyfile sitting beside it, which was pinned.

The gate

A hand-maintained list that nothing reads back is how both defects got here, so checks/check-eol-pins.py reads it in both directions:

  • unpinned — a tracked file opening with #! whose resolved eol is not lf. The shebang is the test rather than the mode bit, because the two move independently and it is the interpreter line that a CRLF breaks.
  • dead — a pattern matching no tracked file, which reads as coverage while binding nothing.

It asks git check-attr for the resolved attribute rather than re-implementing the match rules, so it cannot disagree with what git actually applies on checkout, and it fails rather than passing if it finds no shebang files at all. It runs in Lint sources job beside check-env-docs.py.

Verification

Each defect was reintroduced and the gate was watched failing on it, rather than the gate being trusted because it passes:

=== A: drop the ops pin (the reviewer's finding) ===
error: unpinned: ops/vps-backup-pull opens with a shebang and resolves to eol=unspecified.
FAIL - 1 line-ending pin finding(s)

=== B: re-add a never-tracked pin ===
error: dead: .gitattributes:60 pattern 'deploy/blog-deploy-shell' matches no tracked file.
FAIL - 1 line-ending pin finding(s)

=== restored, control ===
PASS - 16 shebang files pinned to LF, 12 patterns all matching tracked files

It also caught its own file before that was staged, which is the third direction working.

git add --renormalize . after the change stages nothing beyond the three files here, so the pins bind future checkouts and rewrite no bytes now.

The systemd units are deliberately not pinned, measured rather than assumed. systemd-analyze verify on a CRLF copy of vps-backup-pull.service reports the same clean result as the LF original, and the same tool does flag an unreachable ExecStart, so the negative means something. They are not in this class.

ops/install.sh needed nothing: *.sh already covers it.

`ops/vps-backup-pull` is extensionless, so no `*.sh` or `*.py` rule reached
it, and `* -text` keeps git passive by default. A CRLF checkout would hand
systemd a broken interpreter line for a script it runs unattended.

The reason it was missed is the more interesting half. `.gitattributes`
already pinned `deploy/blog-deploy-shell` and `deploy/authorized_keys`, and
this repository has never carried either file, in any commit: they live on
the server, described in OPERATIONS.md "Server Hardening". A pin for a file
that does not exist binds nothing, and the comment above those two claimed
the extensionless shebang case was handled. That is what made the real one
invisible. Both pins are dropped, with the reason recorded where they were.

`deploy/bootstrap.Caddyfile` gets a pin it never had. It is read by Caddy
from the container's config directory and is the only Caddy file outside
the release bundle, so it is the same daemon-parsed class as
`deploy/Caddyfile` beside it, which was pinned.

`checks/check-eol-pins.py` reads the list back, in both directions, because
a hand-maintained list that nothing verifies is how this got here:

  unpinned  a tracked file opening with `#!` whose resolved eol is not lf.
            The shebang is the test rather than the mode bit, because the
            two move independently and it is the interpreter line a CRLF
            breaks.
  dead      a pattern matching no tracked file, which reads as coverage
            while binding nothing.

It asks `git check-attr` for the resolved attribute instead of
re-implementing the match rules, so it cannot disagree with what git
actually applies on checkout, and it fails rather than passing if it finds
no shebang files at all.

Verified by reintroducing each defect against the gate: dropping the
`ops/vps-backup-pull` line reports it unpinned, and re-adding
`deploy/blog-deploy-shell` reports it dead. It also caught its own file
before that was staged, which is the third direction working.

`git add --renormalize .` after the change stages nothing beyond these
three files, so the pins bind future checkouts and rewrite no bytes now.

systemd units are deliberately not pinned. Measured rather than assumed:
`systemd-analyze verify` on a CRLF copy of `vps-backup-pull.service`
reports the same clean result as the LF original, and the same tool does
flag an unreachable `ExecStart`, so the negative means something.

Found by Copilot review on #68.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 9, 2026 01:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request strengthens the repository’s line-ending enforcement by pinning an extensionless shebang script to LF in .gitattributes and introducing a CI gate that checks .gitattributes coverage in both directions (shebang files must resolve to eol=lf, and .gitattributes patterns must match at least one tracked file).

Changes:

  • Add checks/check-eol-pins.py to detect (1) unpinned tracked shebang files and (2) “dead” .gitattributes patterns that match no tracked files.
  • Run the new check in .github/workflows/validate-task.yml alongside existing source validation.
  • Update .gitattributes to pin ops/vps-backup-pull (and checks/check-eol-pins.py) to LF, remove never-tracked pins, and add an LF pin for deploy/bootstrap.Caddyfile.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
checks/check-eol-pins.py Adds a new validation gate that checks shebang LF pinning and detects dead .gitattributes patterns via git-resolved attributes.
.github/workflows/validate-task.yml Runs the new line-ending pin gate in CI during the validation workflow.
.gitattributes Pins the extensionless shebang script to LF, pins the new checker script, removes dead pins, and pins deploy/bootstrap.Caddyfile.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/validate-task.yml Outdated
…false

The workflow comment read "a hand-maintained list that nothing read back",
which is both ungrammatical and, as of the step it introduces, no longer
true. The script's own docstring had the same problem in the present tense:
it asserted that nothing reads the list back, which its own existence
disproves.

Found by Copilot review on #69, which caught the workflow half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 9, 2026 01:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@ptr727
ptr727 merged commit a4d7a10 into develop Aug 9, 2026
5 checks passed
@ptr727
ptr727 deleted the fix-eol-pins-class branch August 9, 2026 01:26
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.

2 participants