Skip to content

activity: harden the data contract before automatic merges - #286

Merged
mmcky merged 3 commits into
mainfrom
activity-data-contract
Sep 29, 2026
Merged

mmcky merged 3 commits into
mainfrom
activity-data-contract

Conversation

@mmcky

@mmcky mmcky commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Once QuantEcon/reports-activity#91 is live, the reporter's daily Activity PRs merge themselves, so build becomes the only gate on what reaches quantecon.org. This PR makes that gate hold the contract settled on 2026-09-29: day-named, append-only data files, a new book type, and a limit on what the reporter's own PRs may touch. Today the check passes an overwritten file (in a probe the entry count fell from 40 to 37 and it still printed "Activity data OK") and the same release listed in two files.

What changes

README ("News and Activity") and the _layouts/activity.html comment. Files are _data/activity/<day>-<stream>.yml, named for the UTC day the entries cover, not the run date. lectures files now also hold book updates. Files are append-only: a re-run adds new entries after the existing ones and never changes or removes one, and a run writes no file for a stream with nothing to report. The README also says how to append: as text after the file's last byte, with no --- line, because re-dumping the whole file would drop its header comment and Jekyll reads only a file's first YAML document. The definitions table adds "updates to existing books" to Activity (a new book stays under News), and the field table adds book to type and to the date and url rules. The paragraph lists what CI checks: only .yml and .yaml files in _data/activity/, one YAML document per file, no repeated release or PR URLs, no changed or removed entries, and, for the reporter's PRs, only added day files or appends that keep every existing byte (the first step after checkout). It also notes that the older migrated and hand-added files are named for the day they were written. The sentence on how the Activity page orders entries is unchanged, since #277 rewrites it. The layout change is a Liquid comment only.

