Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true

[*.{bat,cmd}]
end_of_line = crlf
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
* text=auto eol=lf

*.bat text eol=crlf
*.cmd text eol=crlf

*.docx binary
*.pdf binary
*.pptx binary
*.xlsx binary
*.gif binary
*.ico binary
*.jpg binary
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ permissions:
contents: read

jobs:
line-ending-policy:
name: line-ending-policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -B tools/verify_line_endings.py --root .

test:
strategy:
fail-fast: false
Expand Down
45 changes: 45 additions & 0 deletions line-ending-policy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# TestForge line-ending policy gate

This composite action verifies the repository's committed Git line-ending contract before a change can merge. It supports two profiles:

- Standard: LF-authored text, CRLF `.bat`/`.cmd` working-tree exceptions, binary `-text` rules, and a matching EditorConfig baseline.
- Byte custody: a root `* -text` contract for repositories whose tracked bytes must remain untouched.

The verifier inspects effective Git attributes and the Git index. Scoped `-text` paths remain exempt from CR-byte rejection, so release snapshots and canonical byte trees can retain intentional bytes.

## Caller workflow

Pin the action to a reviewed TestForge commit:

```yaml
name: TestForge line-ending policy

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
line-ending-policy:
name: line-ending-policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: Stunspot/TestForge/line-ending-policy@REPLACE_WITH_REVIEWED_COMMIT_SHA
```

Make the `line-ending-policy` job a required status check on the default branch. The workflow detects violations; the GitHub ruleset prevents them from landing.

## Local command

```text
python -B tools/verify_line_endings.py --root PATH_TO_REPOSITORY
```

