From 1ce6d5f103fc8bb0588ad27dbeefd4955e15a3a8 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 25 Aug 2026 12:47:18 +0200 Subject: [PATCH 1/2] ci(sync): assign the sync pull request only when it is worth reading The pull request carried an assignee on every run, so the notification said nothing: most runs move only `updatedAt`, `stars` or the version and URLs inside `downloads`, and each of those follows a commit, a click or a release without changing what the page shows. A second comparison now strips those out as well, and only what is left over draws an assignee. The platform/format pairs stay in it on purpose, because the build fails only when no asset at all matches the platform table: one installer missing from a release would otherwise take a download button off the page unseen. --- .github/workflows/sync-projects.yml | 57 ++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync-projects.yml b/.github/workflows/sync-projects.yml index 0ea18fa..8d8610f 100644 --- a/.github/workflows/sync-projects.yml +++ b/.github/workflows/sync-projects.yml @@ -67,18 +67,51 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | + # Two comparisons against main, asking two different questions. + # # `generatedAt` carries the build date, so it differs on every run # after midnight UTC. Comparing the file as a whole would therefore # open a pull request every single day with one changed line, and the # real changes would drown in that noise. Compare without it: the # stamp then says when the data last changed, not when it was checked. - if diff -q \ - <(git show HEAD:projects.json | grep -v '"generatedAt"') \ - <(grep -v '"generatedAt"' projects.json) >/dev/null; then + stamp='del(.generatedAt)' + + # The second question is whether the change is worth a human's + # attention. Some of the fetched facts move on their own and say + # nothing when they do: `updatedAt` follows any commit in the + # repository, `stars` follows anyone's click, and inside `downloads` + # the version, URL and size rotate with every release without + # changing which installers the page offers. Compare with those gone + # as well, and what is left over is exactly what earns an assignee. + # + # The platform/format pairs stay in that comparison on purpose: the + # build only fails when *no* asset matches the platform table, so a + # release that quietly stopped shipping one installer would otherwise + # take a download button off the page unseen. + moving="$stamp | .projects |= map( + del(.updatedAt, .stars, .version) + | if .downloads then .downloads |= map({ platform, format }) else . end + )" + + same() { + diff -q \ + <(git show HEAD:projects.json | jq -S "$1") \ + <(jq -S "$1" projects.json) >/dev/null + } + + if same "$stamp"; then echo "No changes beyond the build date." exit 0 fi + assignee= + if same "$moving"; then + echo "Only push dates, stars and release URLs moved — leaving this one unassigned." + else + assignee=TitusKirch + echo "Something beyond the moving facts changed — assigning $assignee." + fi + branch=bot/sync-projects base_sha=$(git rev-parse HEAD) @@ -136,11 +169,25 @@ jobs: --base main \ --head "$branch" \ --title "chore: sync projects.json" \ - --body "Automated rebuild of \`projects.json\` from the curated entries under \`projects/\` plus live GitHub repository metadata." \ - --assignee TitusKirch) + --body "Automated rebuild of \`projects.json\` from the curated entries under \`projects/\` plus live GitHub repository metadata.") echo "Opened $pr" fi + # Assign after the fact rather than at creation, so a pull request an + # earlier run opened unassigned still picks one up once a later run + # finds something worth reading — the branch is re-pointed at main + # every time, so an open PR always carries the current diff. Nothing + # ever unassigns it again: that diff is judged against main, and a + # fact that earned a look does not stop having earned it. + # + # Auto-merge stays on either way. What an assignee is asked is "is + # this right?", not "should this land?", and the answer to a wrong + # entry is a commit under `projects/`, never a held pull request — + # which the next run would overwrite mid-read in any case. + if [ -n "$assignee" ]; then + gh pr edit "$pr" --add-assignee "$assignee" >/dev/null + fi + # Enabling auto-merge is a repository setting, so this is the one step # that can fail on configuration rather than on content. Say so plainly # instead of leaving a bare API error, and still fail the run: a sync From b1a0bb1d734a3b7c1c22e869e873d1fb9510d976 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 25 Aug 2026 12:47:34 +0200 Subject: [PATCH 2/2] docs(agents): record when the sync pull request draws an assignee --- AGENTS.md | 1 + CLAUDE.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4ce2860..e14c363 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,6 +210,7 @@ This is why no secret with access to private repositories exists anywhere in thi - **It is all-or-nothing.** If any single API call fails, nothing is written and the run fails. A partial file is worse than a stale one: the app's fallback chain can serve the last good copy, but it cannot tell that a project vanished because one call errored. - **`pnpm build` ends with `oxfmt projects.json`, and must.** `JSON.stringify` puts every array member on its own line; oxfmt keeps short ones inline. Without that step the build writes a file its own `pnpm format` gate then rejects, and every sync PR would open red. - **It compares without `generatedAt`.** That field holds the build date and therefore differs on every run after midnight UTC — comparing the whole file would open a pull request on every run with one changed line, and real changes would drown in it. So the stamp means *when the data last changed*, not when it was last checked; a run that finds nothing new leaves both the file and the date alone. +- **It assigns only what someone would actually read.** A second comparison runs with the self-moving facts stripped out as well: `updatedAt` follows any commit in a repository, `stars` follows anyone's click, and inside `downloads` the version, URL and size rotate with every release. A pull request that changes nothing else is left unassigned and merges itself unseen. Everything left over draws an assignee — a licence, the archived flag, a platform that dropped out of `downloads`, and every editorial change, whose *parsed* shape is visible nowhere else. The platform/format pairs stay in the comparison because `build.ts` only fails when *no* asset matches the platform table: one installer quietly missing from a release would otherwise take a download button off the page unseen. Auto-merge stays on either way — what an assignee is asked is "is this right?", not "should this land?", and the answer to a wrong entry is a commit under `projects/`, never a held pull request, which the next run would re-point out from under the reader anyway. - **It runs as a self-merging pull request, never a direct push.** The PR is not a review gate — `main` requires a pull request but zero approving reviews, so the workflow marks it `--squash --auto` and GitHub merges it once CI is green. What the PR buys is that a red run stops the change and that every sync leaves a record. This needs *Allow auto-merge* enabled on the repository; without it the merge call fails and the PR simply waits for a human. `.github/workflows/sync-projects.yml` re-points `bot/sync-projects` at the current main tip and commits through the **Contents API** — commits made that way with `GITHUB_TOKEN` are signed by GitHub and count as *Verified*, which a `required_signatures` rule on main insists on. A plain `git push` would be rejected. `cleanup-sync-branch.yml` deletes the branch after the merge, since re-pointing means the merge never does. The app fetches only the finished `projects.json` — no token, no rate limit, no knowledge of the API — and a scheduler command pulls it into the cache. **Fallback chain so the page is never empty:** cache → last known state → a copy shipped inside the app repo. Without that last link a public page depends on `raw.githubusercontent.com` being up. diff --git a/CLAUDE.md b/CLAUDE.md index 4ce2860..e14c363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,6 +210,7 @@ This is why no secret with access to private repositories exists anywhere in thi - **It is all-or-nothing.** If any single API call fails, nothing is written and the run fails. A partial file is worse than a stale one: the app's fallback chain can serve the last good copy, but it cannot tell that a project vanished because one call errored. - **`pnpm build` ends with `oxfmt projects.json`, and must.** `JSON.stringify` puts every array member on its own line; oxfmt keeps short ones inline. Without that step the build writes a file its own `pnpm format` gate then rejects, and every sync PR would open red. - **It compares without `generatedAt`.** That field holds the build date and therefore differs on every run after midnight UTC — comparing the whole file would open a pull request on every run with one changed line, and real changes would drown in it. So the stamp means *when the data last changed*, not when it was last checked; a run that finds nothing new leaves both the file and the date alone. +- **It assigns only what someone would actually read.** A second comparison runs with the self-moving facts stripped out as well: `updatedAt` follows any commit in a repository, `stars` follows anyone's click, and inside `downloads` the version, URL and size rotate with every release. A pull request that changes nothing else is left unassigned and merges itself unseen. Everything left over draws an assignee — a licence, the archived flag, a platform that dropped out of `downloads`, and every editorial change, whose *parsed* shape is visible nowhere else. The platform/format pairs stay in the comparison because `build.ts` only fails when *no* asset matches the platform table: one installer quietly missing from a release would otherwise take a download button off the page unseen. Auto-merge stays on either way — what an assignee is asked is "is this right?", not "should this land?", and the answer to a wrong entry is a commit under `projects/`, never a held pull request, which the next run would re-point out from under the reader anyway. - **It runs as a self-merging pull request, never a direct push.** The PR is not a review gate — `main` requires a pull request but zero approving reviews, so the workflow marks it `--squash --auto` and GitHub merges it once CI is green. What the PR buys is that a red run stops the change and that every sync leaves a record. This needs *Allow auto-merge* enabled on the repository; without it the merge call fails and the PR simply waits for a human. `.github/workflows/sync-projects.yml` re-points `bot/sync-projects` at the current main tip and commits through the **Contents API** — commits made that way with `GITHUB_TOKEN` are signed by GitHub and count as *Verified*, which a `required_signatures` rule on main insists on. A plain `git push` would be rejected. `cleanup-sync-branch.yml` deletes the branch after the merge, since re-pointing means the merge never does. The app fetches only the finished `projects.json` — no token, no rate limit, no knowledge of the API — and a scheduler command pulls it into the cache. **Fallback chain so the page is never empty:** cache → last known state → a copy shipped inside the app repo. Without that last link a public page depends on `raw.githubusercontent.com` being up.