Merge pull request #155 from storytold/fix/house-rules-preserve-local-text

Preserve local instructions when updating house rules
This commit is contained in:
FredApps authored and GitHub committed 2026-11-13 21:24:00 +01:00
commit debd222436
5 files changed
+306 -69

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
name: House rules
# Fails unless AGENTS.md copies the shared rules from craft-contrib unchanged, at a commit that is
# on main. An intact older copy warns and does not fail. Callers run it beside their gate:
# on main. An intact older copy warns only when its normalized standards file changed on main, and does not fail. Callers run it beside their gate:
#
# house-rules:
# uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
+3 -2
View File
@@ -710,7 +710,8 @@ Fails unless `AGENTS.md` carries the shared house rules from
the commit it was copied from and the SHA-256 of the bytes inside it. The check loads
`standards/house-rules-core.md` (and `standards/house-rules-rust.md` when `rust` is true) at that
commit, requires the commit to be on `main`, and compares the normalized bytes. An edited block, or
a commit that is not on `main`, fails. An intact older commit warns. A source that cannot be read
a commit that is not on `main`, fails. An intact older commit warns only when its normalized standards file changed on `main`.
If the file is unchanged, there is no warning. A source that cannot be read
fails as "could not check". `rust` defaults to true; a repository that is not Rust passes `false`.
The caller is only the workflow call. The reusable workflow checks the app out and checks
@@ -806,7 +807,7 @@ Individual actions are also available for composing custom pipelines:
- `actions/ast-grep`: Installs a pinned ast-grep, runs the caller's rule tests and scans with its `sgconfig.yml` (see `ast-grep.yml`).
- `actions/export-app-build-env`: Sets the app's own `<PREFIX>_VERSION`, `<PREFIX>_BUILD_DATE` and `<PREFIX>_BUILD_SHA` for the steps after it, with the prefix its `packaging/env.sh` reads (or `env-prefix`); its `names` output lists them for a VM's `envs`.
- `actions/check-git-sources`: The check `git-sources.yml` runs, for a job that has already checked out the app; `allowed-prefix` changes where git dependencies may come from (default `git+https://github.com/storytold/`).
- `actions/house-rules`: The check `house-rules.yml` runs. `source` is a checkout of craft-contrib with `main` history; `require-rust` asks for the Rust block as well as the core block.
- `actions/house-rules`: The check `house-rules.yml` runs. `source` is a checkout of craft-contrib with `main` history (refresh `origin/main` before checking; the local `main` ref is used only when `origin/main` is absent, never `HEAD`); `require-rust` asks for the Rust block as well as the core block.
## Storytold Organization Hosting
+1 -1
View File
@@ -1,7 +1,7 @@
name: Check house rules
description: >-
Fail unless the AGENTS.md house-rules blocks match craft-contrib at the commit they name,
and that commit is on main. An intact older commit warns.
and that commit is on main. An intact older commit warns only when the normalized standards file changed on main.
inputs:
working-directory:
+104 -53
View File
@@ -4,7 +4,7 @@
The bytes inside a block are the normalized standards file. The recorded sha256
must match those bytes, and those bytes must match the file at the recorded
commit on craft-contrib main. A digest rewritten to suit an edit does not pass.
An intact older commit warns. A missing source is "could not check".
An intact older commit warns only when the normalized standards file changed on main. A missing source is "could not check".
"""
from __future__ import annotations
@@ -22,9 +22,10 @@ SOURCES = {
"rust": "standards/house-rules-rust.md",
}
START = re.compile(
r"<!-- craft-house-rules:start ([a-z]+) source=([0-9a-f]{40}) sha256=([0-9a-f]{64}) -->\n"
r"<!-- craft-house-rules:start ([a-z]+) source=([0-9a-f]{40}) sha256=([0-9a-f]{64}) -->(?:\r\n|\r|\n)"
)
END = "<!-- craft-house-rules:end -->"
MARKER = re.compile(r"<!--\s*craft-house-rules:")
def normalize(text: str) -> str:
@@ -38,19 +39,46 @@ def digest(text: str) -> str:
return hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()
def without_fenced_examples(text: str) -> str:
"""Mask Markdown fences without changing offsets used for byte-preserving writes."""
result = []
fence = None
for line in text.splitlines(keepends=True):
if fence is None:
opening = re.match(r" {0,3}(`{3,}|~{3,})([^\r\n]*)", line)
if opening and not (opening[1][0] == "`" and "`" in opening[2]):
fence = opening[1]
else:
result.append(line)
continue
else:
closing = re.fullmatch(r" {0,3}" + re.escape(fence[0]) +
r"{" + str(len(fence)) + r",}[ \t]*(?:\r\n|\r|\n)?", line)
if closing:
fence = None
result.append("".join(c if c in "\r\n" else " " for c in line))
return "".join(result)
def parse_blocks(text: str):
"""Return (blocks, spans). A span is the half-open range of one block in `text`."""
text = normalize(text)
markers = without_fenced_examples(text)
blocks = []
spans = []
position = 0
while True:
match = START.search(text, position)
if match is None:
marker = MARKER.search(markers, position)
if marker is None:
break
end_at = text.find(END, match.end())
if end_at < 0:
match = START.match(text, marker.start())
if match is None:
raise ValueError("house rules: malformed or unexpected block marker")
closing = MARKER.search(markers, match.end())
if closing is None:
raise ValueError(f"house rules: {match.group(1)} block is not closed")
end_at = closing.start()
if not text.startswith(END, end_at):
raise ValueError("house rules: nested or malformed block marker")
body = text[match.end() : end_at]
blocks.append(
{
@@ -60,8 +88,13 @@ def parse_blocks(text: str):
"body": body,
}
)
spans.append((match.start(), end_at + len(END)))
position = end_at + len(END)
# The marker's line ending belongs to the generated block, not local text.
if text.startswith("\r\n", position):
position += 2
elif text[position:position + 1] in ("\r", "\n"):
position += 1
spans.append((match.start(), position))
return blocks, spans
@@ -90,11 +123,24 @@ def commit_sha(raw: str) -> str | None:
def git(source: str, args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", source, *args],
capture_output=True,
text=True,
)
command = ["git", "-C", source, *args]
try:
return subprocess.run(command, capture_output=True, text=True, encoding="utf-8")
except (OSError, UnicodeError) as error:
return subprocess.CompletedProcess(command, 128, "", str(error))
def main_sha(source: str) -> str | None:
"""Prefer fetched origin/main, falling back to local main; never trust HEAD."""
for ref in ("refs/remotes/origin/main", "refs/heads/main"):
exists = git(source, ["show-ref", "--verify", "--quiet", ref])
if exists.returncode == 1:
continue
if exists.returncode != 0:
return None
result = git(source, ["rev-parse", "--verify", f"{ref}^{{commit}}"])
return commit_sha(result.stdout) if result.returncode == 0 else None
return None
def revision_state(source: str, sha: str) -> str:
@@ -102,28 +148,22 @@ def revision_state(source: str, sha: str) -> str:
recorded = commit_sha(sha)
if recorded is None:
return "missing"
head = git(source, ["rev-parse", "HEAD"])
if head.returncode != 0:
return "missing"
head_sha_value = commit_sha(head.stdout)
if head_sha_value is None:
main = main_sha(source)
if main is None:
return "missing"
if git(source, ["cat-file", "-e", f"{recorded}^{{commit}}"]).returncode != 0:
return "missing"
if head_sha_value == recorded:
if main == recorded:
return "identical"
if git(source, ["merge-base", "--is-ancestor", recorded, "HEAD"]).returncode == 0:
ancestry = git(source, ["merge-base", "--is-ancestor", recorded, main])
if ancestry.returncode == 0:
return "older"
return "not-on-main"
return "not-on-main" if ancestry.returncode == 1 else "missing"
def file_at_head(source: str, path: str) -> str | None:
if path not in SOURCES.values():
return None
shown = git(source, ["show", f"HEAD:{path}"])
if shown.returncode != 0:
return None
return shown.stdout
def file_at_main(source: str, path: str) -> str | None:
main = main_sha(source)
return file_at(source, main, path) if main is not None else None
def file_at(source: str, sha: str, path: str) -> str | None:
@@ -156,7 +196,7 @@ def judge_block(source: str, block: dict) -> tuple[str | None, str | None]:
return f"house rules: {block['name']} block does not match {path} at {block['source']}", None
if state != "older":
return None, None
current = file_at_head(source, path)
current = file_at_main(source, path)
if current is None:
return f"house rules: could not check {block['name']} at main", None
if normalize(current) != body:
@@ -191,33 +231,39 @@ def check_file(agents: str, source: Path, require_rust: bool) -> tuple[list[str]
return failures, warnings
def head_sha(source: str) -> str:
result = git(source, ["rev-parse", "HEAD"])
recorded = commit_sha(result.stdout) if result.returncode == 0 else None
def source_sha(source: str) -> str:
recorded = main_sha(source)
if recorded is None:
raise RuntimeError("house rules: could not read the source revision")
return recorded
def apply_blocks(agents: str, rendered: list[str]) -> str:
text = normalize(agents) if agents else ""
try:
_, spans = parse_blocks(text) if text else ([], [])
except ValueError:
spans = []
insertion = "".join(rendered)
"""Replace only generated spans; keep all surrounding local text verbatim."""
blocks, spans = parse_blocks(agents) if agents else ([], [])
replacements = {}
for text in rendered:
generated, _ = parse_blocks(text)
replacements[generated[0]["name"]] = text
if not spans:
rest = text
if rest and not rest.startswith("\n"):
rest = "\n" + rest
return insertion + rest
start = spans[0][0]
end = spans[-1][1]
# Drop a single trailing newline that belonged to the last rendered block.
suffix = text[end:]
if suffix.startswith("\n"):
suffix = suffix[1:]
return text[:start] + insertion + suffix
bom = "\ufeff" if agents.startswith("\ufeff") else ""
local = agents[len(bom):]
separator = "\n" if local and not local.startswith(("\n", "\r")) else ""
return bom + "".join(rendered) + separator + local
names = [block["name"] for block in blocks]
if len(set(names)) != len(names):
raise ValueError("house rules: a block name is repeated")
if any(name not in SOURCES for name in names):
raise ValueError("house rules: refusing to replace an unknown block")
pieces = []
position = 0
for block, (start, end) in zip(blocks, spans):
pieces.append(agents[position:start])
pieces.append(replacements.pop(block["name"], ""))
position = end
pieces.extend(replacements.values())
pieces.append(agents[position:])
return "".join(pieces)
def agents_file(raw: str) -> Path:
@@ -235,7 +281,7 @@ def write_agents(directory: Path, source: Path, require_rust: bool) -> None:
checkout = checkout_path(str(source))
if checkout is None:
raise RuntimeError("house rules: could not read the craft-contrib checkout")
sha = head_sha(checkout)
sha = source_sha(checkout)
names = ["core", "rust"] if require_rust else ["core"]
rendered = []
for name in names:
@@ -245,8 +291,13 @@ def write_agents(directory: Path, source: Path, require_rust: bool) -> None:
rendered.append(render(name, sha, content))
path = agents_file(str(directory))
# The existing file is preserved around the blocks. Its text is not a path.
current = path.read_text(encoding="utf-8") if path.is_file() else ""
path.write_text(apply_blocks(current, rendered), encoding="utf-8") # NOSONAR
current = ""
if path.is_file():
with path.open(encoding="utf-8", newline="") as handle:
current = handle.read()
updated = apply_blocks(current, rendered)
with path.open("w", encoding="utf-8", newline="") as handle:
handle.write(updated) # NOSONAR
def main(argv: list[str] | None = None) -> int:
@@ -261,7 +312,7 @@ def main(argv: list[str] | None = None) -> int:
if args.mode == "write":
try:
write_agents(directory, source, args.require_rust)
except RuntimeError as error:
except (RuntimeError, ValueError) as error:
print(error)
return 2
return 0
+197 -12
View File
@@ -6,6 +6,7 @@ import os
import subprocess
import tempfile
import unittest
from unittest import mock
import yaml
@@ -60,9 +61,35 @@ class HouseRulesTests(unittest.TestCase):
house_rules.write_agents(self.app, self.source, require_rust)
def check(self, require_rust: bool = True):
text = (self.app / "AGENTS.md").read_text()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
return house_rules.check_file(text, self.source, require_rust)
def test_initial_write_keeps_bom_at_start(self):
target = self.app / "AGENTS.md"
target.write_bytes("\ufeff# Local\r\nKeep me.".encode("utf-8"))
self.write()
output = target.read_bytes()
self.assertTrue(output.startswith("\ufeff<!--".encode("utf-8")))
self.assertTrue(output.endswith(b"# Local\r\nKeep me."))
self.assertEqual(output.count("\ufeff".encode("utf-8")), 1)
self.write()
self.assertEqual(target.read_bytes(), output)
self.assertEqual(self.check(), ([], []))
def test_fenced_marker_examples_remain_local_text(self):
for fence in ("```", "~~~~"):
with self.subTest(fence=fence):
example = fence + "text\n" + house_rules.END + "\n" + fence + "\n"
target = self.app / "AGENTS.md"
target.write_text(example, encoding="utf-8", newline="\n")
self.write()
self.assertTrue(target.read_bytes().endswith(example.encode()))
self.assertEqual(self.check(), ([], []))
# A real orphan after a closed example must still fail closed.
target.write_bytes(target.read_bytes() + house_rules.END.encode())
with self.assertRaises(ValueError):
self.write()
def test_normalization_uses_lf_and_one_trailing_newline(self):
self.assertEqual(house_rules.normalize("\ufeffa\r\nb\r\n\n"), "a\nb\n")
@@ -71,7 +98,7 @@ class HouseRulesTests(unittest.TestCase):
failures, warnings = self.check()
self.assertEqual(failures, [])
self.assertEqual(warnings, [])
text = (self.app / "AGENTS.md").read_text()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
self.assertIn(self.second, text)
self.assertIn("Core two.", text)
self.assertIn("Rust two.", text)
@@ -79,16 +106,65 @@ class HouseRulesTests(unittest.TestCase):
def test_text_outside_the_blocks_is_kept(self):
(self.app / "AGENTS.md").write_text("# App\n\nLocal rule.\n")
self.write()
text = (self.app / "AGENTS.md").read_text()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
self.assertTrue(text.startswith("<!-- craft-house-rules:start core "))
self.assertIn("# App\n\nLocal rule.\n", text)
self.write()
again = (self.app / "AGENTS.md").read_text()
again = (self.app / "AGENTS.md").read_text(encoding="utf-8")
self.assertEqual(text, again)
def test_local_text_between_blocks_is_kept(self):
core = house_rules.render("core", self.first, "Core one.\n")
rust = house_rules.render("rust", self.first, "Rust one.\n")
local = "\n## Local guard\nNever remove this guard.\n\n"
target = self.app / "AGENTS.md"
target.write_text("Before.\n" + core + local + rust + "After.\n")
self.write()
self.assertEqual(target.read_text(encoding="utf-8"), "Before.\n" +
house_rules.render("core", self.second, "Core two.\n") + local +
house_rules.render("rust", self.second, "Rust two.\n") + "After.\n")
def test_writer_preserves_outside_bytes_and_is_idempotent(self):
core = house_rules.render("core", self.first, "Core one.\n").replace("\n", "\r\n")
rust = house_rules.render("rust", self.first, "Rust one.\n").replace("\n", "\r\n")
target = self.app / "AGENTS.md"
local = "\r\nLocal guard.\r\n\r\n"
target.write_bytes(("Before.\r\n" + core + local + rust + "After.\r\n\r\n").encode())
self.write()
expected = ("Before.\r\n" +
house_rules.render("core", self.second, "Core two.\n") + local +
house_rules.render("rust", self.second, "Rust two.\n") + "After.\r\n\r\n").encode()
self.assertEqual(target.read_bytes(), expected)
self.write()
self.assertEqual(target.read_bytes(), expected)
self.assertEqual(self.check(), ([], []))
def test_initial_write_keeps_local_line_endings_and_no_final_newline(self):
target = self.app / "AGENTS.md"
original = b"# App\r\nLocal rule without final newline."
target.write_bytes(original)
self.write()
self.assertTrue(target.read_bytes().endswith(original))
def test_removing_rust_does_not_remove_intervening_local_rule(self):
target = self.app / "AGENTS.md"
target.write_text(house_rules.render("core", self.first, "Core one.\n") +
"Local rule.\n" + house_rules.render("rust", self.first, "Rust one.\n"))
self.write(require_rust=False)
self.assertEqual(target.read_text(encoding="utf-8"),
house_rules.render("core", self.second, "Core two.\n") + "Local rule.\n")
def test_unclosed_block_is_not_overwritten(self):
target = self.app / "AGENTS.md"
original = house_rules.render("core", self.first, "Core one.\n").replace(house_rules.END, "Local guard")
target.write_text(original)
with self.assertRaises(ValueError):
self.write()
self.assertEqual(target.read_text(encoding="utf-8"), original)
def test_an_edited_block_fails_even_when_its_digest_is_rewritten(self):
self.write()
text = (self.app / "AGENTS.md").read_text()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
text = text.replace("Core two.", "Core edited.")
body = text.split("-->\n", 1)[1].split("<!-- craft-house-rules:end -->", 1)[0]
text = text.replace(house_rules.digest("Core two.\n"), house_rules.digest(body), 1)
@@ -98,7 +174,7 @@ class HouseRulesTests(unittest.TestCase):
def test_a_digest_that_does_not_match_the_bytes_fails(self):
self.write()
text = (self.app / "AGENTS.md").read_text()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
text = text.replace(house_rules.digest("Core two.\n"), "0" * 64, 1)
(self.app / "AGENTS.md").write_text(text)
failures, _ = self.check()
@@ -107,7 +183,7 @@ class HouseRulesTests(unittest.TestCase):
def test_an_intact_older_commit_warns(self):
house_rules.write_agents(self.app, self.source, True)
# Move the recorded revision back to the first commit without changing the checker's view of HEAD.
text = (self.app / "AGENTS.md").read_text()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
old_core = house_rules.render("core", self.first, "Core one.\n")
old_rust = house_rules.render("rust", self.first, "Rust one.\n")
(self.app / "AGENTS.md").write_text(old_core + old_rust)
@@ -130,15 +206,17 @@ class HouseRulesTests(unittest.TestCase):
git(self.source, "checkout", "--orphan", "side")
self.commit_standards("Side core.\n", "Side rust.\n")
side = git(self.source, "rev-parse", "HEAD")
house_rules.write_agents(self.app, self.source, True)
git(self.source, "checkout", "main")
(self.app / "AGENTS.md").write_text(
house_rules.render("core", side, "Side core.\n") +
house_rules.render("rust", side, "Side rust.\n"))
# Even with HEAD still on the unmerged side branch, it must not pass.
failures, _ = self.check()
self.assertTrue(any("is not on main" in item for item in failures), failures)
self.assertIn(side, " ".join(failures))
def test_a_missing_revision_could_not_be_checked(self):
self.write()
text = (self.app / "AGENTS.md").read_text().replace(self.second, "a" * 40)
text = (self.app / "AGENTS.md").read_text(encoding="utf-8").replace(self.second, "a" * 40)
(self.app / "AGENTS.md").write_text(text)
failures, _ = self.check()
self.assertTrue(any(item.startswith("house rules: could not check") for item in failures))
@@ -151,9 +229,116 @@ class HouseRulesTests(unittest.TestCase):
self.assertIn("house rules: the rust block is missing", failures)
def test_malformed_optional_rust_marker_fails_without_writing(self):
core = house_rules.render("core", self.second, "Core two.\n")
rust = house_rules.render("rust", self.second, "Rust two.\n")
for broken in (rust.replace("sha256=", "sha256=bad"),
rust.replace("source=", "source=bad"),
rust.replace("start rust", "start Rust"),
rust.replace("<!-- craft", "<!--craft", 1)):
with self.subTest(broken=broken):
target = self.app / "AGENTS.md"
original = (core + broken).encode()
target.write_bytes(original)
failures, _ = self.check(require_rust=False)
self.assertTrue(any("marker" in item for item in failures), failures)
with self.assertRaises(ValueError):
self.write(require_rust=False)
self.assertEqual(target.read_bytes(), original)
def test_nested_or_orphan_markers_fail_without_writing(self):
core = house_rules.render("core", self.second, "Core two.\n")
rust = house_rules.render("rust", self.second, "Rust two.\n")
for text in (house_rules.END + "\n" + core, core + house_rules.END,
core.replace(house_rules.END, rust + house_rules.END),
core.replace(house_rules.END, "<!-- craft-house-rules:end bad -->")):
with self.subTest(text=text):
target = self.app / "AGENTS.md"
target.write_bytes(text.encode())
self.assertTrue(self.check(False)[0])
with self.assertRaises(ValueError):
self.write(False)
self.assertEqual(target.read_bytes(), text.encode())
def test_duplicate_and_unknown_blocks_fail_without_writing(self):
core = house_rules.render("core", self.second, "Core two.\n")
for text in (core + core, core + house_rules.render("other", self.second, "Other.\n")):
with self.subTest(text=text):
target = self.app / "AGENTS.md"
target.write_bytes(text.encode())
self.assertTrue(self.check(False)[0])
with self.assertRaises(ValueError):
self.write(False)
self.assertEqual(target.read_bytes(), text.encode())
def test_writer_uses_main_even_with_unmerged_head(self):
git(self.source, "checkout", "-b", "side")
self.commit_standards("Side core.\n", "Side rust.\n")
self.write()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
self.assertIn(self.second, text)
self.assertIn("Core two.", text)
self.assertNotIn("Side core.", text)
self.assertEqual(self.check(), ([], []))
def test_origin_main_takes_priority_over_local_main(self):
git(self.source, "update-ref", "refs/remotes/origin/main", self.first)
self.write()
text = (self.app / "AGENTS.md").read_text(encoding="utf-8")
self.assertIn(self.first, text)
self.assertIn("Core one.", text)
self.assertEqual(self.check(), ([], []))
self.assertEqual(house_rules.revision_state(str(self.source), self.second), "not-on-main")
def test_missing_main_fails_closed_even_with_valid_head(self):
self.write()
target = self.app / "AGENTS.md"
original = target.read_bytes()
git(self.source, "branch", "-m", "side")
self.assertTrue(any("could not check" in item for item in self.check()[0]))
with self.assertRaises(RuntimeError):
self.write()
self.assertEqual(target.read_bytes(), original)
def test_detached_head_uses_named_main(self):
git(self.source, "checkout", "--detach", self.first)
self.write()
self.assertIn(self.second, (self.app / "AGENTS.md").read_text(encoding="utf-8"))
self.assertEqual(self.check(), ([], []))
def test_ancestry_errors_are_not_reported_as_non_ancestry(self):
real_git = house_rules.git
def git_error(source, args):
if args[0] == "merge-base":
return subprocess.CompletedProcess(args, 128, "", "missing intermediate commit")
return real_git(source, args)
with mock.patch.object(house_rules, "git", side_effect=git_error):
self.assertEqual(house_rules.revision_state(str(self.source), self.first), "missing")
failures, _ = house_rules.check_file(
house_rules.render("core", self.first, "Core one.\n"), self.source, False)
self.assertTrue(any("could not check" in item for item in failures))
def test_git_uses_explicit_utf8_and_reports_invalid_output(self):
with mock.patch.object(house_rules.subprocess, "run") as run:
house_rules.git(str(self.source), ["status"])
self.assertEqual(run.call_args.kwargs["encoding"], "utf-8")
error = UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid")
with mock.patch.object(house_rules.subprocess, "run", side_effect=error):
self.assertNotEqual(house_rules.git(str(self.source), ["status"]).returncode, 0)
def test_non_ascii_standards_round_trip(self):
standards = self.source / "standards"
(standards / "house-rules-core.md").write_bytes("Core café.\n".encode("utf-8"))
git(self.source, "add", "standards")
git(self.source, "commit", "-m", "Unicode standards")
self.write()
self.assertIn("café".encode("utf-8"), (self.app / "AGENTS.md").read_bytes())
self.assertEqual(self.check(), ([], []))
class HouseRulesWiringTests(unittest.TestCase):
def test_the_action_passes_its_inputs_through_the_environment(self):
action = yaml.load((ROOT / "actions/house-rules/action.yml").read_text(), Loader=yaml.BaseLoader)
action = yaml.load((ROOT / "actions/house-rules/action.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
step = action["runs"]["steps"][0]
self.assertNotIn("${{", step["run"])
self.assertEqual(step["env"]["WORKSPACE_DIR"], "${{ inputs.working-directory }}")
@@ -162,7 +347,7 @@ class HouseRulesWiringTests(unittest.TestCase):
self.assertEqual(action["inputs"]["require-rust"]["default"], "true")
def test_the_workflow_checks_out_craft_contrib_and_runs_the_action(self):
workflow = yaml.load((ROOT / ".github/workflows/house-rules.yml").read_text(), Loader=yaml.BaseLoader)
workflow = yaml.load((ROOT / ".github/workflows/house-rules.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
job = workflow["jobs"]["house-rules"]
self.assertEqual(workflow["permissions"], {"contents": "read"})
self.assertIn("timeout-minutes", job)