From 56e533786e2f0cb9c9d49ecf0b16e7209e5f4455 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 16:24:57 +0700 Subject: [PATCH 1/3] feat(ci): a PR that changes a course carries its blog post The T27 work report status now reads apps/website/specs/policy/course_post.t27 and refuses a PR that changes apps/website/specs/course/ without adding or modifying a post body under apps/website/src/data/blog/bodies/. Closes #1460 Co-Authored-By: Claude Opus 5.5 --- apps/website/specs/policy/course_post.t27 | 51 +++++++++++++ docs/PR_BLOG_AUTOMATION.md | 8 ++ scripts/pr_blog_report.py | 58 ++++++++++++++- scripts/test_pr_blog_report.py | 91 +++++++++++++++++++++++ 4 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 apps/website/specs/policy/course_post.t27 diff --git a/apps/website/specs/policy/course_post.t27 b/apps/website/specs/policy/course_post.t27 new file mode 100644 index 0000000000..5e94013eb8 --- /dev/null +++ b/apps/website/specs/policy/course_post.t27 @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/policy/course_post.t27 -- a PR that changes a course carries its blog post +; Source of truth for scripts/pr_blog_report.py (gHashTag/trinity, repository root), which +; reads the str constants below when it validates a PR's work report with --files. A changed +; file under COURSE_DIR makes the PR a course PR; a course PR needs at least one post body +; under POST_DIR that the PR adds or modifies, or the "T27 work report" status turns red with +; RULE as the reason. The rule's words live here and nowhere else. +; ASCII only (L3), English only (LANG-EN). + +; WHY: every PR already needs a work report whose blog outline becomes an article after the +; merge (docs/PR_BLOG_AUTOMATION.md). A course is read while it changes, so its post ships +; with the change itself: the reader of a new lesson can find, on the same day, the post that +; says what changed, what the recordings show and what they do not. + +; WHAT COUNTS: paths are repository paths as the GitHub pull request files API returns them. +; A post body is a file under POST_DIR (English body and Russian ruBody in one module); a +; removed or only renamed body does not count. A changed policy file is not a course change, +; which is why this file lives under specs/policy/ and not under specs/course/. + +; LIMIT: the files API lists at most 3000 files. When the list is shorter than the PR's +; changed_files and no listed file is under COURSE_DIR, the rule cannot be decided and the +; validator says so instead of passing or failing silently. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module course_post_policy; + +pub const KIND : str = "policy"; +pub const SCHEMA_VERSION : u8 = 1; +pub const RULE_ID : str = "post-per-pr"; +pub const RULE : str = "A PR that changes a course carries its blog post, English body and Russian ruBody, in the same PR."; +pub const COURSE_DIR : str = "apps/website/specs/course/"; +pub const POST_DIR : str = "apps/website/src/data/blog/bodies/"; +pub const POST_STATUS_COUNT : u8 = 2; +pub const POST_STATUSES : [2]str = ["added", "modified"]; + +test the_rule_is_named_like_the_course_recipe { + assert RULE_ID == "post-per-pr"; + assert KIND == "policy"; +} + +test a_course_change_and_a_post_are_different_directories { + assert COURSE_DIR != POST_DIR; + assert COURSE_DIR == "apps/website/specs/course/"; + assert POST_DIR == "apps/website/src/data/blog/bodies/"; +} + +test only_a_written_post_counts { + assert POST_STATUS_COUNT == 2; + assert POST_STATUSES[0] == "added"; + assert POST_STATUSES[1] == "modified"; +} diff --git a/docs/PR_BLOG_AUTOMATION.md b/docs/PR_BLOG_AUTOMATION.md index 15b1067037..ab7004fa0b 100644 --- a/docs/PR_BLOG_AUTOMATION.md +++ b/docs/PR_BLOG_AUTOMATION.md @@ -74,6 +74,14 @@ human push to the branch ends the exemption. The bump writes a publication task. A body that carries a report block is validated normally, Dependabot or not. Humans and agents have no exemption. +## A course PR carries its post + +A PR that changes a course also carries its blog post in the same PR, not after the +merge. The rule, the course directory and the post directory live in one place, +`apps/website/specs/policy/course_post.t27`; `scripts/pr_blog_report.py validate --files` +reads that spec and turns `T27 work report` red when a course file changed and no post +body was added or modified. This note points at the spec and does not restate it. + ## Automation and safety `pr-blog-report.yml` uses `pull_request_target` and checks out only trusted diff --git a/scripts/pr_blog_report.py b/scripts/pr_blog_report.py index 1c4eeaf422..a871fae672 100644 --- a/scripts/pr_blog_report.py +++ b/scripts/pr_blog_report.py @@ -51,6 +51,13 @@ r"|composer\.(?:json|lock)|build\.zig\.zon)\Z") DEPENDENCY_FILE_STATUS = {"modified", "added", "changed"} +# A PR that changes a course carries its blog post. The rule's words and paths live in a +# t27 spec; this program reads only its str constants and fails closed when they are absent. +COURSE_POST_SPEC = Path(__file__).resolve().parent.parent / "apps/website/specs/policy/course_post.t27" +STR_CONST = re.compile(r'^pub const ([A-Z][A-Z0-9_]*) : str = "([^"\\]*)";$', re.MULTILINE) +STR_LIST_CONST = re.compile(r'^pub const ([A-Z][A-Z0-9_]*) : \[([0-9]+)\]str = \[([^\]]*)\];$', re.MULTILINE) +STR_ITEM = re.compile(r'"([^"\\]*)"') + class ReportError(ValueError): """A report or event failed the enforced contract.""" @@ -326,6 +333,47 @@ def dependency_bump_report(event: dict[str, Any], files: list[dict[str, Any]], } +def course_post_policy(path: Path = COURSE_POST_SPEC) -> dict[str, Any]: + """Read the course-post rule from its t27 spec. A missing or malformed spec fails closed.""" + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + fail(f"course-post policy {path.name} is unreadable: {exc}") + strings = dict(STR_CONST.findall(source)) + lists = {name: (int(size), STR_ITEM.findall(items)) for name, size, items in STR_LIST_CONST.findall(source)} + for name in ("RULE", "COURSE_DIR", "POST_DIR"): + if not strings.get(name): + fail(f"course-post policy {path.name} has no str constant {name}") + if not strings["COURSE_DIR"].endswith("/") or not strings["POST_DIR"].endswith("/"): + fail(f"course-post policy {path.name}: COURSE_DIR and POST_DIR must end with /") + size, statuses = lists.get("POST_STATUSES", (0, [])) + # t27 does not check a declared array length (gHashTag/t27#7395), so this reader does. + if not statuses or len(statuses) != size: + fail(f"course-post policy {path.name}: POST_STATUSES must list exactly its declared length") + return {"rule": strings["RULE"], "course_dir": strings["COURSE_DIR"], + "post_dir": strings["POST_DIR"], "statuses": frozenset(statuses)} + + +def course_post_refusal(event: dict[str, Any], files: list[dict[str, Any]], + policy: dict[str, Any]) -> tuple[str | None, str | None]: + """Return (refusal, notice): why a course PR lacks its post, and what could not be decided.""" + changes = [(item.get("filename"), item.get("status")) for item in files] + if any(not isinstance(name, str) or not isinstance(status, str) for name, status in changes): + return "changed-file list has an entry without a filename or status", None + course = [name for name, _ in changes if name.startswith(policy["course_dir"])] + if not course: + changed = event["pull_request"].get("changed_files") + if type(changed) is int and len(files) < changed: + return None, (f"course-post rule not decided: the files API listed {len(files)} of " + f"{changed} changed files and none of them is under {policy['course_dir']}") + return None, None + if any(name.startswith(policy["post_dir"]) and status in policy["statuses"] for name, status in changes): + return None, None + more = f" and {len(course) - 1} more course file(s)" if len(course) > 1 else "" + return (f"{policy['rule']} This PR changes {course[0]}{more} but adds or modifies no post " + f"under {policy['post_dir']} (rule in {COURSE_POST_SPEC.relative_to(COURSE_POST_SPEC.parents[3])})"), None + + def lifecycle(report: dict[str, Any]) -> str: if report["merged"]: return "Merged PR; unpublished blog draft" @@ -525,8 +573,8 @@ def main(argv: list[str] | None = None) -> int: event = decode_json(raw.decode("utf-8"), "event") pr = event.get("pull_request") if isinstance(event, dict) else None body = pr.get("body") if isinstance(pr, dict) else None - if args.files and args.commits and not (isinstance(body, str) and START in body): - files = json_lines(args.files.read_text(encoding="utf-8"), "files") + files = json_lines(args.files.read_text(encoding="utf-8"), "files") if args.files else None + if files is not None and args.commits and not (isinstance(body, str) and START in body): commits = json_lines(args.commits.read_text(encoding="utf-8"), "commits") refusal = dependency_bump_refusal(event, files, commits) if refusal is None: @@ -540,6 +588,12 @@ def main(argv: list[str] | None = None) -> int: if isinstance(pr, dict) and isinstance(pr.get("user"), dict) and pr["user"].get("login") == DEPENDABOT: print(f"Dependency-bump exemption does not apply: {refusal}", file=sys.stderr) report = validate_event(event) + if files is not None: + refusal, notice = course_post_refusal(event, files, course_post_policy()) + if notice: + print(notice, file=sys.stderr) + if refusal: + fail(refusal) write_artifacts(report, args.output) print(f"Validated {report['repository']}#{report['number']} at {report['head_sha']}: " f"{lifecycle(report)}; artifacts written to {args.output}") diff --git a/scripts/test_pr_blog_report.py b/scripts/test_pr_blog_report.py index 5c1b7a15ba..90e0dc1495 100644 --- a/scripts/test_pr_blog_report.py +++ b/scripts/test_pr_blog_report.py @@ -460,5 +460,96 @@ def test_a_report_block_is_validated_normally_even_from_dependabot(self): self.assertIn("stale", result.stderr) +COURSE = "apps/website/specs/course/ai-numbers.t27" +POST = "apps/website/src/data/blog/bodies/two-courses.ts" + + +class CoursePostTests(unittest.TestCase): + def refusal(self, files, changed=None): + event = valid_event() + event["pull_request"]["changed_files"] = len(files) if changed is None else changed + return report.course_post_refusal(event, files, report.course_post_policy()) + + def test_the_real_policy_spec_is_read(self): + policy = report.course_post_policy() + self.assertEqual(policy["course_dir"], "apps/website/specs/course/") + self.assertEqual(policy["post_dir"], "apps/website/src/data/blog/bodies/") + self.assertEqual(policy["statuses"], frozenset({"added", "modified"})) + self.assertIn("in the same PR", policy["rule"]) + + def test_a_course_change_without_a_post_is_refused(self): + refusal, notice = self.refusal([{"filename": COURSE, "status": "modified"}, + {"filename": "apps/website/specs/course/courses.t27", "status": "modified"}]) + self.assertIn("carries its blog post", refusal) + self.assertIn(COURSE, refusal) + self.assertIn("1 more course file", refusal) + self.assertIn("specs/policy/course_post.t27", refusal) + self.assertIsNone(notice) + + def test_an_added_or_modified_post_satisfies_it(self): + for status in ("added", "modified"): + self.assertEqual(self.refusal([{"filename": COURSE, "status": "modified"}, + {"filename": POST, "status": status}]), (None, None)) + + def test_a_removed_or_renamed_post_does_not_count(self): + for status in ("removed", "renamed"): + refusal, _ = self.refusal([{"filename": COURSE, "status": "modified"}, + {"filename": POST, "status": status}]) + self.assertIn("carries its blog post", refusal) + + def test_other_prs_and_the_policy_itself_are_not_course_changes(self): + for name in ("apps/website/src/App.tsx", "apps/website/specs/policy/course_post.t27", + "apps/website/specs/coursework.t27", "specs/course/x.t27"): + self.assertEqual(self.refusal([{"filename": name, "status": "modified"}]), (None, None)) + + def test_an_incomplete_list_without_a_course_file_is_said_not_passed_silently(self): + refusal, notice = self.refusal([{"filename": "README.md", "status": "modified"}], changed=3001) + self.assertIsNone(refusal) + self.assertIn("not decided", notice) + + def test_malformed_file_entries_are_refused(self): + refusal, _ = self.refusal([{"filename": COURSE}]) + self.assertIn("without a filename or status", refusal) + + def test_a_policy_spec_that_lost_its_rule_fails_closed(self): + source = report.COURSE_POST_SPEC.read_text(encoding="utf-8") + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "course_post.t27" + for broken, reason in ( + (source.replace("pub const RULE :", "pub const RULES :"), "no str constant RULE"), + (source.replace('= ["added", "modified"]', '= ["added"]'), "declared length"), + (source.replace('"apps/website/specs/course/"', '"apps/website/specs/course"', 1), "must end with /"), + ): + path.write_text(broken, encoding="utf-8") + with self.assertRaisesRegex(report.ReportError, reason): + report.course_post_policy(path) + with self.assertRaisesRegex(report.ReportError, "unreadable"): + report.course_post_policy(Path(directory) / "missing.t27") + + def run_cli(self, files, directory): + root = Path(directory) + event = valid_event() + event["pull_request"]["changed_files"] = len(files) + (root / "event.json").write_text(json.dumps(event)) + (root / "files.jsonl").write_text("\n".join(json.dumps(item) for item in files) + "\n") + command = [sys.executable, str(Path(report.__file__)), "validate", "--event", str(root / "event.json"), + "--output", str(root / "out"), "--files", str(root / "files.jsonl")] + return subprocess.run(command, capture_output=True, text=True) + + def test_cli_refuses_a_course_pr_without_its_post_and_writes_nothing(self): + with tempfile.TemporaryDirectory() as directory: + result = self.run_cli([{"filename": COURSE, "status": "modified"}], directory) + self.assertEqual(result.returncode, 1) + self.assertIn("carries its blog post", result.stderr) + self.assertFalse((Path(directory) / "out").exists()) + + def test_cli_accepts_a_course_pr_with_its_post(self): + with tempfile.TemporaryDirectory() as directory: + result = self.run_cli([{"filename": COURSE, "status": "modified"}, + {"filename": POST, "status": "added"}], directory) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((Path(directory) / "out" / "report.json").exists()) + + if __name__ == "__main__": unittest.main() From f57e6308c1d747ba2c686470bf15cbc173e18654 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 16:35:08 +0700 Subject: [PATCH 2/3] fix(blog-report): name the course-post spec by its full path, count renamed lessons The refusal said "rule in website/specs/policy/course_post.t27": the path was taken relative to parents[3] of the spec, which is apps/, not the repository root. The repo-relative path is now one constant, and a test asserts it in full and checks the file exists there. Two gaps closed on the way: - a lesson renamed out of apps/website/specs/course/ is a course change (previous_filename is read; the workflow now fetches it); - a files list shorter than changed_files with a course file and no post is a notice ("not decided"), not a refusal, since the post may sit in the unlisted part. The spec's WHAT COUNTS note now says why a renamed post does not count: GitHub reports a rename with edits as "renamed" too. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-blog-report.yml | 5 +++-- apps/website/specs/policy/course_post.t27 | 14 ++++++++----- scripts/pr_blog_report.py | 25 +++++++++++++++-------- scripts/test_pr_blog_report.py | 23 ++++++++++++++++++++- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pr-blog-report.yml b/.github/workflows/pr-blog-report.yml index cd244fe15f..202e65587c 100644 --- a/.github/workflows/pr-blog-report.yml +++ b/.github/workflows/pr-blog-report.yml @@ -45,9 +45,10 @@ jobs: - name: Load changed files and commits as data run: | set -euo pipefail - # Only used for the Dependabot dependency-bump exemption; see scripts/pr_blog_report.py. + # Used for the Dependabot dependency-bump exemption and the course-post rule + # (apps/website/specs/policy/course_post.t27); see scripts/pr_blog_report.py. gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \ - --jq '.[] | {filename, status}' > /tmp/pr-files.jsonl + --jq '.[] | {filename, status, previous_filename}' > /tmp/pr-files.jsonl gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits?per_page=100" \ --jq '.[] | {sha, author: .author.login, committer: .committer.login, verified: .commit.verification.verified}' > /tmp/pr-commits.jsonl - name: Validate mandatory report and generate blog draft diff --git a/apps/website/specs/policy/course_post.t27 b/apps/website/specs/policy/course_post.t27 index 5e94013eb8..987842f2c0 100644 --- a/apps/website/specs/policy/course_post.t27 +++ b/apps/website/specs/policy/course_post.t27 @@ -13,13 +13,17 @@ ; says what changed, what the recordings show and what they do not. ; WHAT COUNTS: paths are repository paths as the GitHub pull request files API returns them. -; A post body is a file under POST_DIR (English body and Russian ruBody in one module); a -; removed or only renamed body does not count. A changed policy file is not a course change, -; which is why this file lives under specs/policy/ and not under specs/course/. +; A file whose new or previous path is under COURSE_DIR is a course change, so moving a lesson +; out of the course counts too. A post body is a file under POST_DIR (English body and Russian +; ruBody in one module) whose status is one of POST_STATUSES. A removed body does not count, +; and neither does a renamed one: the API reports a rename as "renamed" even when the file was +; also edited, so a moved post is not taken as a written one. A changed policy file is not a +; course change, which is why this file lives under specs/policy/ and not under specs/course/. ; LIMIT: the files API lists at most 3000 files. When the list is shorter than the PR's -; changed_files and no listed file is under COURSE_DIR, the rule cannot be decided and the -; validator says so instead of passing or failing silently. +; changed_files and the listed part does not settle the rule (no course file, or a course file +; with no post), the validator says the rule is not decided instead of passing or failing +; silently. ; phi^2 + 1/phi^2 = 3 | TRINITY module course_post_policy; diff --git a/scripts/pr_blog_report.py b/scripts/pr_blog_report.py index a871fae672..f51a370736 100644 --- a/scripts/pr_blog_report.py +++ b/scripts/pr_blog_report.py @@ -53,7 +53,8 @@ # A PR that changes a course carries its blog post. The rule's words and paths live in a # t27 spec; this program reads only its str constants and fails closed when they are absent. -COURSE_POST_SPEC = Path(__file__).resolve().parent.parent / "apps/website/specs/policy/course_post.t27" +COURSE_POST_REL = "apps/website/specs/policy/course_post.t27" +COURSE_POST_SPEC = Path(__file__).resolve().parent.parent / COURSE_POST_REL STR_CONST = re.compile(r'^pub const ([A-Z][A-Z0-9_]*) : str = "([^"\\]*)";$', re.MULTILINE) STR_LIST_CONST = re.compile(r'^pub const ([A-Z][A-Z0-9_]*) : \[([0-9]+)\]str = \[([^\]]*)\];$', re.MULTILINE) STR_ITEM = re.compile(r'"([^"\\]*)"') @@ -357,21 +358,29 @@ def course_post_policy(path: Path = COURSE_POST_SPEC) -> dict[str, Any]: def course_post_refusal(event: dict[str, Any], files: list[dict[str, Any]], policy: dict[str, Any]) -> tuple[str | None, str | None]: """Return (refusal, notice): why a course PR lacks its post, and what could not be decided.""" - changes = [(item.get("filename"), item.get("status")) for item in files] - if any(not isinstance(name, str) or not isinstance(status, str) for name, status in changes): + changes = [(item.get("filename"), item.get("status"), item.get("previous_filename")) for item in files] + if any(not isinstance(name, str) or not isinstance(status, str) or not isinstance(old, (str, type(None))) + for name, status, old in changes): return "changed-file list has an entry without a filename or status", None - course = [name for name, _ in changes if name.startswith(policy["course_dir"])] + # A file renamed out of the course directory is still a course change. + course = [name for name, _, old in changes + if name.startswith(policy["course_dir"]) or (old or "").startswith(policy["course_dir"])] + changed = event["pull_request"].get("changed_files") + incomplete = type(changed) is int and len(files) < changed if not course: - changed = event["pull_request"].get("changed_files") - if type(changed) is int and len(files) < changed: + if incomplete: return None, (f"course-post rule not decided: the files API listed {len(files)} of " f"{changed} changed files and none of them is under {policy['course_dir']}") return None, None - if any(name.startswith(policy["post_dir"]) and status in policy["statuses"] for name, status in changes): + if any(name.startswith(policy["post_dir"]) and status in policy["statuses"] for name, status, _ in changes): return None, None + if incomplete: + return None, (f"course-post rule not decided: the files API listed {len(files)} of {changed} " + f"changed files, {course[0]} among them, and no post under {policy['post_dir']} " + "was seen in the listed part") more = f" and {len(course) - 1} more course file(s)" if len(course) > 1 else "" return (f"{policy['rule']} This PR changes {course[0]}{more} but adds or modifies no post " - f"under {policy['post_dir']} (rule in {COURSE_POST_SPEC.relative_to(COURSE_POST_SPEC.parents[3])})"), None + f"under {policy['post_dir']} (rule in {COURSE_POST_REL})"), None def lifecycle(report: dict[str, Any]) -> str: diff --git a/scripts/test_pr_blog_report.py b/scripts/test_pr_blog_report.py index 90e0dc1495..f4ef339644 100644 --- a/scripts/test_pr_blog_report.py +++ b/scripts/test_pr_blog_report.py @@ -483,9 +483,30 @@ def test_a_course_change_without_a_post_is_refused(self): self.assertIn("carries its blog post", refusal) self.assertIn(COURSE, refusal) self.assertIn("1 more course file", refusal) - self.assertIn("specs/policy/course_post.t27", refusal) + self.assertIn("(rule in apps/website/specs/policy/course_post.t27)", refusal) + self.assertTrue((Path(report.__file__).resolve().parent.parent / report.COURSE_POST_REL).is_file()) self.assertIsNone(notice) + def test_a_lesson_renamed_out_of_the_course_is_a_course_change(self): + moved = {"filename": "apps/website/specs/archive/ai-numbers.t27", "status": "renamed", + "previous_filename": COURSE} + refusal, _ = self.refusal([moved]) + self.assertIn("carries its blog post", refusal) + self.assertEqual(self.refusal([moved, {"filename": POST, "status": "added", "previous_filename": None}]), + (None, None)) + + def test_a_previous_filename_that_is_not_a_path_is_refused(self): + refusal, _ = self.refusal([{"filename": COURSE, "status": "renamed", "previous_filename": 7}]) + self.assertIn("without a filename or status", refusal) + + def test_an_incomplete_list_with_a_course_file_and_no_post_is_not_decided(self): + refusal, notice = self.refusal([{"filename": COURSE, "status": "modified"}], changed=3001) + self.assertIsNone(refusal) + self.assertIn("not decided", notice) + self.assertIn(COURSE, notice) + self.assertEqual(self.refusal([{"filename": COURSE, "status": "modified"}, + {"filename": POST, "status": "modified"}], changed=3001), (None, None)) + def test_an_added_or_modified_post_satisfies_it(self): for status in ("added", "modified"): self.assertEqual(self.refusal([{"filename": COURSE, "status": "modified"}, From d3bdb9022bc170811228b2de93acc9ec0dc14a1e Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 18:01:57 +0700 Subject: [PATCH 3/3] feat(blog): a course PR now carries its blog post A bilingual post (EN body + RU ruBody) on this PR: the rule in apps/website/specs/policy/course_post.t27, how scripts/pr_blog_report.py reads it (fail closed; renames counted by old and new path; a renamed post does not count; an incomplete file list is "not decided"), and the seven planted mutants, each killed by its named test. Says plainly the PR is open and the rule binds only after the merge. Refs #1460 Co-Authored-By: Claude Opus 5.5 --- .../bodies/a-course-pr-carries-its-post.ts | 101 ++++++++++++++++++ apps/website/src/data/blog/index.ts | 32 ++++++ apps/website/src/data/blog/posts.ts | 2 + 3 files changed, 135 insertions(+) create mode 100644 apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts diff --git a/apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts b/apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts new file mode 100644 index 0000000000..495f84f33c --- /dev/null +++ b/apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts @@ -0,0 +1,101 @@ +import type { Block } from '../types' + +// Every fact here is read from trinity#1461 at f57e6308c: apps/website/specs/policy/course_post.t27, +// scripts/pr_blog_report.py, scripts/test_pr_blog_report.py, .github/workflows/pr-blog-report.yml, +// and the PR's work report (the mutant runs). The test count is a local rerun on 2026-10-07. + +export const body: Block[] = [ + { + kind: 'p', + text: 'Every trinity PR already carries a work report, and a merged PR later becomes a blog draft. A course change could still land with no post at all, so a reader could meet a new lesson before anything explained it. trinity#1461 closes that gap: a PR that changes a course now has to carry its blog post, English body and Russian ruBody, in the same PR, or the "T27 work report" status turns red. The PR is open and not merged yet.', + }, + { kind: 'h', text: 'The rule lives in a t27 spec' }, + { + kind: 'p', + text: 'The words of the rule are in one file, `apps/website/specs/policy/course_post.t27`. It holds the rule sentence, the course directory (`apps/website/specs/course/`), the post directory (`apps/website/src/data/blog/bodies/`) and the two file statuses that count as a written post, "added" and "modified". Three `test` blocks pin those values. The spec sits under `specs/policy/`, not under `specs/course/`, so editing the rule is not itself a course change.', + }, + { + kind: 'p', + text: '`scripts/pr_blog_report.py` does not restate the rule. It reads the spec\'s `str` constants and its one string list, and it fails closed: a missing constant, a directory without its trailing slash, or a `POST_STATUSES` list whose length differs from its declared length stops the check. The script checks that length itself because t27 does not check a declared array length yet (gHashTag/t27#7395). The refusal message is the spec\'s RULE sentence followed by the file that triggered it.', + }, + { kind: 'h', text: 'What counts as a change and as a post' }, + { + kind: 'ul', + items: [ + 'A file counts as a course change if its new path or its previous path is under the course directory. The workflow now asks the GitHub files API for `previous_filename` as well, so moving a lesson out of the course still counts.', + 'A post counts only when a body file is "added" or "modified". A removed body does not count, and neither does a renamed one: the API reports a rename as "renamed" even when the file was also edited, so a moved post is not taken for a written one.', + 'The files API lists at most 3000 files. When the list is shorter than the PR\'s `changed_files` and the listed part does not settle the rule, the script prints that the rule is "not decided" instead of passing or failing silently.', + 'An entry without a filename or status, or with a previous path that is not a string, is refused.', + ], + }, + { kind: 'h', text: 'Seven planted bugs, seven red tests' }, + { + kind: 'p', + text: 'The PR adds 10 tests for the rule; the pipeline suite now runs 71 tests, and a rerun on 7 October gave OK. To check that the tests can see a bug, seven mutants were planted in `pr_blog_report.py`, one at a time, each restored with git afterwards. Each one failed the test named for it.', + }, + { + kind: 'ol', + items: [ + 'The file status is ignored, so a removed post would count.', + 'The refusal is never raised.', + 'The declared length of POST_STATUSES is not checked.', + 'The incomplete-list check is inverted.', + 'The refusal names the spec the old way, by a path cut with parents[3], which dropped the leading apps/.', + 'previous_filename is ignored, so a lesson renamed out of the course is missed.', + 'The "not decided" notice for an incomplete list with a course file is turned back into a refusal.', + ], + }, + { kind: 'h', text: 'What it does not check' }, + { + kind: 'p', + text: 'It checks that a post body file changed, not that the post describes the course change; review and the blog checks still judge the content. The workflow runs the script from main (`pull_request_target`), so the rule binds only PRs opened or pushed after the merge. A manual re-run of the report for an older course PR that shipped without a post will now turn red.', + }, +] + +export const ruBody: Block[] = [ + { + kind: 'p', + text: 'У каждого PR в trinity уже есть отчёт о работе, а влитый PR потом становится черновиком поста в блоге. Но изменение курса всё ещё могло попасть в main совсем без поста, и читатель встречал новый урок раньше, чем что-нибудь его объясняло. trinity#1461 закрывает эту дыру: PR, который меняет курс, обязан нести свой пост — английское body и русское ruBody — в том же PR, иначе статус «T27 work report» краснеет. PR открыт и ещё не влит.', + }, + { kind: 'h', text: 'Правило живёт в спеке t27' }, + { + kind: 'p', + text: 'Слова правила лежат в одном файле, `apps/website/specs/policy/course_post.t27`. В нём фраза правила, каталог курса (`apps/website/specs/course/`), каталог постов (`apps/website/src/data/blog/bodies/`) и два статуса файла, которые считаются написанным постом: «added» и «modified». Три блока `test` закрепляют эти значения. Спека лежит в `specs/policy/`, а не в `specs/course/`, поэтому правка правила сама изменением курса не считается.', + }, + { + kind: 'p', + text: '`scripts/pr_blog_report.py` правило не пересказывает. Он читает строковые константы спеки и её единственный список строк и при сбое закрывается: нет константы, у каталога нет косой черты в конце, или длина списка `POST_STATUSES` не совпадает с объявленной — проверка останавливается. Длину скрипт сверяет сам, потому что t27 пока не проверяет объявленную длину массива (gHashTag/t27#7395). Сообщение об отказе — это фраза RULE из спеки и файл, который её задел.', + }, + { kind: 'h', text: 'Что считается изменением и что — постом' }, + { + kind: 'ul', + items: [ + 'Файл считается изменением курса, если его новый или прежний путь лежит в каталоге курса. Workflow теперь запрашивает у GitHub API ещё и `previous_filename`, поэтому урок, вынесенный из курса, тоже считается.', + 'Пост засчитывается, только если файл тела «added» или «modified». Удалённое тело не считается, переименованное тоже: API называет переименование «renamed», даже если файл ещё и правили, так что перенесённый пост не принимается за написанный.', + 'API файлов отдаёт не больше 3000 файлов. Если список короче `changed_files` у PR и видимая часть не решает дело, скрипт пишет, что правило «не решено», а не проходит и не падает молча.', + 'Запись без имени файла или статуса, или с прежним путём, который не строка, отклоняется.', + ], + }, + { kind: 'h', text: 'Семь посаженных ошибок, семь красных тестов' }, + { + kind: 'p', + text: 'PR добавляет 10 тестов на правило; весь набор теперь гоняет 71 тест, и повторный прогон 7 октября дал OK. Чтобы проверить, что тесты видят ошибку, в `pr_blog_report.py` по одному посадили семь мутантов, каждый потом откатили через git. Каждый уронил тест, названный в его честь.', + }, + { + kind: 'ol', + items: [ + 'Статус файла не смотрится, и удалённый пост засчитался бы.', + 'Отказ никогда не выдаётся.', + 'Объявленная длина POST_STATUSES не проверяется.', + 'Проверка неполного списка перевёрнута.', + 'Отказ называет спеку по-старому, путём, обрезанным через parents[3], без начального apps/.', + 'previous_filename не смотрится, и урок, вынесенный из курса, пропускается.', + 'Пометка «не решено» для неполного списка с файлом курса снова превращена в отказ.', + ], + }, + { kind: 'h', text: 'Чего это не проверяет' }, + { + kind: 'p', + text: 'Проверяется, что файл тела поста изменился, а не то, что пост описывает изменение курса; содержание по-прежнему судят ревью и проверки блога. Workflow запускает скрипт из main (`pull_request_target`), поэтому правило действует только на PR, открытые или обновлённые после слияния. Ручной перезапуск отчёта для старого PR курса, ушедшего без поста, теперь станет красным.', + }, +] diff --git a/apps/website/src/data/blog/index.ts b/apps/website/src/data/blog/index.ts index 07c686c3c2..4274eac6f7 100644 --- a/apps/website/src/data/blog/index.ts +++ b/apps/website/src/data/blog/index.ts @@ -2,6 +2,38 @@ import type { PostMeta } from './types' /** Индекс блога: список и метаданные без тяжёлых тел публикаций. */ export const postsIndex: PostMeta[] = [ + { + slug: "a-course-pr-carries-its-post", + title: "A course PR now carries its blog post", + summary: "[open PR, not merged; binds only PRs opened or pushed after the merge; checks that a post file changed, not what it says] The T27 work report check now refuses a trinity PR that changes a course without adding or modifying a blog post body in the same PR. The rule's words live only in a t27 spec, specs/policy/course_post.t27, which the checker reads and fails closed on. Renamed lessons count by old and new path, renamed posts do not count, and an incomplete file list is reported as not decided. Seven planted mutants each failed their own test.", + date: "2026-10-07", + readingMinutes: 4, + tags: ["CI", "Courses", "Blog", "Mutation testing"], + receipts: [ + { label: "trinity#1461: a course PR carries its blog post (this post's PR)", href: "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/gHashTag/trinity/pull/1461" }, + { label: "trinity#1460: the issue", href: "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/gHashTag/trinity/issues/1460" }, + { label: "The rule: apps/website/specs/policy/course_post.t27", href: "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/gHashTag/trinity/blob/f57e6308c1d747ba2c686470bf15cbc173e18654/apps/website/specs/policy/course_post.t27" }, + { label: "The checker: scripts/pr_blog_report.py", href: "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/gHashTag/trinity/blob/f57e6308c1d747ba2c686470bf15cbc173e18654/scripts/pr_blog_report.py" }, + { label: "t27#7395: t27 does not check a declared array length", href: "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/gHashTag/t27/issues/7395" }, + ], + openQuestions: [ + "trinity#1461 is open; until it merges, the rule binds no PR, because the workflow runs the checker from main.", + "The check sees that a post body file was added or modified, not that the post is about the course change; that is still left to review.", + "Above the files API limit of 3000 files the rule can come out as not decided rather than judged.", + "A manual re-run of the report for an older course PR that shipped without a post will now turn red.", + ], + published: true, + ru: { + title: "PR курса теперь несёт свой пост", + summary: "[PR открыт и не влит; действует только на PR, открытые или обновлённые после слияния; проверяет, что файл поста изменился, а не что в нём написано] Проверка T27 work report теперь отклоняет PR в trinity, который меняет курс и не добавляет и не правит тело поста в блоге в том же PR. Слова правила живут только в спеке t27, specs/policy/course_post.t27, которую проверка читает и при сбое закрывается. Переименованные уроки считаются по старому и новому пути, переименованные посты не считаются, а неполный список файлов даёт «не решено». Семь посаженных мутантов уронили каждый свой тест.", + openQuestions: [ + "trinity#1461 открыт; пока он не влит, правило не действует ни на один PR, потому что workflow запускает проверку из main.", + "Проверка видит, что файл тела поста добавлен или изменён, но не то, что пост — об изменении курса; это по-прежнему дело ревью.", + "Выше предела API в 3000 файлов правило может выйти «не решено», а не вынесенным.", + "Ручной перезапуск отчёта для старого PR курса, ушедшего без поста, теперь станет красным.", + ], + }, + }, { slug: "one-outlier-twenty-three-zeros", title: "One outlier, 23 zeros: a course module on the OCP MX block", diff --git a/apps/website/src/data/blog/posts.ts b/apps/website/src/data/blog/posts.ts index 8a98fc4f49..7514638785 100644 --- a/apps/website/src/data/blog/posts.ts +++ b/apps/website/src/data/blog/posts.ts @@ -1,3 +1,4 @@ +import { body as body_a_course_pr_carries_its_post, ruBody as ruBody_a_course_pr_carries_its_post } from './bodies/a-course-pr-carries-its-post' import { body as body_one_outlier_twenty_three_zeros, ruBody as ruBody_one_outlier_twenty_three_zeros } from './bodies/one-outlier-twenty-three-zeros' import { body as body_a_terminal_for_seven_backends_in_an_x_post, ruBody as ruBody_a_terminal_for_seven_backends_in_an_x_post } from './bodies/a-terminal-for-seven-backends-in-an-x-post' import { body as body_t27c_compile_time_and_a_backend_without_llvm, ruBody as ruBody_t27c_compile_time_and_a_backend_without_llvm } from './bodies/t27c-compile-time-and-a-backend-without-llvm' @@ -93,6 +94,7 @@ import { body as body_features_that_change_no_bits, ruBody as ruBody_features_th import { body as body_one_commit_nine_workflow_outcomes, ruBody as ruBody_one_commit_nine_workflow_outcomes } from './bodies/one-commit-nine-workflow-outcomes' const bodies: Record = { + 'a-course-pr-carries-its-post': { body: body_a_course_pr_carries_its_post, ruBody: ruBody_a_course_pr_carries_its_post }, 'one-outlier-twenty-three-zeros': { body: body_one_outlier_twenty_three_zeros, ruBody: ruBody_one_outlier_twenty_three_zeros }, 'a-terminal-for-seven-backends-in-an-x-post': { body: body_a_terminal_for_seven_backends_in_an_x_post, ruBody: ruBody_a_terminal_for_seven_backends_in_an_x_post }, 't27c-compile-time-and-a-backend-without-llvm': { body: body_t27c_compile_time_and_a_backend_without_llvm, ruBody: ruBody_t27c_compile_time_and_a_backend_without_llvm },