From d7cee7d3e589ff2ecefb7ad1ce5632f868f03dfe Mon Sep 17 00:00:00 2001 From: James Carroll Date: Mon, 20 Jul 2026 13:48:38 -0400 Subject: [PATCH 1/5] changelog: restore the curated editorial bar + let bot PRs self-land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changelog page's "Recent updates" list only shows genuinely major features from now on — new capabilities, new languages, meaningful fixes to something visibly broken. Smaller fixes and internal changes still ship (see the pull request history), they just don't clutter the highlights page. The page also no longer explains its own machinery to readers; it just shows what changed. - A merged PR now needs both a "## What this means for you" section AND the new `changelog:major` label to earn a changelog entry — presence of the section alone stopped being a significance signal once every PR started carrying one by convention. Documented in CONTRIBUTING.md. - Removed the "These lines come from the descriptions of merged code changes and stay in English for now" line and its i18n key — the existing `.chg-auto` content-zone carve-out already keeps the hermetic stray-English guard green without it (verified: all ten shipping languages still pass on changelog.html). - Re-curated the existing entries under the new bar (33 -> fewer), and fixed a bullet-list-formatted marker section leaking a literal "-" into the rendered text. - The automated-update workflow now dispatches CI directly on its own bot branch (workflow_dispatch, not pull_request) and waits for it before enqueuing the PR — routes around GitHub's same-repo approval gate for GITHUB_TOKEN-authored PRs, which is why the bot's PRs have needed a maintainer's manual click to land. Tests: fixture-anchored coverage for the label gate (major/minor/internal PRs, real PR bodies) and the bullet-marker fix; full standards + a11y + i18n guard suite green; reading-level ratchet tightened to the new, lower grade. --- .github/workflows/ci.yml | 2 + .github/workflows/update-changelog.yml | 67 ++++++++++- CONTRIBUTING.md | 23 +++- about.html | 2 +- api.html | 2 +- changelog-data.json | 94 ++------------- changelog.html | 19 +-- data.html | 2 +- i18n.js | 21 ++-- i18n/lang/ar.js | 1 - i18n/lang/bn.js | 1 - i18n/lang/es.js | 1 - i18n/lang/fr.js | 1 - i18n/lang/ht.js | 1 - i18n/lang/ko.js | 1 - i18n/lang/pl.js | 1 - i18n/lang/ru.js | 1 - i18n/lang/ur.js | 1 - i18n/lang/zh-Hans.js | 1 - index.html | 2 +- reading-level-baseline.json | 2 +- stats.html | 2 +- test/changelog_entry_gate.test.mjs | 127 ++++++++++++++++++++ test/changelog_extract.test.mjs | 22 +++- test/check_changelog_reading_level.test.mjs | 28 ++++- tools/changelog_extract.mjs | 34 ++++-- tools/check_changelog_reading_level.mjs | 14 ++- tools/gen_changelog.mjs | 51 +++++--- 28 files changed, 351 insertions(+), 173 deletions(-) create mode 100644 test/changelog_entry_gate.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bc4dd8..1e15854 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,6 +200,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} PR_URL: ${{ github.event.pull_request.html_url }} PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | printf '%s' "$PR_BODY" > "$RUNNER_TEMP/changelog-sim/pr-body.md" node tools/check_changelog_reading_level.mjs \ @@ -210,6 +211,7 @@ jobs: --url "$PR_URL" \ --merged-at "$(date -u +%Y-%m-%d)" \ --body-file "$RUNNER_TEMP/changelog-sim/pr-body.md" \ + --labels "$PR_LABELS" \ --baseline reading-level-baseline.json # Opt-in only — a manually-labeled PR gets LLM-drafted plain-language suggestions as # PR-ready JSON (not auto-applied; a human reviews and accepts/discards). Needs diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index 6812ee7..a5e9d5c 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -1,6 +1,7 @@ # Regenerates changelog.html's "Recent updates" list on every PR merge, so no one hand-edits -# the changelog. See CONTRIBUTING.md's "Changelog entries" section for the marker convention -# a PR body needs, and tools/gen_changelog.mjs for the extraction + rebuild logic. +# the changelog. See CONTRIBUTING.md's "Changelog entries" section for the marker + label +# convention a PR body/labels need, and tools/gen_changelog.mjs for the extraction + rebuild +# logic. # # `main` sits behind a merge-queue ruleset, and the Actions token cannot be a bypass actor — # a direct `git push` to `main` is rejected outright. So this workflow pushes to a standing @@ -19,6 +20,23 @@ # regenerate. Multiple qualifying PRs merging before the queue processes the bot PR # accumulate onto that SAME open PR (each run reads the bot branch's current pending # state as its base) rather than opening one bot PR per entry. +# +# Self-landing (crol-changelog-m6): main's ruleset requires four named status checks +# ("Unit tests (site + worker)", "Accessibility + language gate (axe on every PR)", +# "Stray-English guard (runtime, fixtures)", "Reading-level ratchet gate (readable-or-else)") +# on top of the merge queue, so arming auto-merge alone isn't enough — the bot PR still needs +# those checks to actually run and pass on its head SHA before it's mergeable at all. They +# never do on their own: GitHub requires a maintainer to click "Approve and run workflows" for +# ANY `pull_request`-triggered run whose author is GITHUB_TOKEN's bot identity +# (github-actions[bot]), even for a same-repo, non-fork branch like this one — confirmed live +# on PR #81, which sat with zero check runs on its head SHA (mergeStateStatus: BLOCKED) until +# a maintainer manually re-ran CI. The "Dispatch CI directly" step below routes around this: +# `workflow_dispatch` is a different trigger, and the fork/first-time-contributor approval +# gate is scoped to `pull_request`/`pull_request_target` events only — it doesn't apply here. +# Required-status-check matching keys on (SHA, check name), not on which event produced the +# check run, so a workflow_dispatch-triggered CI run on the bot branch's head SHA satisfies +# the same required checks a pull_request-triggered run would have. Once those pass, the +# existing enqueuePullRequest call (moved to after the wait) succeeds on its own. name: Update changelog on: @@ -28,6 +46,7 @@ on: permissions: contents: write pull-requests: write + actions: write concurrency: group: update-changelog @@ -37,6 +56,7 @@ jobs: update: if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' runs-on: ubuntu-latest + timeout-minutes: 25 env: BOT_BRANCH: bot/changelog-update GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -68,24 +88,27 @@ jobs: cp changelog-data.json "$RUNNER_TEMP/before-data.json" cp changelog.html "$RUNNER_TEMP/before-changelog.html" - - name: Extract this PR's user-impact line and regenerate the changelog + - name: Extract this PR's user-impact line (if labeled changelog:major) and regenerate the changelog + env: + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | node tools/gen_changelog.mjs \ --number "${{ github.event.pull_request.number }}" \ --url "${{ github.event.pull_request.html_url }}" \ --merged-at "${{ github.event.pull_request.merged_at }}" \ - --body-file "$RUNNER_TEMP/pr-body.md" + --body-file "$RUNNER_TEMP/pr-body.md" \ + --labels "$PR_LABELS" - name: Stop here if regeneration produced no change (explicit no-diff guard, before touching any branch) run: | if diff -q "$RUNNER_TEMP/before-data.json" changelog-data.json >/dev/null \ && diff -q "$RUNNER_TEMP/before-changelog.html" changelog.html >/dev/null; then - echo "No new entry (no marker in this PR, already recorded, or nothing changed) — nothing to do." + echo "No new entry (not labeled changelog:major, no marker, already recorded, or nothing changed) — nothing to do." exit 0 fi echo "changed=true" >> "$GITHUB_ENV" - - name: Push the bot branch, open or update its PR, and enqueue it + - name: Push the bot branch and open or update its PR if: env.changed == 'true' run: | git config user.name "github-actions[bot]" @@ -113,7 +136,39 @@ jobs: PR_NUMBER="$(gh pr list --head "$BOT_BRANCH" --state open --json number --jq '.[0].number')" echo "Opened bot PR #$PR_NUMBER" fi + echo "$PR_NUMBER" > "$RUNNER_TEMP/bot-pr-number" + + # See the file-header comment above for why this step exists: `pull_request`-triggered + # CI on this branch would sit at "action_required" forever without a maintainer's click. + - name: Dispatch CI directly on the bot branch (workflow_dispatch bypasses the pull_request-event approval gate) and wait for it + if: env.changed == 'true' + timeout-minutes: 20 + run: | + gh workflow run ci.yml --ref "$BOT_BRANCH" + + RUN_ID="" + for i in $(seq 1 20); do + sleep 3 + RUN_ID="$(gh run list --workflow ci.yml --branch "$BOT_BRANCH" --event workflow_dispatch \ + --limit 1 --json databaseId --jq '.[0].databaseId // empty')" + [ -n "$RUN_ID" ] && break + done + + if [ -z "$RUN_ID" ]; then + echo "::warning::couldn't find the dispatched CI run on $BOT_BRANCH within the poll window — leaving bot PR #$(cat "$RUNNER_TEMP/bot-pr-number") for manual review" + echo "ci_ok=false" >> "$GITHUB_ENV" + elif gh run watch "$RUN_ID" --exit-status; then + echo "CI run $RUN_ID passed on $BOT_BRANCH" + echo "ci_ok=true" >> "$GITHUB_ENV" + else + echo "::warning::CI run $RUN_ID failed on $BOT_BRANCH — leaving bot PR #$(cat "$RUNNER_TEMP/bot-pr-number") for manual review" + echo "ci_ok=false" >> "$GITHUB_ENV" + fi + - name: Enqueue the bot PR now that its required checks are satisfied + if: env.changed == 'true' && env.ci_ok == 'true' + run: | + PR_NUMBER="$(cat "$RUNNER_TEMP/bot-pr-number")" PR_NODE_ID="$(gh pr view "$PR_NUMBER" --json id --jq .id)" gh api graphql -f query=' mutation($prId: ID!) { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8556c5..dafcbc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,12 +46,23 @@ These rules built the project and they're not aspirational — every shipped fea ## Changelog entries -`changelog.html`'s "Recent updates" list is generated, not hand-edited. If your PR changes -something a visitor would notice, add a one-line `## What this means for you` section to the -PR body — plain language, present tense, no code names or internal jargon. A merge-triggered -workflow (`.github/workflows/update-changelog.yml`) extracts that line and regenerates the -page automatically. No marker section means no entry — that's the intended way to keep -plumbing/CI/refactor PRs off the page, not an oversight to fix. +`changelog.html`'s "Recent updates" list is generated, not hand-edited, and it's curated: it +exists to surface the handful of changes worth a returning visitor's attention, not to mirror +every merged PR. Two things earn a PR an entry, both required: + +1. A one-line `## What this means for you` section in the PR body — plain language, present + tense, no code names or internal jargon. +2. The `changelog:major` label, applied when the change is genuinely significant to a + visitor — a new feature, a new language, a meaningful fix to something visibly broken. + Most PRs should NOT carry this label: a bug fix, an internal/tooling change, a wording + tweak, or a refinement to something that already shipped is real work but not a changelog + moment. If in doubt, leave it off — the PR history (and, for internal work, the project's + `AGENTS.md`) is still the complete record; the changelog page is the curated highlights, + not the log. + +A merge-triggered workflow (`.github/workflows/update-changelog.yml`) checks for the label, +then extracts the marker line, and regenerates the page automatically. Missing either one +means no entry — that's the intended way to keep the page selective, not an oversight to fix. ## Running things diff --git a/about.html b/about.html index 289e7a6..29228c2 100644 --- a/about.html +++ b/about.html @@ -5,7 +5,7 @@ About · CROL-List - + diff --git a/api.html b/api.html index 6de7294..7a4f35e 100644 --- a/api.html +++ b/api.html @@ -5,7 +5,7 @@ API and feeds · CROL-List - + diff --git a/changelog-data.json b/changelog-data.json index 8c256f3..3e3caf8 100644 --- a/changelog-data.json +++ b/changelog-data.json @@ -1,29 +1,17 @@ { - "_comment": "Source of truth for changelog.html's 'Recent updates' list. Regenerated by tools/gen_changelog.mjs from merged PRs carrying a '## What this means for you' section (see CONTRIBUTING.md). Entries are newest first. Do not hand-edit the 'text' field without also updating changelog.html's auto-generated block (run: node tools/gen_changelog.mjs --rebuild).", + "_comment": "Source of truth for changelog.html's 'Recent updates' list. Regenerated by tools/gen_changelog.mjs from merged PRs carrying both the 'changelog:major' label and a '## What this means for you' section (see CONTRIBUTING.md). Entries are newest first. Do not hand-edit the 'text' field without also updating changelog.html's auto-generated block (run: node tools/gen_changelog.mjs --rebuild).", "entries": [ - { - "pr": 82, - "merged_at": "2026-07-20", - "url": "https://github.com/cityscroll/crol-list/pull/82", - "text": "Digest emails now have a shorter footer. The stats page and the API docs describe click counts more plainly, with no change to what's counted or how." - }, { "pr": 80, "merged_at": "2026-07-17", "url": "https://github.com/cityscroll/crol-list/pull/80", "text": "If you're watching a notice from an agency that files its awards somewhere other than the City Record, you can now ask to be emailed the moment that award shows up. Click \"Email me when the award registers\" on the notice, confirm your email once, and you're done — no extra emails until there's real news, and it's the exact same possible-vs-confirmed distinction the notice page already shows you." }, - { - "pr": 77, - "merged_at": "2026-07-17", - "url": "https://github.com/cityscroll/crol-list/pull/77", - "text": "No visible change to the site. A future code change that would make the changelog page harder to read now gets flagged on its own pull request, before it merges, instead of after." - }, { "pr": 76, "merged_at": "2026-07-17", "url": "https://github.com/cityscroll/crol-list/pull/76", - "text": "- Every note that says your answer lives in another public system now hands you the door: click through to the exact record, the filtered dataset view, or an explanation of what was checked. - If a source can't be linked yet, the site says so honestly instead of showing a dead mention." + "text": "Every note that says your answer lives in another public system now hands you the door: click through to the exact record, the filtered dataset view, or an explanation of what was checked. If a source can't be linked yet, the site says so honestly instead of showing a dead mention." }, { "pr": 74, @@ -37,12 +25,6 @@ "url": "https://github.com/cityscroll/crol-list/pull/72", "text": "Has a saved alert gone quiet? If it finds nothing new for a couple of months, its next email says so. That email links back to the alerts page. Your exact search is already loaded there. You can broaden it in one step." }, - { - "pr": 73, - "merged_at": "2026-07-17", - "url": "https://github.com/cityscroll/crol-list/pull/73", - "text": "No visible change to the site — this only makes the automated changelog update workflow more reliable, so a language-dictionary change in one PR can't leave the changelog page's cache stamp out of date." - }, { "pr": 70, "merged_at": "2026-07-17", @@ -53,67 +35,31 @@ "pr": 64, "merged_at": "2026-07-15", "url": "https://github.com/cityscroll/crol-list/pull/64", - "text": "Opening a notice with no confirmed prior-award history now offers a \"Show possible earlier rounds\" option — a set of caveated maybes for your own judgment, instead of a dead end." - }, - { - "pr": 63, - "merged_at": "2026-07-15", - "url": "https://github.com/cityscroll/crol-list/pull/63", - "text": "Contracts suggestions now hint at which ones lead somewhere deeper: a chip with a quiet colored border means its results include contracts with a documented award history or a published agency forecast — worth a click if you want more than this quarter's notice." + "text": "Opening a notice with no confirmed prior-award history now offers a \"Show possible earlier rounds\" option \u2014 a set of caveated maybes for your own judgment, instead of a dead end." }, { "pr": 62, "merged_at": "2026-07-15", "url": "https://github.com/cityscroll/crol-list/pull/62", - "text": "The stats page's \"all time\" numbers, topic/lens breakdowns, and day-by-day history now say exactly how far back they can honestly account for — no more numbers that quietly undercount, and no more \"since launch\" where that isn't true. Digests, searches, and now active watches are also easier to read at a glance, with the technical plumbing (feed fetches, API checks) moved to its own clearly-labeled section at the bottom, translated across all ten shipping languages." - }, - { - "pr": 57, - "merged_at": "2026-07-15", - "url": "https://github.com/cityscroll/crol-list/pull/57", - "text": "Not user-facing — this is an operator-only admin endpoint with no UI surface, so no changelog entry." - }, - { - "pr": 55, - "merged_at": "2026-07-15", - "url": "https://github.com/cityscroll/crol-list/pull/55", - "text": "Typing a query into the Alerts \"Narrow by keyword\" field and clicking Preview now works whether or not you've clicked a topic chip first — it's resolved the same way the \"Ask\" box already handles free-text queries, and you'll always see either a populated preview or a clear \"we understood this as…\" summary, never silence." + "text": "The stats page's \"all time\" numbers, topic/lens breakdowns, and day-by-day history now say exactly how far back they can honestly account for \u2014 no more numbers that quietly undercount, and no more \"since launch\" where that isn't true. Digests, searches, and now active watches are also easier to read at a glance, with the technical plumbing (feed fetches, API checks) moved to its own clearly-labeled section at the bottom, translated across all ten shipping languages." }, { "pr": 50, "merged_at": "2026-07-15", "url": "https://github.com/cityscroll/crol-list/pull/50", - "text": "Typing a full sentence like \"education contracts over $200K due in 3 months\" into the Alerts quiz's keyword field now previews real matching results and saves an alert with the actual interpreted filters — the same thing you'd get from the \"Ask\" box, and the same thing that gets saved when you subscribe. A single word or a quoted phrase still searches literally as before." - }, - { - "pr": 49, - "merged_at": "2026-07-15", - "url": "https://github.com/cityscroll/crol-list/pull/49", - "text": "The homepage tagline now describes CROL-List as something you subscribe to for the things you care about, instead of a passive tracking tool — in English and all ten shipping languages." + "text": "Typing a full sentence like \"education contracts over $200K due in 3 months\" into the Alerts quiz's keyword field now previews real matching results and saves an alert with the actual interpreted filters \u2014 the same thing you'd get from the \"Ask\" box, and the same thing that gets saved when you subscribe. A single word or a quoted phrase still searches literally as before." }, { "pr": 47, "merged_at": "2026-07-14", "url": "https://github.com/cityscroll/crol-list/pull/47", - "text": "Alert emails and the \"Preview my digest\" tool now show exactly which word matched and where — no more guessing why an unrelated-looking notice showed up in your search results." - }, - { - "pr": 46, - "merged_at": "2026-07-14", - "url": "https://github.com/cityscroll/crol-list/pull/46", - "text": "The \"Contracts\" tab and \"Contract trail\" panel heading now match on first paint instead of flashing \"Money\" before JavaScript repaints them, and three previously English-only empty-state hints will now actually show their translation when you switch languages." + "text": "Alert emails and the \"Preview my digest\" tool now show exactly which word matched and where \u2014 no more guessing why an unrelated-looking notice showed up in your search results." }, { "pr": 45, "merged_at": "2026-07-14", "url": "https://github.com/cityscroll/crol-list/pull/45", - "text": "Opening the Land or Staffing tab for the first time now shows a real, current example — a live rezoning application, or the city's most common job title with its salary band and career ladder — instead of an empty panel waiting for you to search. Your own searches and shared links work exactly as before." - }, - { - "pr": 42, - "merged_at": "2026-07-14", - "url": "https://github.com/cityscroll/crol-list/pull/42", - "text": "Older award notices (pre-2013) are more likely to show their Checkbook NYC payment history instead of a blanket \"no registered contract found.\" Digest emails also no longer show a duplicate line item on the rare occasion City Record republishes a notice under a new ID." + "text": "Opening the Land or Staffing tab for the first time now shows a real, current example \u2014 a live rezoning application, or the city's most common job title with its salary band and career ladder \u2014 instead of an empty panel waiting for you to search. Your own searches and shared links work exactly as before." }, { "pr": 38, @@ -121,24 +67,12 @@ "url": "https://github.com/cityscroll/crol-list/pull/38", "text": "The stats page now shows day-by-day history of digests sent and searches asked, with recovered and live-counted days honestly labeled." }, - { - "pr": 35, - "merged_at": "2026-07-14", - "url": "https://github.com/cityscroll/crol-list/pull/35", - "text": "The People tab is now called Staffing." - }, { "pr": 32, "merged_at": "2026-07-14", "url": "https://github.com/cityscroll/crol-list/pull/32", "text": "You can now build an alert that watches one agency, or only awards, or only open solicitations, not just an amount or a deadline." }, - { - "pr": 31, - "merged_at": "2026-07-14", - "url": "https://github.com/cityscroll/crol-list/pull/31", - "text": "The respond panel, agency award totals, and empty alert previews now tell you the truth instead of showing a broken-looking guess." - }, { "pr": 30, "merged_at": "2026-07-14", @@ -187,12 +121,6 @@ "url": "https://github.com/cityscroll/crol-list/pull/21", "text": "Added Chinese (simplified) and Russian." }, - { - "pr": 16, - "merged_at": "2026-07-13", - "url": "https://github.com/cityscroll/crol-list/pull/16", - "text": "The About page's biggest dollar claims now link to the actual notices behind them." - }, { "pr": 15, "merged_at": "2026-07-13", @@ -203,13 +131,7 @@ "pr": 13, "merged_at": "2026-07-13", "url": "https://github.com/cityscroll/crol-list/pull/13", - "text": "Every page, not just the homepage, now translates into Español, and your language choice is remembered across visits." - }, - { - "pr": 11, - "merged_at": "2026-07-13", - "url": "https://github.com/cityscroll/crol-list/pull/11", - "text": "Fixed two Spanish-translation bugs: today's notice count and the investigation workspace were showing English text after switching to Español." + "text": "Every page, not just the homepage, now translates into Espa\u00f1ol, and your language choice is remembered across visits." } ] } diff --git a/changelog.html b/changelog.html index d62c3e4..71c4ca6 100644 --- a/changelog.html +++ b/changelog.html @@ -5,7 +5,7 @@ Changelog · CROL-List - + @@ -35,7 +35,6 @@ .lede{font-size:16px;color:var(--ink-soft);margin:22px 0 6px} .rel-auto{margin-top:22px} .rel-auto h2{font-family:var(--font-sc);font-size:16px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink);margin:0} - .chgauto-note{font:13px/1.5 var(--ui);color:var(--muted);margin:4px 0 12px} ul.chg-auto{list-style:none;margin:0;padding:0;font:15.5px/1.6 var(--font-body);color:var(--ink-soft)} ul.chg-auto li{padding:8px 0;border-top:1px solid var(--rule)} ul.chg-auto li:first-child{border-top:none} @@ -103,31 +102,19 @@