The command returns JSON and exits nonzero for a policy violation or inspection error.
8 changes: 8 additions & 0 deletions line-ending-policy/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: TestForge line-ending policy
description: Verify the Collaborative Dynamics LF, Windows-command, binary, and byte-custody contract.
runs:
using: composite
steps:
- name: Verify line-ending policy
shell: bash
run: python "$GITHUB_ACTION_PATH/../tools/verify_line_endings.py" --root "$GITHUB_WORKSPACE"
73 changes: 64 additions & 9 deletions release-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@
"package": "testforge-public-repository",
"version": "1.1.1",
"release_date": "2026-07-20",
"artifact_count": 687,
"artifact_count": 698,
"artifacts": [
{
"path": ".agents/plugins/marketplace.json",
"size": 397,
"sha256": "4f1010e892c432ba62b62c2d611af692b19cdfaeb24a7d1e8aa0d0d034421ed7"
},
{
"path": ".editorconfig",
"size": 112,
"sha256": "3f98ae7d1a00751db3e32bdb4b2b3f53df8417d08946469bae79db6fb6e15334"
},
{
"path": ".gitattributes",
"size": 125,
"sha256": "a6e4397b4120000046fa9bfacb31df844160cab51b0fb7a45d084241c0c04910"
"size": 221,
"sha256": "138131eed771585ddd21d950e40821fc669a4b48210d879a43dfdf8fabdbe712"
},
{
"path": ".github/workflows/deploy-pages.yml",
"size": 781,
"sha256": "7a060c30a1d7bd0995ab6ddbe75a4140402f0eea4227637095cc814f0874a6c4"
},
{
"path": ".github/workflows/test.yml",
"size": 828,
"sha256": "c63e6515daf1b0d1af1f9de91d9c0ac2bccc0d99ce758364231f18aa85747c0a"
"size": 1098,
"sha256": "95c53e37b928d33c264b01e69cd35e68d34a33046673d72b3bc69301b5d86652"
},
{
"path": ".gitignore",
Expand Down Expand Up @@ -85,6 +95,31 @@
"size": 959,
"sha256": "57bf58033a9570984271c4e39ec79387ff09af9cbd9e81bb2736ade080452399"
},
{
"path": "docs/.nojekyll",
"size": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"path": "docs/assets/testforge-hero.png",
"size": 1481766,
"sha256": "d327ba98cb45afbe7a939e0f265eb51db98a50258d5ba8c117e6573e98c7d05b"
},
{
"path": "docs/index.html",
"size": 17593,
"sha256": "aab19b819b6c772cb70f2604e65001715b081005d280750eed042c198867e9cc"
},
{
"path": "docs/SITE-SOURCE.md",
"size": 1835,
"sha256": "428deaaf6ae81cde85aae1551c3afa51d84d95f256672df17b20298e884c4cf5"
},
{
"path": "docs/style.css",
"size": 16589,
"sha256": "1c9cd32a619426ba4c0925dc710c3c9944beabf0b3c7979b188010f5452de1e4"
},
{
"path": "documentation-manifest.json",
"size": 1677,
Expand Down Expand Up @@ -120,6 +155,16 @@
"size": 2770,
"sha256": "ed7874c404bbcf284cb8e3a10ffe2f4c13b0dffec6238215d2171df658fb124e"
},
{
"path": "line-ending-policy/action.yml",
"size": 327,
"sha256": "b0160dbc726839c2a8433e877ff6d486d09c2016f8091192da33bba1afe0704d"
},
{
"path": "line-ending-policy/README.md",
"size": 1455,
"sha256": "7652ef8b6cf2f63fed3416445692b36c442fc767369bc7c0dd353d69b452b002"
},
{
"path": "NOTICE.md",
"size": 680,
Expand Down Expand Up @@ -662,8 +707,8 @@
},
{
"path": "README.md",
"size": 8721,
"sha256": "692953a48b51e18853fe19bc2626ce1bc9d24cf9a8d4c36c11176ccefd164a80"
"size": 8905,
"sha256": "6c0bf259a9c61e94a9e9704d448d0ba92133c05a6f3acc073091dc9aac4251c3"
},
{
"path": "RELEASE-NOTES-v1.1.0.md",
Expand Down Expand Up @@ -3135,10 +3180,15 @@
"size": 3729,
"sha256": "1b47b54c993d72d2bc8ebd5921794a3f83ab993fd25ad49d2ab53d97b2947091"
},
{
"path": "tests/test_line_ending_policy.py",
"size": 5373,
"sha256": "c41bd426a3573101df09d45cb59e6f14f988da7c5d809dfd345ad485b349cb9a"
},
{
"path": "tests/test_public_distribution.py",
"size": 6286,
"sha256": "8027ec37dfb3c4381c4eb4c30ef3d5e264af886614dd287483cc5ba3350479b2"
"size": 6559,
"sha256": "f4361101c9359066f3e9963124acfc01a0d14b72e3b7611aaffe40f9a34efb1a"
},
{
"path": "tools/augment-evals/.gitignore",
Expand Down Expand Up @@ -3255,6 +3305,11 @@
"size": 4289,
"sha256": "fac03f5b5329481dc97a5a7fc204b5f7ad5bb9c266006be8ecd46a89feec4394"
},
{
"path": "tools/verify_line_endings.py",
"size": 13672,
"sha256": "e225276be3d42734cfbed5c25f7c33e840f4fb7531e6126545339bf2daaa3ec1"
},
{
"path": "TRADEMARKS.md",
"size": 762,
Expand Down
159 changes: 159 additions & 0 deletions tests/test_line_ending_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from __future__ import annotations

import subprocess
import sys
import tempfile
import unittest
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))

from verify_line_endings import verify as verify_line_endings


STANDARD_ATTRIBUTES = """* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.zip binary
*.docx binary
*.xlsx binary
*.pptx binary
*.pdf binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
"""

STANDARD_EDITORCONFIG = """root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true

[*.{bat,cmd}]
end_of_line = crlf
"""


class LineEndingPolicyTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
subprocess.run(["git", "init", "-q"], cwd=self.root, check=True)

def tearDown(self):
self.temporary.cleanup()

def git_add(self, *paths: str):
subprocess.run(
["git", "-c", "core.autocrlf=false", "add", *paths],
cwd=self.root,
check=True,
capture_output=True,
)

def add_raw_blob(self, path: str, data: bytes):
object_id = subprocess.run(
["git", "hash-object", "-w", "--stdin"],
cwd=self.root,
check=True,
capture_output=True,
input=data,
).stdout.decode("ascii").strip()
subprocess.run(
["git", "update-index", "--add", "--cacheinfo", f"100644,{object_id},{path}"],
cwd=self.root,
check=True,
capture_output=True,
)