.github/scripts/check-activity-data.rb. Every existing rule, message and the "Activity data OK (N files)" line stay. New:

  • book is a valid type.
  • _data/activity/ may hold only .yml and .yaml files. The check lists the directory itself and fails, naming the path, on anything else: Jekyll would read and show a .json, .csv or .tsv file or a subdirectory there, unchecked. A .yml name that isn't a regular file fails too (a directory used to crash the check). Names that start with a dot are skipped, as Jekyll skips them.
  • Each file must be a single YAML document. Jekyll reads only the first, so entries after a --- line would be neither checked nor shown. The check parses the whole stream and fails a file with more than one document.
  • A release URL or a pull-request URL listed more than once, in one file or across files, fails with one error naming every file, entry and change involved.
  • With --base <revision>, every data file at the base must still exist, and its entries must be the first entries of the new file, unchanged (the parsed YAML is compared). Entries are matched by value, so each error says what happened: an entry removed (the entries after it aren't reported as changed), changed (with the keys that differ), a new entry added before existing ones, or the existing entries reordered. A deleted file fails too. The base is read with git (rev-parse, ls-tree, one cat-file --batch), with paths read as UTF-8, so a file with a non-ASCII name is compared like any other. A base file is compared with the data file of the same name, or reported as deleted, so none is skipped silently. If the base can't be read, the check fails and says why. Without --base, as in a local run, only this comparison is skipped, and the output says so.
  • File names and YAML values in ::error:: lines are escaped (%, CR, LF) so they can't end the annotation or start another workflow command.

.github/workflows/build.yml. The job id stays build, and no job is added.

  • permissions: contents: read at the top level.
  • actions/checkout gets fetch-depth: 2. On pull_request it checks out GitHub's test merge commit, so HEAD^1 is the tip of main and HEAD^2 the PR's head.
  • A new first step after checkout, "Limit reporter PRs to Activity data", runs only when github.event.pull_request.user.id == 294005175 (the reporter App's bot user ID; never the login or slug, which change with the rename). It sits before ruby/setup-ruby, whose bundle install evaluates the PR's Gemfile, and before every other step that runs the PR's code. It is inline because the App has no workflows permission, so the App can't change it. It uses bash and git, plus mktemp and head from the runner image. It diffs HEAD^1 against HEAD with renames off, and fails with an ::error:: naming each offending path unless every change is an added regular file (mode 100644), or a modification of one whose base content is a byte prefix of its new content, at a path matching _data/activity/YYYY-MM-DD-(software|lectures).yml. Deletions, renames (a deletion plus an add), mode changes, symlinks, submodules and every other path fail. So does a checkout without HEAD^2, and the error names both causes: HEAD isn't the merge commit, or the checkout is shallower than fetch-depth: 2.
  • "Check Activity data" now runs ruby .github/scripts/check-activity-data.rb --base HEAD^1, whoever opened the PR.
  • A new step after it runs the regression test with plain ruby.

.github/scripts/test-check-activity-data.rb (new, committed, and run by build). It has 38 minitest cases. The PR cases build real merge commits, fetch them at depth 2 and check them out detached, as actions/checkout does. They run the guard's own run text, read from build.yml with YAML at test time, through bash --noprofile --norc -eo pipefail (as Actions runs shell: bash), and the data check with the arguments parsed from the workflow's step. Covered:

  • Data check: main's data passes, with and without a base; book passes; an unknown type fails; duplicate release and PR URLs fail, across two files (naming both) and within one file; a file with two YAML documents fails (after ---, or after ... and a document with a Ruby object tag), while one document that starts with --- or ends with ... passes; a .json, .csv or .yml.json file, a subdirectory, a directory named like a data file and a dangling symlink each fail and are named, while .DS_Store is skipped; the existing rules still fail as before.
  • Against the base: an appended entry passes; a changed entry, a removed last entry, a removed earlier entry (named alone), a new entry put first, a reordering and a deleted file each fail with their own message; a changed entry in a file with a non-ASCII name fails; an unreadable base (not a repository, or a depth-1 checkout) fails clearly. The first base file holds multi-byte text, so every PR case reads the base by byte offsets.
  • Guard: a new day file and appends pass; changes that reached main after the branch are not counted; a rewrite, a deletion, a rename, a mode change, an added symlink or executable, an edit to _layouts/ or Gemfile, and ten paths that aren't day files each fail and are named (two of them, with a suffix after .yml and a prefix before _data/, pin both anchors of the path pattern); a checkout of the PR's own head commit fails although its change alone would pass, and so does a depth-1 checkout; a path containing a newline can't inject a workflow command.
  • Where the guard passes and the data check must fail: the old file has no final newline, and the appended bytes continue its last line, changing that entry's summary; and an append that starts with --- adds a second YAML document. The guard passes both, since the old bytes are a prefix, and the data check fails both.
  • The workflow's shape: read-only permissions; build is the only job; the guard is step 2, keyed on the bot user ID, with no ${{ }} in its script; the data check passes --base HEAD^1.

How it was tested

Regression test, locally in three setups. CI runs it under Ruby 3.4 with minitest 5.25 and bash 5.

ruby .github/scripts/test-check-activity-data.rb
Ruby minitest bash Result
4.0.7 6.0.0 3.2.57 (macOS) 38 runs, 688 assertions, 0 failures, 0 errors, 0 skips
3.1.3 5.25.4 5.2.37 38 runs, 686 assertions, 0 failures, 0 errors, 0 skips
3.1.3 5.15.0 3.2.57 (macOS) 38 runs, 686 assertions, 0 failures, 0 errors, 0 skips

Each run takes about 12 seconds, mostly git process start-up. minitest 6 counts two more assertions for the same tests.

Data check on this branch's data (main's 23 files and 40 entries, which have no duplicate URLs, even ignoring case), with cd6a5e2 being main when this branch was made:

ruby .github/scripts/check-activity-data.rb
ruby .github/scripts/check-activity-data.rb --base cd6a5e2

The first prints the skipped-comparison note and "Activity data OK (23 files)". The second prints "Compared with the base cd6a5e2 (cd6a5e2): the 40 entries in its 23 files are unchanged." and "Activity data OK (23 files)".

A scratch harness built a merge commit for each of 42 PR scenarios on top of the real files. The scenarios covered appends, rewrites, deletions, renames, mode changes, symlinks, submodules, paths outside the data directory, duplicates, book and unknown types, CRLF, a depth-1 checkout and others. The harness ran the guard and the data check on this branch and on its state before the local review's fixes. Only three outcomes differ, all intended: a .yml.bak file in _data/activity/ now fails the data check; the depth-1 guard error names the cause; and a data file replaced by a symlink to /etc/hosts now reads as invalid YAML rather than "not a list" (both fail). The local review's cases now fail the data check, each naming the file: an append that starts a second document, a .json data file, a .yml.json file, a directory named like a data file, and a changed entry in a file with a non-ASCII name. Removing the second of five entries reports only that entry, and a swap reports the reordering.

Writers using the emitter settings in QuantEcon/reports-activity#91 (yaml.safe_dump(..., sort_keys=False, allow_unicode=True)), in the same harness: appending the dump of the new entries after the file's last byte passes both checks. Re-dumping the whole file fails the guard, since the header comment is gone, though the data check passes it. Appending with explicit_start=True passes the guard and fails the data check (two documents).

shellcheck 0.11.0 on the guard's script, extracted from build.yml, and actionlint 1.7.7 on build.yml (with shellcheck): no findings.

Mutation check (scratch copies only): each of 28 deliberate breaks made at least one test fail. Sixteen were first run before the local review's fixes, and re-run on the final code: the prefix test always passing; any path allowed; deletions allowed; any added mode accepted; no merge-commit check; no escaping; the guard keyed on the login; the permissions block removed; checkout depth 1; no --base; book removed; no duplicate rule; case-sensitive URL keys; no entry comparison; deleted files allowed; an unreadable base only noted. Twelve were added with the fixes: no ^ and no $ in the guard's path pattern; binmode dropped from the base read; no one-document rule; non-YAML files ignored; dotfiles not skipped; base paths left untagged; entries compared by position; no reorder check; no added-before check; the old guard message; and only the exit removed from the merge-commit check. That last one first survived, because the test checked out a root commit, where git diff-tree HEAD^1 fails anyway. The test now checks out the PR's own head commit. One more break, restoring the old File.file? test for whether a base file still exists, changes no outcome now that base paths are UTF-8, so no test can tell it apart.

No visible change: a production build of this branch (282bf28) diffed against a production build of main (cd6a5e2), with per-build stamps normalised, differs in exactly one file, README.md, which Jekyll publishes as a static file.

Alongside #276: git merge-tree merges this branch with #276's branch without conflicts. On the merged tree this suite passes (38 runs), and build.yml runs checkout, the guard, Setup Ruby, the data check, this suite, #276's "Test Activity rows", then the Jekyll build.

In this PR's own build the guard step is skipped, since a person opened it. It first runs for real on the App's own PRs, starting with the deliberately failing PR that #275's test calls for.

Choices for review

  • The guard checks only PRs the App opens. It is keyed on the PR's author, as decision 13 and Harden the Activity data contract before automatic merges #284 set out. The App is the only bypass actor on main, so it could still merge, without approval, a PR someone else opened (including one from a fork) that passes build. Only the reporter's own merge step prevents that: it merges the PR it just opened, at the head commit whose build it checked. Commits the App might push onto someone else's PR aren't checked either. Adding || github.event.sender.id == 294005175 to the step's condition would check the run that such a push starts, but not a later push by the PR's author, so it was left out. This boundary is worth weighing before the go-live variable is set to merge.
  • No override for correcting a published entry. The append-only rule applies to every PR, including a maintainer's. A correction fails build, and an admin can merge past it, which the README now says. A label or path-based escape hatch was left out.
  • The data check compares parsed entries; the guard compares bytes. A person's PR that only reformats a file (for example, drops its header comment) passes the data check. The reporter's PRs are held to byte-exact appends by the guard, and the README now tells the reporter to append text rather than re-dump a file. A second YAML document is the exception: the data check fails it for everyone, since Jekyll wouldn't show it.
  • _data/activity/ holds only YAML files. The check fails on any other file or directory there, including ones Jekyll ignores, such as a README.md or a .bak file, rather than only on what Jekyll reads (.json, .csv, .tsv and subdirectories). Names that start with a dot are skipped, as Jekyll skips them, so a stray .DS_Store doesn't fail a local run.
  • Duplicate URLs ignore the case of the GitHub owner and repository, which GitHub treats as the same. quantecon/quantecon.py and QuantEcon/QuantEcon.py count as one release. Tags stay case-sensitive.
  • File names are enforced for the reporter's PRs only. The data check accepts any .yml or .yaml name, so a person can add, say, a hand-made backfill file with a suffix.
  • The guard only allows mode 100644. An executable data file fails, like a mode change. An App PR that changes nothing passes the guard.
  • --base is an explicit argument, not an environment variable. An empty or missing value fails instead of silently skipping the comparison.
  • A base file that isn't a list of entries is skipped with a note, not failed, so a PR can repair a broken file on main. A base file with more than one YAML document is compared by its first, the part Jekyll showed. Every other unreadable base fails.
  • The guard test is committed and runs in build, rather than kept as a scratch check. That keeps the tested script identical to the deployed one, and exercises it under the runner's bash 5 on every PR (about 12 seconds locally).
  • The README paragraph also mentions the guard, and notes that the migrated and hand-added files are named for the day they were written, since "named for the UTC day covered" isn't true of them.

Notes

  • For John. The definitions change adds updates to existing books to Activity, on top of the definitions approved in Approve the News and Activity stream definitions #272, so John should see this PR before it merges. A new book is still News.
  • Timing. This PR must merge before QuantEcon/reports-activity#91 goes live. The reporter's book entries come in QuantEcon/reports-activity#91's second slice.
  • Book rendering until Restyle /activity/ with the shared rows, a type filter and day links #277. /activity/ has no qe-badge--book style yet, so a book entry would show an uncoloured badge until Restyle /activity/ with the shared rows, a type filter and day links #277 restyles the page. No book entries exist yet.
  • Alongside Compute the Activity rows at build time, with tests #276. Both PRs add a test step after "Check Activity data" in build.yml, and git merges the two branches cleanly. build isn't strict, so whichever merges second has so far been tested without the other's step: update its branch once the first has merged, so its build runs both. This PR leaves .github/copilot-instructions.md to Compute the Activity rows at build time, with tests #276. Its line calling the data check "the one automated check" is now out of date.
  • build isn't strict. Each PR is checked against main as of its test merge. If two PRs each pass alone but together list the same release, the duplicate shows up on the next PR's build.
  • _data/activity/2026-09-28-software.yml keeps its name: nothing was released or published on 2026-09-28 UTC, so no daily run writes to it, and an append-only write would keep its entries anyway.

Closes #284.
Part of #271.

🤖 Generated with Claude Code

mmcky and others added 3 commits September 29, 2026 19:10
The reporter's daily PRs will merge themselves once they pass the data
check, so the data contract they must meet is written down first.

README's "Activity data" now says each file is named for the UTC day
its entries cover, not the run date, and that files are append-only: a
re-run adds its new entries after the existing ones and never changes
or removes one. It appends them as text after the file's last byte,
with no --- line, since re-dumping the file would drop its header
comment and Jekyll reads only a file's first YAML document. The older
migrated and hand-added files are named for the day they were written.
The paragraph also lists what CI checks, including the limit on the
reporter's own PRs, and says a correction to a published entry needs
an admin to merge it.

The definitions table adds updates to existing books to Activity (a
new book stays under News), and the data format gains the book type.
The comment in _layouts/activity.html matches; nothing renders
differently.

Part of #284.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the reporter's PRs merge themselves, this check is the only gate
on the data that reaches the site, and it passed both an overwritten
file and a release listed twice. check-activity-data.rb now accepts
the book type, and:

- lists _data/activity/ itself and fails on anything there but regular
  .yml and .yaml files. Jekyll would read and show a .json, .csv or
  .tsv file or a subdirectory, unchecked. Names that start with a dot
  are skipped, as Jekyll skips them.
- fails a file that holds more than one YAML document, since Jekyll
  reads only the first, so entries after a --- line would be neither
  checked nor shown.
- fails when a release URL or a pull-request URL is listed more than
  once, in one file or across files, naming every file and entry
  involved. The GitHub owner and repository are compared without case.
- with --base <revision>, fails when a data file at that revision is
  deleted, or its entries are changed, removed or reordered, or a new
  entry is put before them. Entries are matched by value (the parsed
  YAML is compared), so removing one entry names only that entry. The
  base is read with git, with paths tagged UTF-8 so that a file with a
  non-ASCII name is compared too, and a revision that can't be read
  fails the check. Without --base only that comparison is skipped, and
  the output says so.

Every existing rule and message stays; annotation text is now escaped.

Part of #284.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build.yml gets a read-only token and a depth-2 checkout, so the test
merge commit's parents are there. A new first step, before setup-ruby
runs any of the PR's code, fails a PR opened by the reporter App (bot
user ID 294005175, not the login) unless it only adds
_data/activity/YYYY-MM-DD-(software|lectures).yml files or appends to
them byte for byte. It is plain bash and git, inline, since the App
can't change workflow files. When HEAD^2 is missing, it fails and
names both causes: HEAD isn't the pull request's merge commit, or the
checkout is too shallow to hold its parents. The data check now runs
with --base HEAD^1, and the job stays named build, the required check.