Changelog

Recent updates

-

These lines come from the descriptions of merged code changes and stay in English for now.

diff --git a/data.html b/data.html index 6c3eec3..6a0de18 100644 --- a/data.html +++ b/data.html @@ -5,7 +5,7 @@ The Data · CROL-List - + diff --git a/i18n.js b/i18n.js index 93c149d..662e873 100644 --- a/i18n.js +++ b/i18n.js @@ -77,16 +77,16 @@ const SHIPPING_LANGS = ["es", "zh-Hans", "ru", "bn", "ht", "ko", "fr", "pl", "ar // a Polish fix never invalidates nine other dictionaries' cache entries. // Regenerate with: shasum -a 256 i18n/lang/.js | cut -c1-8 const LANG_FILE_HASHES = { - es: "24f3843f", - "zh-Hans": "be2c719e", - ru: "d58ccf44", - bn: "97ac9ecb", - ht: "b06ba6ed", - ko: "c429d4d2", - fr: "fe858f2e", - pl: "8778801a", - ar: "dd44ab2f", - ur: "0fa4d802", + es: "0f658183", + "zh-Hans": "cf9f41db", + ru: "f6774944", + bn: "47277e30", + ht: "348ef634", + ko: "3a8f2ce8", + fr: "7723c016", + pl: "6f35a533", + ar: "13a874c6", + ur: "b5086443", }; // Translation review-state (w8-02): drives the machine-translation disclosure banner @@ -902,7 +902,6 @@ const STRINGS = { // changelog.html chg_p_lede: "What changed on CROL-List, newest first.", chg_auto_h2: "Recent updates", - chg_auto_note: "These lines come from the descriptions of merged code changes and stay in English for now.", chg_earlier_h2: "Earlier releases", chg_detail_note: "The detailed technical notes below each release (bullet lists, incident reports) remain in English for now.", chg_foot_html: "CROL-List is an unofficial, free interface to public data. About · Stats · API and feeds · Home", diff --git a/i18n/lang/ar.js b/i18n/lang/ar.js index 4212cf2..5090575 100644 --- a/i18n/lang/ar.js +++ b/i18n/lang/ar.js @@ -693,7 +693,6 @@ api_foot_html: "CROL-List · الرئيسية · حول", chg_p_lede: "ما تغيّر في CROL-List، الأحدث أولاً.", chg_auto_h2: "التحديثات الأخيرة", - chg_auto_note: "هذه الأسطر مأخوذة من أوصاف تغييرات الشيفرة المدمجة، وتبقى بالإنجليزية حالياً.", chg_earlier_h2: "الإصدارات السابقة", chg_detail_note: "الملاحظات التقنية المفصّلة تحت كل إصدار (قوائم نقطية، تقارير حوادث) تبقى بالإنجليزية في الوقت الراهن.", chg_foot_html: "CROL-List واجهة غير رسمية ومجانية للبيانات العامة. حول · إحصاءات · واجهة برمجة التطبيقات والتلقيمات · الرئيسية", diff --git a/i18n/lang/bn.js b/i18n/lang/bn.js index c178a8e..5853170 100644 --- a/i18n/lang/bn.js +++ b/i18n/lang/bn.js @@ -680,7 +680,6 @@ api_foot_html: "CROL-List · হোম · সম্পর্কে", chg_p_lede: "CROL-List-এ কী পরিবর্তন হয়েছে, নতুনগুলো আগে।", chg_auto_h2: "সাম্প্রতিক আপডেট", - chg_auto_note: "এই লাইনগুলো মার্জ করা কোড পরিবর্তনের বিবরণ থেকে নেওয়া, এবং আপাতত ইংরেজিতে থাকছে।", chg_earlier_h2: "আগের সংস্করণ", chg_detail_note: "প্রতিটি সংস্করণের নিচের বিস্তারিত প্রযুক্তিগত নোট (বুলেট তালিকা, ঘটনার প্রতিবেদন) আপাতত ইংরেজিতেই থাকে।", chg_foot_html: "CROL-List সরকারি উপাত্তের একটি অনানুষ্ঠানিক, বিনামূল্যের ইন্টারফেস। সম্পর্কে · পরিসংখ্যান · API ও ফিড · হোম", diff --git a/i18n/lang/es.js b/i18n/lang/es.js index e0ab951..d99bd55 100644 --- a/i18n/lang/es.js +++ b/i18n/lang/es.js @@ -680,7 +680,6 @@ api_foot_html: "CROL-List · Inicio · Acerca de", chg_p_lede: "Qué cambió en CROL-List, lo más reciente primero.", chg_auto_h2: "Actualizaciones recientes", - chg_auto_note: "Estas líneas provienen de las descripciones de los cambios de código fusionados y permanecen en inglés por ahora.", chg_earlier_h2: "Versiones anteriores", chg_detail_note: "Las notas técnicas detalladas debajo de cada versión (listas con viñetas, informes de incidentes) permanecen en inglés por ahora.", chg_foot_html: "CROL-List es una interfaz gratuita y no oficial de datos públicos. Acerca de · Estadísticas · API y fuentes · Inicio", diff --git a/i18n/lang/fr.js b/i18n/lang/fr.js index f8956de..596ca1e 100644 --- a/i18n/lang/fr.js +++ b/i18n/lang/fr.js @@ -796,7 +796,6 @@ // changelog.html chg_p_lede: "Ce qui a changé sur CROL-List, du plus récent au plus ancien.", chg_auto_h2: "Mises à jour récentes", - chg_auto_note: "Ces lignes proviennent des descriptions des modifications de code fusionnées et restent en anglais pour le moment.", chg_earlier_h2: "Versions précédentes", chg_detail_note: "Les notes techniques détaillées sous chaque version (listes à puces, rapports d'incidents) restent en anglais pour l'instant.", chg_foot_html: "CROL-List est une interface gratuite et non officielle vers des données publiques. À propos · Statistiques · API et flux · Accueil", diff --git a/i18n/lang/ht.js b/i18n/lang/ht.js index 553235f..a4dafc4 100644 --- a/i18n/lang/ht.js +++ b/i18n/lang/ht.js @@ -679,7 +679,6 @@ api_foot_html: "CROL-List · Akèy · Konsènan", chg_p_lede: "Sa ki chanje sou CROL-List, pi resan an anvan.", chg_auto_h2: "Dènye chanjman yo", - chg_auto_note: "Liy sa yo soti nan deskripsyon chanjman kòd ki fin fusyone yo, epi yo rete an anglè pou kounye a.", chg_earlier_h2: "Ansyen vèsyon yo", chg_detail_note: "Nòt teknik detaye anba chak vèsyon (lis pwen, rapò ensidan) rete an angle pou kounye a.", chg_foot_html: "CROL-List se yon entèfas gratis, ki pa ofisyèl, pou done piblik. Konsènan · Estatistik · API ak flux · Akèy", diff --git a/i18n/lang/ko.js b/i18n/lang/ko.js index 88dce7d..fad90ac 100644 --- a/i18n/lang/ko.js +++ b/i18n/lang/ko.js @@ -684,7 +684,6 @@ api_foot_html: "CROL-List · · 소개", chg_p_lede: "CROL-List의 변경 사항, 최신순.", chg_auto_h2: "최근 업데이트", - chg_auto_note: "이 내용은 병합된 코드 변경 사항의 설명에서 가져온 것이며, 현재는 영어로 표시됩니다.", chg_earlier_h2: "이전 릴리스", chg_detail_note: "각 릴리스 아래의 세부 기술 노트(항목 목록, 사고 보고서)는 현재 영어로 제공됩니다.", chg_foot_html: "CROL-List는 공식 기관이 아닌 무료 공개 데이터 인터페이스입니다. 소개 · 통계 · API 및 피드 · ", diff --git a/i18n/lang/pl.js b/i18n/lang/pl.js index 2ea1171..f0f5918 100644 --- a/i18n/lang/pl.js +++ b/i18n/lang/pl.js @@ -700,7 +700,6 @@ api_foot_html: "CROL-List · Strona główna · O projekcie", chg_p_lede: "Co się zmieniło w CROL-List, od najnowszych.", chg_auto_h2: "Najnowsze aktualizacje", - chg_auto_note: "Te wpisy pochodzą z opisów scalonych zmian w kodzie i na razie pozostają w języku angielskim.", chg_earlier_h2: "Wcześniejsze wydania", chg_detail_note: "Szczegółowe notatki techniczne pod każdym wydaniem (listy punktowane, raporty incydentów) pozostają na razie w języku angielskim.", chg_foot_html: "CROL-List to nieoficjalny, bezpłatny interfejs do danych publicznych. O projekcie · Statystyki · API i kanały · Strona główna", diff --git a/i18n/lang/ru.js b/i18n/lang/ru.js index fab7bf8..00675a8 100644 --- a/i18n/lang/ru.js +++ b/i18n/lang/ru.js @@ -690,7 +690,6 @@ api_foot_html: "CROL-List · Главная · О проекте", chg_p_lede: "Что изменилось в CROL-List, сначала самое новое.", chg_auto_h2: "Недавние обновления", - chg_auto_note: "Эти строки взяты из описаний объединённых изменений кода и пока остаются на английском языке.", chg_earlier_h2: "Более ранние версии", chg_detail_note: "Подробные технические заметки под каждым выпуском (маркированные списки, отчёты об инцидентах) пока остаются на английском.", chg_foot_html: "CROL-List — это неофициальный бесплатный интерфейс к публичным данным. О проекте · Статистика · API и ленты · Главная", diff --git a/i18n/lang/ur.js b/i18n/lang/ur.js index 5d80591..d0392ac 100644 --- a/i18n/lang/ur.js +++ b/i18n/lang/ur.js @@ -693,7 +693,6 @@ api_foot_html: "CROL-List · ہوم · تعارف", chg_p_lede: "CROL-List میں کیا بدلا، تازہ ترین پہلے۔", chg_auto_h2: "حالیہ اپڈیٹس", - chg_auto_note: "یہ سطریں مرج شدہ کوڈ کی تبدیلیوں کی تفصیل سے لی گئی ہیں اور فی الحال انگریزی میں ہیں۔", chg_earlier_h2: "پرانے ورژن", chg_detail_note: "ہر ریلیز کے نیچے تفصیلی تکنیکی نوٹس (بلٹ پوائنٹ فہرستیں، واقعے کی رپورٹیں) فی الحال انگریزی میں ہی رہتی ہیں۔", chg_foot_html: "CROL-List عوامی ڈیٹا کا ایک غیر سرکاری، مفت انٹرفیس ہے۔ تعارف · اعداد و شمار · API اور فیڈز · ہوم", diff --git a/i18n/lang/zh-Hans.js b/i18n/lang/zh-Hans.js index b7e45b9..9038eaa 100644 --- a/i18n/lang/zh-Hans.js +++ b/i18n/lang/zh-Hans.js @@ -678,7 +678,6 @@ api_foot_html: "CROL-List · 首页 · 关于", chg_p_lede: "CROL-List 的更新内容,按最新排序。", chg_auto_h2: "最近更新", - chg_auto_note: "这些内容来自已合并代码更改的说明,目前仍为英文。", chg_earlier_h2: "更早的版本", chg_detail_note: "每个版本下方的详细技术说明(项目列表、事件报告)目前仍为英文。", chg_foot_html: "CROL-List 是一个非官方的、免费的公开数据接口。关于 · 统计 · API 与信息源 · 首页", diff --git a/index.html b/index.html index d9b8efc..c0470a1 100644 --- a/index.html +++ b/index.html @@ -356,7 +356,7 @@ - + diff --git a/reading-level-baseline.json b/reading-level-baseline.json index d4504ea..13611ee 100644 --- a/reading-level-baseline.json +++ b/reading-level-baseline.json @@ -12,7 +12,7 @@ }, "changelog.html": { "formula": "flesch_kincaid_grade", - "grade": 10.915831306504046, + "grade": 10.340383771929826, "language": "en" }, "data.html": { diff --git a/stats.html b/stats.html index 9ca82d5..6ab1fff 100644 --- a/stats.html +++ b/stats.html @@ -5,7 +5,7 @@ Stats · CROL-List - + diff --git a/test/changelog_entry_gate.test.mjs b/test/changelog_entry_gate.test.mjs new file mode 100644 index 0000000..cd14d38 --- /dev/null +++ b/test/changelog_entry_gate.test.mjs @@ -0,0 +1,127 @@ +// Characterization tests for the changelog editorial gate (crol-changelogcurate-m6): +// tools/gen_changelog.mjs's computeEntryAddition() now requires the `changelog:major` +// label in addition to a "## What this means for you" section before a merged PR earns a +// changelog.html entry (see tools/changelog_extract.mjs's header comment for why the +// marker section alone stopped being a significance signal). +// +// Before this gate existed: recent changelog.html entries had drifted into a near-mirror +// of the merged-PR log — every PR that carried a marker section got a line, regardless of +// whether a visitor would notice or care. Real production examples of the regression, all +// PR bodies pulled verbatim from this repo's own history: +// - PR #77 (below) is a CI-only reliability fix; its own "What this means for you" text +// admits as much ("No visible change to the site") yet still rendered as a changelog +// entry. +// - PR #55 (below) is a real bug fix (a broken click path), user-visible but not the kind +// of change the pre-2026-07-10 curated page would have called out on its own. +// - PR #70 (below) is a genuinely major feature debut (external-awards coverage) — the +// kind of entry the page exists to surface. +// +// After: only a PR carrying BOTH the section and the label produces an entry. #77 and #55 +// (unlabeled, as they actually shipped) produce nothing; #70, labeled `changelog:major` as +// it should have been, produces its entry. +import test from "node:test"; +import assert from "node:assert/strict"; +import { computeEntryAddition } from "../tools/gen_changelog.mjs"; +import { MAJOR_LABEL } from "../tools/changelog_extract.mjs"; + +const PR_77_INTERNAL_TOOLING_BODY = `## Summary +Wires a pre-merge reading-level simulation into CI. + +## What this means for you + +No visible change to the site. A future code change that would make the changelog page +harder to read now gets flagged on its own pull request, before it merges, instead of +after. + +## Test plan +- [x] node --test test/*.test.mjs — 260/260 pass +`; + +const PR_55_MINOR_FIX_BODY = `## Summary +Fixes a click handler that silently no-op'd. + +## What this means for you + +Typing a query into the Alerts "Narrow by keyword" field and clicking Preview now works whether or not you've clicked a topic chip first — it's resolved the same way the "Ask" box already handles free-text queries, and you'll always see either a populated preview or a clear "we understood this as…" summary, never silence. + +## Test plan +- [x] Reproduced the reported failure live against production before making any change +`; + +const PR_70_MAJOR_TEXT = + "When you open a notice or agency profile for a public authority that files its contract awards outside the City Record — like the School Construction Authority, NYC Health + Hospitals, NYCHA, or the Economic Development Corporation — the site now checks the authority's own open-data filing and shows what it found there, naming and linking the source and when it was last updated. If an agency's awards genuinely aren't published anywhere as open data, the site now says so plainly instead of leaving you guessing."; + +const PR_70_MAJOR_BODY = `## Motivation +Several public authorities post their solicitations in the City Record but file the resulting awards elsewhere. + +## What this means for you + +${PR_70_MAJOR_TEXT} + +## Validation +- Full Node and Worker test suites pass. +`; + +test("an internal/tooling PR (real PR #77, unlabeled) produces no entry even though it carries the marker section", () => { + const result = computeEntryAddition([], { + number: 77, + url: "https://github.com/cityscroll/crol-list/pull/77", + mergedAt: "2026-07-17", + body: PR_77_INTERNAL_TOOLING_BODY, + labels: ["bug"], + }); + assert.equal(result.reason, "not-major"); + assert.equal(result.text, null); + assert.equal(result.entries.length, 0); +}); + +test("a minor-fix PR (real PR #55, unlabeled) produces no entry even though the fix is genuinely user-visible", () => { + const result = computeEntryAddition([], { + number: 55, + url: "https://github.com/cityscroll/crol-list/pull/55", + mergedAt: "2026-07-15", + body: PR_55_MINOR_FIX_BODY, + labels: [], + }); + assert.equal(result.reason, "not-major"); + assert.equal(result.entries.length, 0); +}); + +test("a major user-facing PR (real PR #70), labeled changelog:major, produces its entry", () => { + const result = computeEntryAddition([], { + number: 70, + url: "https://github.com/cityscroll/crol-list/pull/70", + mergedAt: "2026-07-17", + body: PR_70_MAJOR_BODY, + labels: ["enhancement", MAJOR_LABEL], + }); + assert.equal(result.reason, "added"); + assert.equal(result.text, PR_70_MAJOR_TEXT); + assert.equal(result.entries.length, 1); + assert.equal(result.entries[0].pr, 70); +}); + +test("the changelog:major label alone, with no marker section, still produces no entry", () => { + const result = computeEntryAddition([], { + number: 99, + url: "https://example.invalid/99", + mergedAt: "2026-07-20", + body: "## Summary\nJust a summary, no impact section.\n", + labels: [MAJOR_LABEL], + }); + assert.equal(result.reason, "no-marker"); + assert.equal(result.entries.length, 0); +}); + +test("an already-recorded PR number stays a no-op regardless of label", () => { + const existing = [{ pr: 70, merged_at: "2026-07-17", url: "", text: "already here" }]; + const result = computeEntryAddition(existing, { + number: 70, + url: "https://example.invalid/70", + mergedAt: "2026-07-17", + body: PR_70_MAJOR_BODY, + labels: [MAJOR_LABEL], + }); + assert.equal(result.reason, "already-recorded"); + assert.equal(result.entries, existing); +}); diff --git a/test/changelog_extract.test.mjs b/test/changelog_extract.test.mjs index e4050b3..df6bc9b 100644 --- a/test/changelog_extract.test.mjs +++ b/test/changelog_extract.test.mjs @@ -18,7 +18,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { extractUserImpact } from "../tools/changelog_extract.mjs"; +import { extractUserImpact, hasMajorLabel, MAJOR_LABEL } from "../tools/changelog_extract.mjs"; const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); @@ -93,3 +93,23 @@ test("the changelog bot's own PR body produces nothing (the loop's convergence p const botBody = fs.readFileSync(path.join(ROOT, ".github", "changelog-bot-pr-body.md"), "utf8"); assert.equal(extractUserImpact(botBody), null); }); + +// crol-changelogcurate-m6: a bullet-list-formatted marker section used to leak a literal +// "-" into the joined text — real field case, PR #76's harvested entry read "- Every note +// that says your answer lives in another public system now hands you the door: ...". +test("a marker section written as a bullet list has its list markers stripped, not leaked into the joined sentence", () => { + const body = "## What this means for you\n- Every note now links through.\n- A dead mention says so honestly.\n\n## Test plan\n"; + assert.equal( + extractUserImpact(body), + "Every note now links through. A dead mention says so honestly." + ); +}); + +test("hasMajorLabel matches the changelog:major label case-insensitively among a PR's other labels", () => { + assert.equal(hasMajorLabel(["enhancement", MAJOR_LABEL]), true); + assert.equal(hasMajorLabel(["Changelog:Major"]), true); + assert.equal(hasMajorLabel(["enhancement", "bug"]), false); + assert.equal(hasMajorLabel([]), false); + assert.equal(hasMajorLabel(null), false); + assert.equal(hasMajorLabel(undefined), false); +}); diff --git a/test/check_changelog_reading_level.test.mjs b/test/check_changelog_reading_level.test.mjs index e00d122..979d96c 100644 --- a/test/check_changelog_reading_level.test.mjs +++ b/test/check_changelog_reading_level.test.mjs @@ -27,6 +27,7 @@ import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { simulate, measureAgainstBaseline, formatRegressionMessage } from "../tools/check_changelog_reading_level.mjs"; +import { MAJOR_LABEL } from "../tools/changelog_extract.mjs"; const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const SCRIPT = path.join(ROOT, "tools", "check_changelog_reading_level.mjs"); @@ -113,12 +114,29 @@ test("a PR with no marker section produces no simulation and no grade check — url: "https://example.invalid/99", mergedAt: "2026-07-17", body: NO_MARKER_BODY, + labels: [MAJOR_LABEL], }); assert.equal(result.reason, "no-marker"); assert.equal(result.text, null); assert.equal(result.html, null); }); +test("a PR with no changelog:major label produces no simulation, even with a marker section present", () => { + const result = simulate({ + baseDataJson: JSON.stringify(BASE_DATA), + baseHtml: BASE_HTML, + i18nSource: Buffer.from(""), + number: 99, + url: "https://example.invalid/99", + mergedAt: "2026-07-17", + body: bodyWithMarker(PR_74_PLAIN), + labels: ["enhancement"], + }); + assert.equal(result.reason, "not-major"); + assert.equal(result.text, null); + assert.equal(result.html, null); +}); + test("a PR number already recorded in the base produces no simulation (mirrors gen_changelog.mjs's own idempotency)", () => { const result = simulate({ baseDataJson: JSON.stringify(BASE_DATA), @@ -144,6 +162,7 @@ test( url: "https://github.com/cityscroll/crol-list/pull/74", mergedAt: "2026-07-17", body: bodyWithMarker(PR_74_ORIGINAL), + labels: [MAJOR_LABEL], }); assert.equal(sim.reason, "added"); assert.equal(sim.text, PR_74_ORIGINAL); @@ -171,6 +190,7 @@ test( url: "https://github.com/cityscroll/crol-list/pull/72", mergedAt: "2026-07-17", body: bodyWithMarker(PR_72_ORIGINAL), + labels: [MAJOR_LABEL], }); assert.equal(sim.reason, "added"); @@ -195,6 +215,7 @@ test( url: `https://github.com/cityscroll/crol-list/pull/${number}`, mergedAt: "2026-07-17", body: bodyWithMarker(text), + labels: [MAJOR_LABEL], }); const result = measureAgainstBaseline(sim.html, { baseline: writeTmpBaseline(), preset: "nycsg7" }); assert.equal(result.status, "pass", `PR #${number}'s reworded text should pass: ${result.reason}`); @@ -236,6 +257,7 @@ test( "--url", "https://github.com/cityscroll/crol-list/pull/74", "--merged-at", "2026-07-17", "--body-file", bodyPath, + "--labels", MAJOR_LABEL, "--baseline", baselinePath, ], { encoding: "utf8" } @@ -269,7 +291,8 @@ test( process.execPath, [SCRIPT, "--base-data", baseDataPath, "--base-html", baseHtmlPath, "--i18n", i18nPath, "--number", "74", "--url", "https://github.com/cityscroll/crol-list/pull/74", - "--merged-at", "2026-07-17", "--body-file", regressBody, "--baseline", baselinePath], + "--merged-at", "2026-07-17", "--body-file", regressBody, "--labels", MAJOR_LABEL, + "--baseline", baselinePath], { encoding: "utf8", stdio: "pipe" } ); }, (err) => { @@ -285,7 +308,8 @@ test( process.execPath, [SCRIPT, "--base-data", baseDataPath, "--base-html", baseHtmlPath, "--i18n", i18nPath, "--number", "74", "--url", "https://github.com/cityscroll/crol-list/pull/74", - "--merged-at", "2026-07-17", "--body-file", passBody, "--baseline", baselinePath], + "--merged-at", "2026-07-17", "--body-file", passBody, "--labels", MAJOR_LABEL, + "--baseline", baselinePath], { encoding: "utf8" } ); assert.match(out, /^OK —/); diff --git a/tools/changelog_extract.mjs b/tools/changelog_extract.mjs index 11b6b0e..8ed04a1 100644 --- a/tools/changelog_extract.mjs +++ b/tools/changelog_extract.mjs @@ -1,10 +1,19 @@ -// tools/changelog_extract.mjs — pure extraction of a PR's user-impact line from its body. +// tools/changelog_extract.mjs — pure extraction of a PR's user-impact line from its body, +// plus the gate that decides whether that PR clears the changelog's editorial bar. // -// Convention (documented in CONTRIBUTING.md): a PR marks itself user-facing by carrying a -// "## What this means for you" section (any heading level, case-insensitive) with one line -// of plain-language, user-impact text. A PR with no such section is plumbing and produces -// nothing — this is the mechanism that keeps changelog.html self-updating without a human -// deciding, per PR, whether it belongs. +// Convention (documented in CONTRIBUTING.md): a PR states its user impact in a +// "## What this means for you" section (any heading level, case-insensitive). That alone +// no longer earns a changelog.html entry — every PR in this project's workflow carries that +// section by internal convention, whether the change is a major feature or an invisible +// refactor, so section-presence stopped meaning "significant" (crol-changelogcurate-m6: the +// generated page had drifted into a near-mirror of the merged-PR log, some entries reading +// "No visible change to the site" or "Not user-facing" — literally saying they don't belong +// on the page while still appearing on it). A PR now ALSO needs the `changelog:major` label +// to be harvested — an explicit, one-click affirmation ("this is worth a reader's attention"), +// separate from the body text. No label -> no entry, same as no marker section -> no entry; +// this is the mechanism that keeps changelog.html self-updating without a human hand-editing +// the page, while keeping "is this significant" a deliberate per-PR decision instead of a +// default. // // Plain ESM (this repo's tools/ scripts and their tests use `.mjs` + import/export; the // require()-able-dictionary convention documented for i18n.js/nl_parse.js is specific to @@ -12,6 +21,15 @@ const MARKER_RE = /^#{1,6}\s*what this means for you\s*$/i; const HEADING_RE = /^#{1,6}\s+\S/; +const LIST_MARKER_RE = /^(?:[-*+]|\d+[.)])\s+/; + +export const MAJOR_LABEL = "changelog:major"; + +export function hasMajorLabel(labels) { + if (!labels) return false; + const list = Array.isArray(labels) ? labels : [labels]; + return list.some((l) => String(l).trim().toLowerCase() === MAJOR_LABEL); +} export function extractUserImpact(body) { if (!body) return null; @@ -35,7 +53,9 @@ export function extractUserImpact(body) { if (collected.length) break; // blank line after content ends the section continue; // allow a blank line right after the heading } - collected.push(trimmed); + // Strip a leading list marker ("- ", "* ", "1. ") so a section written as a bullet + // list doesn't leak a literal "-" into the rendered, joined-with-spaces sentence. + collected.push(trimmed.replace(LIST_MARKER_RE, "")); } const text = collected.join(" ").replace(/\s+/g, " ").trim(); diff --git a/tools/check_changelog_reading_level.mjs b/tools/check_changelog_reading_level.mjs index d692506..ad69f80 100644 --- a/tools/check_changelog_reading_level.mjs +++ b/tools/check_changelog_reading_level.mjs @@ -27,7 +27,7 @@ // Usage: // node tools/check_changelog_reading_level.mjs \ // --base-data --base-html --i18n \ -// --number N --url URL --merged-at DATE --body-file FILE \ +// --number N --url URL --merged-at DATE --body-file FILE --labels "changelog:major,..." \ // --baseline [--preset nycsg7] // // Exit 0: no harvestable entry (nothing to simulate), or the simulated page meets the @@ -39,7 +39,7 @@ import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { computeEntryAddition, buildHtml } from "./gen_changelog.mjs"; +import { computeEntryAddition, buildHtml, parseLabels } from "./gen_changelog.mjs"; export function parseArgs(argv) { const out = { preset: "nycsg7" }; @@ -52,6 +52,7 @@ export function parseArgs(argv) { else if (a === "--url") out.url = argv[++i]; else if (a === "--merged-at") out.mergedAt = argv[++i]; else if (a === "--body-file") out.bodyFile = argv[++i]; + else if (a === "--labels") out.labels = argv[++i]; else if (a === "--baseline") out.baseline = argv[++i]; else if (a === "--preset") out.preset = argv[++i]; } @@ -62,10 +63,10 @@ export function parseArgs(argv) { // — html is null when there's nothing to simulate (reason is "no-marker" or // "already-recorded"). Pure aside from the two base-file/i18n reads the caller hands it as // already-loaded strings, so it's directly unit-testable with fixture strings. -export function simulate({ baseDataJson, baseHtml, i18nSource, number, url, mergedAt, body }) { +export function simulate({ baseDataJson, baseHtml, i18nSource, number, url, mergedAt, body, labels }) { const data = JSON.parse(baseDataJson); const entries = data.entries || []; - const result = computeEntryAddition(entries, { number, url, mergedAt, body }); + const result = computeEntryAddition(entries, { number, url, mergedAt, body, labels }); if (result.reason !== "added") { return { reason: result.reason, text: null, html: null }; } @@ -133,10 +134,11 @@ function main() { url: args.url, mergedAt: args.mergedAt, body, + labels: parseLabels(args.labels), }); - if (sim.reason === "no-marker") { - console.log('No "What this means for you" section in this PR — nothing to simulate.'); + if (sim.reason === "not-major" || sim.reason === "no-marker") { + console.log('This PR is not labeled "changelog:major" (or has no "What this means for you" section) — nothing to simulate.'); return; } if (sim.reason === "already-recorded") { diff --git a/tools/gen_changelog.mjs b/tools/gen_changelog.mjs index c423cb8..e6d197b 100644 --- a/tools/gen_changelog.mjs +++ b/tools/gen_changelog.mjs @@ -1,16 +1,18 @@ #!/usr/bin/env node // tools/gen_changelog.mjs — regenerates changelog.html's "Recent updates" list from -// changelog-data.json, and (given --number/--url/--merged-at/--body-file) first tries to -// add a new entry extracted from a merged PR's body. +// changelog-data.json, and (given --number/--url/--merged-at/--body-file/--labels) first +// tries to add a new entry extracted from a merged PR's body. // // Two call shapes: // node tools/gen_changelog.mjs --number 34 --url --merged-at 2026-07-15 \ -// --body-file /tmp/pr-body.md -// Extracts the PR's "## What this means for you" line (changelog_extract.mjs). No -// marker section -> prints a message and exits 0 (plumbing PRs are expected to lack -// one; this is not a failure). Already-recorded PR number -> no-op (idempotent, safe -// to re-run). Otherwise prepends the entry to changelog-data.json and rewrites the -// HTML block. +// --body-file /tmp/pr-body.md --labels "changelog:major,enhancement" +// Extracts the PR's "## What this means for you" line (changelog_extract.mjs), but only +// when the PR also carries the `changelog:major` label (see changelog_extract.mjs's +// header comment for why the marker section alone stopped being a significance signal). +// No label, or a label but no marker section -> prints a message and exits 0 (this is +// the expected, common case, not a failure). Already-recorded PR number -> no-op +// (idempotent, safe to re-run). Otherwise prepends the entry to changelog-data.json and +// rewrites the HTML block. // node tools/gen_changelog.mjs --rebuild // Rewrites changelog.html's HTML block from the current changelog-data.json only — // useful after a hand-edit to the data file, or to verify the two files agree. @@ -28,7 +30,7 @@ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { fileURLToPath } from "node:url"; -import { extractUserImpact } from "./changelog_extract.mjs"; +import { extractUserImpact, hasMajorLabel } from "./changelog_extract.mjs"; const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const DATA_PATH = path.join(ROOT, "changelog-data.json"); @@ -47,6 +49,7 @@ function parseArgs(argv) { else if (a === "--url") out.url = argv[++i]; else if (a === "--merged-at") out.mergedAt = argv[++i]; else if (a === "--body-file") out.bodyFile = argv[++i]; + else if (a === "--labels") out.labels = argv[++i]; } return out; } @@ -124,14 +127,27 @@ function rewriteHtml(entries) { fs.writeFileSync(HTML_PATH, rebuilt); } -// Pure: given the current entries list and a candidate PR's body, decides whether it adds -// a new entry — reused by both the real post-merge CLI (main(), below) and the pre-merge -// simulation script (tools/check_changelog_reading_level.mjs) so "what counts as harvestable" can never -// drift between the two call sites. -export function computeEntryAddition(entries, { number, url, mergedAt, body }) { +// Splits a comma-separated --labels value (as GitHub Actions' `join(labels.*.name, ',')` +// produces) back into a plain array. Tolerant of a missing/empty value (no labels). +export function parseLabels(raw) { + if (!raw) return []; + return String(raw) + .split(",") + .map((l) => l.trim()) + .filter(Boolean); +} + +// Pure: given the current entries list and a candidate PR's body + labels, decides whether +// it adds a new entry — reused by both the real post-merge CLI (main(), below) and the +// pre-merge simulation script (tools/check_changelog_reading_level.mjs) so "what counts as +// harvestable" can never drift between the two call sites. +export function computeEntryAddition(entries, { number, url, mergedAt, body, labels }) { if (entries.some((e) => e.pr === number)) { return { entries, text: null, reason: "already-recorded" }; } + if (!hasMajorLabel(labels)) { + return { entries, text: null, reason: "not-major" }; + } const text = extractUserImpact(body); if (!text) { return { entries, text: null, reason: "no-marker" }; @@ -157,13 +173,18 @@ function main() { url: args.url, mergedAt: args.mergedAt, body, + labels: parseLabels(args.labels), }); if (result.reason === "already-recorded") { console.log(`PR #${args.number} already recorded — no-op.`); return; } + if (result.reason === "not-major") { + console.log(`PR #${args.number} carries no "changelog:major" label — skipped.`); + return; + } if (result.reason === "no-marker") { - console.log(`PR #${args.number} carries no "What this means for you" section — not user-facing, skipped.`); + console.log(`PR #${args.number} is labeled "changelog:major" but carries no "What this means for you" section — skipped.`); return; } data.entries = result.entries; From 67967bf736bdb29a5e85caecdd2e6601bea0bd1e Mon Sep 17 00:00:00 2001 From: James Carroll Date: Mon, 20 Jul 2026 13:54:39 -0400 Subject: [PATCH 2/5] changelog: re-tighten reading-level baseline after rebase picked up PR #80's entry --- reading-level-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reading-level-baseline.json b/reading-level-baseline.json index 13611ee..22d5b69 100644 --- a/reading-level-baseline.json +++ b/reading-level-baseline.json @@ -12,7 +12,7 @@ }, "changelog.html": { "formula": "flesch_kincaid_grade", - "grade": 10.340383771929826, + "grade": 10.372199954322259, "language": "en" }, "data.html": { From cc88261682abca3a1662f2c3a233f984ea8215e5 Mon Sep 17 00:00:00 2001 From: James Carroll Date: Mon, 20 Jul 2026 13:57:02 -0400 Subject: [PATCH 3/5] changelog: poll the bot PR's named required checks instead of the whole CI run The dispatched ci.yml run also executes the 'functional' job (Playwright against live APIs, workflow_dispatch-only, documented as flaky) since it's not gated to pull_request. That job isn't in main's required-checks list, so waiting on the whole run's pass/fail (gh run watch) would let an unrelated flake there block an otherwise-mergeable bot PR. Polling the PR's own statusCheckRollup for the four required check names directly targets what actually gates the merge queue. --- .github/workflows/update-changelog.yml | 53 +++++++++++++++++++------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index a5e9d5c..6a12892 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -37,6 +37,12 @@ # check run, so a workflow_dispatch-triggered CI run on the bot branch's head SHA satisfies # the same required checks a pull_request-triggered run would have. Once those pass, the # existing enqueuePullRequest call (moved to after the wait) succeeds on its own. +# +# The wait step polls the bot PR's own statusCheckRollup for the four REQUIRED check names +# specifically, not the dispatched run's overall pass/fail — ci.yml's "functional" job (the +# Playwright suite against live APIs) only runs on workflow_dispatch, is documented as +# flaky, and isn't in the ruleset's required list; waiting on the whole run instead of the +# named checks would let an unrelated flake in that job block an otherwise-mergeable PR. name: Update changelog on: @@ -140,28 +146,49 @@ jobs: # See the file-header comment above for why this step exists: `pull_request`-triggered # CI on this branch would sit at "action_required" forever without a maintainer's click. - - name: Dispatch CI directly on the bot branch (workflow_dispatch bypasses the pull_request-event approval gate) and wait for it + - name: Dispatch CI directly on the bot branch (workflow_dispatch bypasses the pull_request-event approval gate) and wait for the required checks if: env.changed == 'true' timeout-minutes: 20 run: | gh workflow run ci.yml --ref "$BOT_BRANCH" + PR_NUMBER="$(cat "$RUNNER_TEMP/bot-pr-number")" - RUN_ID="" - for i in $(seq 1 20); do - sleep 3 - RUN_ID="$(gh run list --workflow ci.yml --branch "$BOT_BRANCH" --event workflow_dispatch \ - --limit 1 --json databaseId --jq '.[0].databaseId // empty')" - [ -n "$RUN_ID" ] && break + # Keep this list in sync with the "required_status_checks" contexts on main's + # ruleset (`gh api repos/OWNER/REPO/rules/branches/main`). + REQUIRED_CHECKS=( + "Unit tests (site + worker)" + "Accessibility + language gate (axe on every PR)" + "Stray-English guard (runtime, fixtures)" + "Reading-level ratchet gate (readable-or-else)" + ) + + ci_ok=false + for i in $(seq 1 80); do + sleep 15 + ROLLUP="$(gh pr view "$PR_NUMBER" --json statusCheckRollup --jq '.statusCheckRollup // []')" + all_done=true + all_green=true + for name in "${REQUIRED_CHECKS[@]}"; do + ENTRY="$(echo "$ROLLUP" | jq -c --arg n "$name" '[.[] | select(.name==$n)][0] // {}')" + status="$(echo "$ENTRY" | jq -r '.status // "PENDING"')" + conclusion="$(echo "$ENTRY" | jq -r '.conclusion // ""')" + if [ "$status" != "COMPLETED" ]; then + all_done=false + elif [ "$conclusion" != "SUCCESS" ]; then + all_green=false + fi + done + if [ "$all_done" = "true" ]; then + [ "$all_green" = "true" ] && ci_ok=true + break + fi done - if [ -z "$RUN_ID" ]; then - echo "::warning::couldn't find the dispatched CI run on $BOT_BRANCH within the poll window — leaving bot PR #$(cat "$RUNNER_TEMP/bot-pr-number") for manual review" - echo "ci_ok=false" >> "$GITHUB_ENV" - elif gh run watch "$RUN_ID" --exit-status; then - echo "CI run $RUN_ID passed on $BOT_BRANCH" + if [ "$ci_ok" = "true" ]; then + echo "Required checks passed on $BOT_BRANCH (PR #$PR_NUMBER)" echo "ci_ok=true" >> "$GITHUB_ENV" else - echo "::warning::CI run $RUN_ID failed on $BOT_BRANCH — leaving bot PR #$(cat "$RUNNER_TEMP/bot-pr-number") for manual review" + echo "::warning::required checks didn't all pass on $BOT_BRANCH within the poll window — leaving bot PR #$PR_NUMBER for manual review" echo "ci_ok=false" >> "$GITHUB_ENV" fi From 49588add765d1664a6673e3708df4fb02baa3cee Mon Sep 17 00:00:00 2001 From: James Carroll Date: Mon, 20 Jul 2026 13:58:17 -0400 Subject: [PATCH 4/5] docs: record the changelog editorial gate + bot self-landing in AGENTS.md --- AGENTS.md | 53 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78bc319..c76f550 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -412,11 +412,19 @@ This file is the project's committed home for project-intrinsic agent knowledge: ## Changelog — self-updating from merged-PR marker sections -- **`changelog.html`'s "Recent updates" list is generated, never hand-edited.** A PR marks - itself user-facing with a `## What this means for you` section in its body (any heading - level, case-insensitive — see `tools/changelog_extract.mjs`'s `extractUserImpact()`); a PR - with no such section is plumbing and gets no entry, by design, not oversight. Convention is - documented for contributors in `CONTRIBUTING.md`'s "Changelog entries" section. +- **`changelog.html`'s "Recent updates" list is generated, never hand-edited, and it's + curated, not comprehensive** (crol-changelog-m6). A PR earns an entry only when it carries + BOTH a `## What this means for you` section in its body (any heading level, case-insensitive + — see `tools/changelog_extract.mjs`'s `extractUserImpact()`) AND the `changelog:major` + label (`hasMajorLabel()`, same file). The marker section alone stopped being a significance + signal once every PR in this project's workflow started carrying one by convention + regardless of actual impact — the page had drifted into a near-mirror of the merged-PR log, + including entries whose own harvested text admitted they didn't belong ("No visible change + to the site", "Not user-facing"). Convention documented for contributors in + `CONTRIBUTING.md`'s "Changelog entries" section; most PRs should NOT carry the label. A + bullet-list-formatted marker section has its list markers (`-`/`*`/`1.`) stripped during + extraction — a real, verbatim leak (PR #76's harvested entry read "- Every note that says + your answer lives...") is what caught this. - **`changelog-data.json` is the source of truth**; the HTML block between the ``/`` markers in `changelog.html` is a full rebuild from that file every time (`tools/gen_changelog.mjs`), never hand-patched — @@ -439,15 +447,40 @@ This file is the project's committed home for project-intrinsic agent knowledge: after one hop. If a second qualifying PR merges before the queue processes the first bot PR, the next run reads the bot branch's own pending `changelog-data.json` as its base (not `main`'s stale copy) and accumulates onto that same open PR instead of opening a duplicate. +- **The bot PR lands itself — no maintainer click required** (crol-changelog-m6, fixing PR + #81's real failure mode). Arming auto-merge alone doesn't help: main's ruleset requires + four named status checks on top of the merge queue, and GitHub requires a maintainer to + click "Approve and run workflows" for ANY `pull_request`-triggered run whose author is + GITHUB_TOKEN's bot identity (`github-actions[bot]`) — even on a same-repo, non-fork branch. + Confirmed live: PR #81 sat with zero check runs on its head SHA (`mergeStateStatus: + BLOCKED`) until a maintainer manually re-ran CI, repeatedly, for every force-push. The fix + routes around it: after pushing, the workflow calls `gh workflow run ci.yml --ref + bot/changelog-update` — `workflow_dispatch` is a different trigger, and the fork/ + first-time-contributor approval gate is scoped to `pull_request`/`pull_request_target` + only. Required-status-check matching keys on (SHA, check name), not on which event produced + the check run, so the dispatched run's checks satisfy the same requirements a + `pull_request`-triggered run would have. The workflow then polls the bot PR's own + `statusCheckRollup` for the four required check names specifically (NOT `gh run watch` + against the whole dispatched run) before calling the existing `enqueuePullRequest` — + `ci.yml`'s `functional` job (Playwright against live APIs) only runs on `workflow_dispatch`, + is documented as flaky, and isn't in the required list, so waiting on the whole run would + let an unrelated flake there block an otherwise-mergeable PR. - **`.chg-auto` is a content-zone carve-out**, same posture as the pre-existing `.chg-detail` archival carve-out: entries are extracted verbatim from PR bodies at merge time, so they can't be pre-translated or guaranteed to pass the NYC copy lint — carved out of both `test/functional/assets/stray_english_allowlist.json` (`content_zone_selectors`) and - `test/standards/nyc_copy_lint.py`'s skip-zone check. The list's own heading and disclosure - note (`chg_auto_h2`/`chg_auto_note`) are ordinary translated chrome, NOT inside the carve-out - — watch the class-name substring check in `nyc_copy_lint.py` if you rename anything (`"chg- - auto" in cls` would also match a sibling class literally named `chg-auto-*`, which is why the - disclosure note's class is `chgauto-note`, not `chg-auto-note`). + `test/standards/nyc_copy_lint.py`'s skip-zone check. The list's own heading (`chg_auto_h2`) + is ordinary translated chrome, NOT inside the carve-out — watch the class-name substring + check in `nyc_copy_lint.py` if you rename anything (`"chg-auto" in cls` would also match a + sibling class literally named `chg-auto-*`). **No disclaimer paragraph explains the + carve-out to readers** (crol-changelog-m6 removed one, `chg_auto_note`, that read "These + lines come from the descriptions of merged code changes and stay in English for now") — + the carve-out itself is what keeps the hermetic stray-English guard green on + changelog.html across all ten shipping languages; a live disclosure paragraph was + self-referential generation-process commentary, not something the carve-out needed. Standing + rule: no process disclaimers or self-referential copy on a user-facing surface — if a + localization constraint exists, the fix is structural (a legitimate carve-out, as here), not + an apology rendered to the reader. - **Backfill vs. future entries**: the fourteen entries seeded 2026-07-11 through 2026-07-14 were hand-picked from `gh pr list --state merged` (pre-dating the marker convention) and written as if each PR had carried the marker; every entry from here on is mechanical. From 3cc21a27d4831903fb6ce500be7f85afeedb3301 Mon Sep 17 00:00:00 2001 From: James Carroll Date: Mon, 20 Jul 2026 14:03:06 -0400 Subject: [PATCH 5/5] chore: re-trigger CI after PR body reword (reading-level ratchet)