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
5 changes: 3 additions & 2 deletions .github/workflows/pr-blog-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions apps/website/specs/policy/course_post.t27
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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 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 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;

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";
}
101 changes: 101 additions & 0 deletions apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts
Original file line number Diff line number Diff line change
@@ -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 курса, ушедшего без поста, теперь станет красным.',
},
]
32 changes: 32 additions & 0 deletions apps/website/src/data/blog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/website/src/data/blog/posts.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, PostBody> = {
'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 },
Expand Down
Loading
Loading