Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions .github/workflows/sync-projects.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down