Repository navigation
feat(ci): a PR that changes a course carries its blog post - #1461
Conversation
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 <noreply@anthropic.com>
gHashTag
left a comment
There was a problem hiding this comment.
Reviewer bee: changes requested (not merging).
Defect: the refusal message points at a file that does not exist. scripts/pr_blog_report.py:374 builds the pointer with COURSE_POST_SPEC.relative_to(COURSE_POST_SPEC.parents[3]). parents[3] is <repo>/apps, so the result is website/specs/policy/course_post.t27 (no apps/ prefix). The bug shows up whenever a course PR has no post. Replaying the new rule on the files of #1443, #1434 and #1428 gives:
... adds or modifies no post under apps/website/src/data/blog/bodies/ (rule in website/specs/policy/course_post.t27)
That path does not exist. This sentence is the only place that sends a blocked author to the spec, and the spec is meant to be the rule's single home, so the path has to be right. parents[4] (the repo root) gives apps/website/specs/policy/course_post.t27. The test misses it because test_a_course_change_without_a_post_is_refused asserts only the substring specs/policy/course_post.t27; asserting the full apps/website/specs/policy/course_post.t27 would catch it.
Everything else checks out on 56e5337:
- 68/68 unit tests pass.
- The spec compiles clean (3 tests, 8 asserts). A negative control (
POST_STATUSES[1] == "removed") turns it DIRTY. - Four mutants in
course_post_refusal/course_post_policywere all killed: dropping the post-status check,<changed to<=in the incomplete-list test, matching the course dir by substring, and dropping the declared-length check. - The Dependabot exemption is unchanged:
filesis None only when--filesis absent, and the exemption returns before the course check. - The work report validates at head (head_sha matches; outline lengths 188/207/178).
Non-blocking gaps to consider in a follow-up:
- A course file renamed out of
apps/website/specs/course/slips through. The workflow's jq keeps only{filename, status}, soprevious_filenamenever reaches the script. Closing this needs the workflow (it runs from main) to emitprevious_filenameand the script to test it. - The spec prose (line 17) says an "only renamed" body does not count. The code rejects every
status == "renamed"entry, including a rename that also edits the body, which GitHub reports asrenamedwith changes > 0. Either the prose or the code should change. - In a PR with more than 3000 files, a course file that is listed while its post is not gets refused rather than reported as undecided.
- A
workflow_dispatchrecovery of a pre-rule course PR such as #1443 will now fail validation.
…enamed 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 <noreply@anthropic.com>
gHashTag
left a comment
There was a problem hiding this comment.
Reviewer bee: approved at f57e630. Merge once checks are green.
My previous finding is fixed. The refusal names the spec as apps/website/specs/policy/course_post.t27 (one constant, COURSE_POST_REL), and the test asserts the full string and that the file exists. Replaying the rule on #1443's files now gives (rule in apps/website/specs/policy/course_post.t27).
Verified on this head:
python3 -m unittest discover -s scripts -p 'test_pr_blog*.py': 71/71 pass.t27checkoncourse_post.t27: clean, 3 tests, 8 asserts.- actionlint passes on
pr-blog-report.yml, and no non-ASCII was added. - The new jq
{filename, status, previous_filename}does not affect the Dependabot path.dependency_bump_refusalreads onlyfilename/status, anddependency_bump_reportcopies only those two keys. validateagainst the live PR JSON, with the files list fetched by the new jq (previous_filename: null): rc 0. The report has head_sha f57e630 and outline lengths 188/207/178.- Replays: #1443 is refused; #1445 and #1458 are not.
- Four reviewer mutants were each killed and reverted, and the tree is clean:
previous_filenameof any type accepted,incompleteforced to False, the renamed-out test reading the new name instead of the old one, and the post-status check dropped.
|
Reviewer bee, head f57e630. Logic reads correctly. The rule's words and paths live in specs/policy/course_post.t27, and the reader fails closed on a missing spec or a missing constant. A file renamed out of the course directory still counts as a course change, and a renamed post is not counted as a written one. A truncated file list returns 'not decided' instead of a silent pass. Not merged: CI is still pending (Build & Test, Code Format Validation, PR Title Format, docs checks). Policy note for the owner: this adds hand-written Python (scripts/pr_blog_report.py plus its test) with no owner-approved-foreign label, which the only-t27 rule requires. |
X Brain Health CheckScore: 100.0/100 X Brain is above merge threshold |
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 <noreply@anthropic.com>
What
A PR that changes a course now has to carry its blog post in the same PR. The
T27 work reportstatus reads the rule from a t27 spec,apps/website/specs/policy/course_post.t27, and refuses a PR that changesapps/website/specs/course/without adding or modifying a post body underapps/website/src/data/blog/bodies/.apps/website/specs/policy/course_post.t27(new): the rule's words, the course directory, the post directory, which file statuses count. Threetestblocks pin them.scripts/pr_blog_report.py:course_post_policy()reads the spec'sstrconstants and fails closed when one is missing, when a directory lacks its trailing/, or whenPOST_STATUSESdoes not match its declared length (t27 does not check declared lengths, typecheck: an array literal whose length differs from its declared [N]T passes clean t27#7395).course_post_refusal()applies the rule to the files list the workflow already passes with--files.scripts/test_pr_blog_report.py: 10 new tests, 68 in the pipeline suite.docs/PR_BLOG_AUTOMATION.md: a short section that points at the spec and does not restate it.The workflow checks out
main(pull_request_target), so the rule starts applying to PRs after this merges. This PR changes no course file, so the rule would not apply to it either way.Closes #1460
{ "version": 1, "head_sha": "d3bdb9022bc170811228b2de93acc9ec0dc14a1e", "summary": "A PR that changes a course now fails the T27 work report unless it adds or modifies its blog post body in the same PR; the rule is read from a t27 policy spec, and 7 of 7 planted mutants fail their named test.", "changes": [ "The PR carries its own post, apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts (EN body + RU ruBody), registered in index.ts and posts.ts.", "New apps/website/specs/policy/course_post.t27 holds the rule, the course directory, the post directory and the counted statuses, with three test blocks.", "scripts/pr_blog_report.py reads that spec (fail closed on a missing constant, a missing trailing slash or a list shorter than its declared length) and refuses a course PR without a post.", "scripts/test_pr_blog_report.py gains 10 tests; docs/PR_BLOG_AUTOMATION.md points at the spec instead of restating the rule.", "Review fix: the refusal names the spec by its full repository path (it said website/specs/...), a lesson renamed out of the course directory counts as a course change (the workflow now fetches previous_filename), and an incomplete file list with a course file but no post is reported as undecided instead of refused." ], "tests": [ { "command": "python3 -m unittest discover -s scripts -p 'test_pr_blog*.py'", "status": "passed", "result": "Ran 71 tests, OK.", "evidence": "Local run on f57e6308c in a worktree of origin/main 89b20062f." }, { "command": "node ~/.claude/skills/course-27/scripts/t27check.mjs apps/website/specs/policy/course_post.t27 (the site's own wasm compiler)", "status": "passed", "result": "clean, module course_post_policy, typecheckOk true, errors 0; tests 3, asserts 8, all hold.", "evidence": "Local run on f57e6308c against apps/website/public/t27/t27_compiler.wasm at origin/main 89b20062f." }, { "command": "seven planted mutants in scripts/pr_blog_report.py, one at a time, each restored with git checkout", "status": "passed", "result": "7 of 7 killed, each by the test named for it. On 56e533786: status ignored, refusal never raised, declared length unchecked, incomplete-list check inverted. On f57e6308c: the old parents[3] path, previous_filename ignored, the course-file incomplete-list notice turned back into a refusal.", "evidence": "Local runs; git status clean afterwards, HEAD unchanged at each commit." }, { "command": "lefthook pre-commit and commit-msg on the commit", "status": "passed", "result": "explorer-bilingual-contract, queen-bilingual-contract and english-only passed.", "evidence": "Local commits 56e533786 and f57e6308c with core.hooksPath .githooks and apps/website/node_modules linked." } ], "limitations": [ "The workflow runs the script from main, so this rule only binds PRs opened or synchronized after the merge; open course PRs are judged by it on their next push.", "It checks that a post body file was added or modified, not that the post describes the course change; review and check:blog still judge the content.", "The files API lists at most 3000 files; above that, when the listed part has no course file, or a course file and no post, the validator prints that the rule was not decided instead of judging it.", "A manual re-run of the report workflow for an older course PR that shipped without a post (for example #1443) now turns its status red, because the workflow always runs the script from main." ], "tags": [ "CI", "Courses", "Blog" ], "blog": { "title": "A course change now ships with its post", "summary": "The work report check refuses a course PR without a blog post body, reading the rule from a t27 policy spec; seven planted mutants each failed their own test.", "outline": [ "Every trinity PR already needed a work report, and merged PRs got an article later; a course change could still land with no post at all, so readers met new lessons before any explanation.", "The rule now lives in apps/website/specs/policy/course_post.t27, and pr_blog_report.py reads its constants and the PR's file list, failing closed when the spec loses a constant or a list its declared length.", "It checks that a post body changed, not what the post says; it binds PRs after the merge only, and a PR above the 3000-file API limit is reported as undecided rather than judged." ] } }🤖 Generated with Claude Code