def write_standard_policy(self):
(self.root / ".gitattributes").write_text(STANDARD_ATTRIBUTES, encoding="utf-8", newline="\n")
(self.root / ".editorconfig").write_text(STANDARD_EDITORCONFIG, encoding="utf-8", newline="\n")

def test_standard_policy_passes_effective_and_blob_checks(self):
self.write_standard_policy()
(self.root / "README.md").write_bytes(b"one\ntwo\n")
self.git_add(".")
result = verify_line_endings(self.root)
self.assertEqual("PASS", result["status"])
self.assertEqual("standard", result["profile"])
self.assertEqual([], result["indexed_text_cr_paths"])

def test_missing_windows_and_binary_rules_fail_effective_probes(self):
(self.root / ".gitattributes").write_text("* text=auto eol=lf\n", encoding="utf-8")
(self.root / ".editorconfig").write_text(STANDARD_EDITORCONFIG, encoding="utf-8")
self.git_add(".")
result = verify_line_endings(self.root)
self.assertEqual("FAIL", result["status"])
self.assertTrue(any(item["code"] == "ATTRIBUTE_MISMATCH" for item in result["findings"]))

def test_crlf_already_in_index_is_rejected(self):
(self.root / "bad.txt").write_bytes(b"one\r\ntwo\r\n")
self.add_raw_blob("bad.txt", b"one\r\ntwo\r\n")
self.write_standard_policy()
self.git_add(".gitattributes", ".editorconfig")
result = verify_line_endings(self.root)
self.assertEqual("FAIL", result["status"])
self.assertEqual(["bad.txt"], result["indexed_text_cr_paths"])

def test_scoped_byte_custody_exempts_canonical_cr_bytes(self):
self.write_standard_policy()
with (self.root / ".gitattributes").open("a", encoding="utf-8", newline="\n") as stream:
stream.write("/release/** -text\n")
release = self.root / "release"
release.mkdir()
(release / "receipt.md").write_bytes(b"one\r\ntwo\r\n")
self.git_add(".")
result = verify_line_endings(self.root)
self.assertEqual("PASS", result["status"])
self.assertEqual([], result["indexed_text_cr_paths"])

def test_gitlink_is_not_scanned_as_a_blob(self):
commit = (
b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n"
b"author Test <test@example.com> 0 +0000\n"
b"committer Test <test@example.com> 0 +0000\n"
b"\nsubmodule fixture\n"
)
object_id = subprocess.run(
["git", "hash-object", "-t", "commit", "-w", "--stdin"],
cwd=self.root,
check=True,
capture_output=True,
input=commit,
).stdout.decode("ascii").strip()
subprocess.run(
[
"git",
"update-index",
"--add",
"--cacheinfo",
f"160000,{object_id},vendor/example",
],
cwd=self.root,
check=True,
capture_output=True,
)
self.write_standard_policy()
self.git_add(".gitattributes", ".editorconfig")
result = verify_line_endings(self.root)
self.assertEqual("PASS", result["status"])
self.assertEqual([], result["indexed_text_cr_paths"])

def test_root_byte_custody_is_a_separate_passing_profile(self):
(self.root / ".gitattributes").write_text("* -text\n", encoding="utf-8")
(self.root / "canonical.bin").write_bytes(b"one\r\ntwo\r\n")
self.git_add(".")
result = verify_line_endings(self.root)
self.assertEqual("PASS", result["status"])
self.assertEqual("byte-custody", result["profile"])


if __name__ == "__main__":
unittest.main()
5 changes: 5 additions & 0 deletions tests/test_public_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ def test_public_tree_has_no_cache_debris(self):
]
self.assertEqual([], debris)

def test_public_line_ending_action_uses_canonical_verifier(self):
action = (ROOT / "line-ending-policy" / "action.yml").read_text(encoding="utf-8")
self.assertIn("tools/verify_line_endings.py", action)
self.assertIn("GITHUB_WORKSPACE", action)

def test_declared_customer_documents_exist(self):
documentation = json.loads(
(ROOT / "documentation-manifest.json").read_text(encoding="utf-8")
Expand Down
Loading
Loading