test-check-activity-data.rb, run by a new step, builds real merge
commits, checks them out the way actions/checkout does, and runs the
guard's own script from build.yml and the data check on them. It
covers each rule of both checks; two appends the guard passes and the
data check must fail (one continues the old last line, one starts a
second YAML document); a depth-1 checkout; a checkout of the PR's own
head commit; and paths that pin both anchors of the guard's path
pattern. The first base file holds multi-byte text, so reading the
base has to count bytes.

Part of #284.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for grand-swan-ca5201 ready!

Name Link
🔨 Latest commit 282bf28
🔍 Latest deploy log https://app.netlify.com/projects/grand-swan-ca5201/deploys/6abb813107398600070baf5c
😎 Deploy Preview https://deploy-preview-286--grand-swan-ca5201.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

It changes security-sensitive CI that gates unattended auto-merge to production, and the PR itself flags an unresolved bypass-actor boundary and requires maintainer sign-off on the definitions change, so it needs human review.

Review effort: Balanced
Findings: None

What changed in this PR

This PR hardens the _data/activity/ data contract in the QuantEcon website's CI so that the build check becomes a reliable gate before QuantEcon/reports-activity#91 lets the reporter App's daily Activity PRs merge themselves without human review (closes #284, part of #271). It closes two gaps the current check misses: silently overwritten data files and the same release/PR appearing in two files. It also documents the new day-named, append-only file convention and adds a book type.

Changes:

  • Rewrites check-activity-data.rb to add: book type, a directory-contents check (only .yml/.yaml), a single-YAML-document rule, duplicate release/PR URL detection (case-insensitive owner/repo), an optional --base <revision> append-only comparison against git, and workflow-command escaping in ::error:: annotations.
  • Adds a build.yml bash guard step (keyed on the reporter bot user ID 294005175, before any PR-controlled code) that restricts reporter PRs to added/appended _data/activity/YYYY-MM-DD-(software|lectures).yml files, plus top-level permissions: contents: read, fetch-depth: 2, and a new regression-test step.
  • Adds a committed 38-case minitest suite that runs in build, and updates the README and the _layouts/activity.html comment to describe day-named, append-only files and the book type.
File Description
.github/​scripts/​check-activity-data.rb Adds book type, directory/single-document validation, duplicate-URL detection, and --base append-only comparison with escaped annotations.
.github/​workflows/​build.yml Adds read-only permissions, fetch-depth: 2, an inline reporter-PR guard step, --base HEAD^1 on the check, and a regression-test step.
.github/​scripts/​test-check-activity-data.rb New minitest suite (38 cases) exercising the data check and the guard via real merge commits; runs in CI.
README.md Documents day-named append-only files, the book type, and the new CI checks.
_layouts/​activity.html Liquid-comment-only update describing the append-only, day-named file scheme.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@mmcky
mmcky marked this pull request as ready for review September 29, 2026 11:38
@mmcky
mmcky merged commit f3f9ae6 into main Sep 29, 2026
6 checks passed
@mmcky
mmcky deleted the activity-data-contract branch September 29, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden the Activity data contract before automatic merges

2 participants