From 380cfa75a33b62db51a99ced37d4d4ac4ae30eca Mon Sep 17 00:00:00 2001 From: Kenny Baas-Schwegler Date: Sun, 2 Aug 2026 12:54:07 +0200 Subject: [PATCH 1/4] Judge hand-edited content by who changed it, not who pushed last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard reads `git log -1 --format='%an' "$SHA"` — the author of the tip commit — and compares it to `virtualddd-sync`. That is the same thing as "who changed the content" only when the sync's own commit happens to be the last one to land. Merge a pull request that carries synced content and it is not. The tip becomes the merge commit, authored by whoever pressed the button, and the deploy fails on content nobody touched. Verified against this repository's own history: with a merge commit on top of 7b7370a, the current check reads "Kenny Baas-Schwegler" and rejects files the sync wrote. A plain sync push still passes, and a genuine hand edit (c6311f5) still fails — which is the case worth not breaking. That is a false positive on the workflow whose whole job is to be trusted, and the obvious workaround for it — push straight to main — is worse than the rule being enforced. Asks the range instead: who authored the commits between BEFORE and SHA that actually touched `src/content`. A merge commit touches nothing, so it does not appear. A squash still fails, and should: squashing rewrites the author of the content into whoever opened the pull request, and there is then no evidence left that the sync produced it. The failure message says so, because the next person to hit it will be holding a legitimate sync. Found while porting this pipeline to weave-it.org, where merging the first branch that carried a sync commit would have hit it. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5a18c66..0c755ed 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -110,8 +110,26 @@ jobs: run: | touched=$(git diff --name-only "$BEFORE" "$SHA" -- src/content 2>/dev/null || true) [ -z "$touched" ] && { echo "No generated content touched."; exit 0; } - author=$(git log -1 --format='%an' "$SHA") - if [ "$author" = "virtualddd-sync" ]; then + + # Ask who authored the commits that *touched src/content* in this + # range — not who authored the tip. + # + # It used to read `git log -1 "$SHA"`, which is the same thing only + # when the sync's own commit is the very last one to land. Merge a + # pull request that carries a sync commit and the tip is the merge + # commit, authored by whoever pressed the button; the content is + # untouched by them and the deploy fails anyway. That is a false + # positive on the one workflow whose whole job is to be trusted, and + # the obvious workaround for it — push straight to main — is worse + # than the rule being enforced. + # + # A squash still fails, and correctly: squashing rewrites the author + # of the content into whoever opened the pull request, so there is no + # longer any evidence that the sync produced it. Use a rebase or a + # merge commit when a branch carries synced content. + authors=$(git log --format='%an' "$BEFORE..$SHA" -- src/content | sort -u) + others=$(printf '%s\n' "$authors" | grep -v '^virtualddd-sync$' || true) + if [ -z "$others" ]; then echo "Changed by the sync, which is the only thing allowed to." exit 0 fi @@ -123,10 +141,16 @@ jobs: echo echo "$touched" | sed 's/^/ /' echo + echo "Changed by: $(printf '%s' "$others" | paste -sd', ')" + echo echo "Make the change in Notion instead. If the *shape* of the content needs to" echo "change, that is a schema change; see docs/content-model.md." + echo + echo "If this *is* the sync's output arriving through a pull request, merge it" + echo "with a rebase or a merge commit rather than a squash — a squash rewrites" + echo "the author and erases the evidence that the sync produced it." } >> "$GITHUB_STEP_SUMMARY" - echo "::error::Generated content under src/content/ was edited by hand (author: $author). Change it in Notion." + echo "::error::Generated content under src/content/ was changed by $(printf '%s' "$others" | paste -sd', '). Change it in Notion." exit 1 - uses: actions/setup-node@v7 From a0329bcf775fbbc649b5a8bf8346575809806020 Mon Sep 17 00:00:00 2001 From: Kenny Baas-Schwegler Date: Sun, 2 Aug 2026 12:54:07 +0200 Subject: [PATCH 2/4] Skip the sync cleanly when NOTION_TOKEN is not set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other secret in this repository degrades to "skip and say so" — deploy.yml guards on `[ -z "$HOST" ]` and reports what it did. The sync does not: an empty or rotated `NOTION_TOKEN` makes the script exit 1, on a schedule, hourly, with nothing actually wrong. Nothing is wrong here today, because the secret is set. It matters the day it is rotated, or a fork runs the workflow, or somebody clears it while debugging — and it matters because a red run meaning "not configured" is indistinguishable from a red run meaning "broken", and a schedule full of both teaches everyone to read neither. Not hypothetical: on weave-it.org, which runs this same workflow, the hourly sync failed for seven hours with an unset token before anyone connected the red cross to the open task that caused it. Guards the job rather than the step, because everything downstream — the diff, the commit, the deploy it triggers — only means anything if something was fetched. The "What changed?" step is guarded too, so a skipped run cannot report "nothing changed in Notion", which is a different claim from "we never looked". Co-Authored-By: Claude Opus 5 --- .github/workflows/sync.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 037c087..3922368 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -71,7 +71,35 @@ jobs: - run: npm ci + # Every other secret in this repository degrades to "skip and say so" — + # deploy.yml guards on `[ -z "$HOST" ]`. This one did not: an empty or + # rotated `NOTION_TOKEN` makes the sync script exit 1, on a schedule, + # hourly, with nothing actually wrong. A red run that means "not + # configured" is indistinguishable from a red run that means "broken", + # and teaches everyone to ignore both. + # + # Skipping the *job* rather than the step, because everything after this + # point — the diff, the commit, the deploy it triggers — only means + # anything if something was fetched. + - name: Is there anything to sync from? + id: token + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + run: | + if [ -z "$NOTION_TOKEN" ]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + { + echo "### Skipped" + echo + echo "No \`NOTION_TOKEN\` secret is set, so there is nothing to sync from." + echo "Add it at **Settings → Secrets and variables → Actions**, then re-run." + } >> "$GITHUB_STEP_SUMMARY" + else + echo "ready=true" >> "$GITHUB_OUTPUT" + fi + - name: Pull Notion and ddd-crew + if: steps.token.outputs.ready == 'true' env: NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} # Raises GitHub's 60/hour unauthenticated limit, which CI runners @@ -106,6 +134,9 @@ jobs: - name: What changed? id: diff + # Guarded too, or a skipped sync reports \"nothing changed in Notion\", + # which is a different claim from \"we never looked\". + if: steps.token.outputs.ready == 'true' run: | # Stage first, and ask the index rather than the working tree. # `git diff` cannot see a file git has never heard of, and that is From a38a3e0a64af2ed242c05c02030e71408f6d6be0 Mon Sep 17 00:00:00 2001 From: Kenny Baas-Schwegler Date: Sun, 2 Aug 2026 12:54:07 +0200 Subject: [PATCH 3/4] Refuse to run an empty test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node --test` on a glob that matches nothing prints "pass 0" and exits 0. Both globs match here, so this changes nothing today. It is here because the sibling repository this pipeline was ported to spent an entire migration with an empty `tests/` directory: the step ran, went green on every push, and the brief went on saying that tier fails the deploy. Nobody was careless — the output said "pass". A green tick for "there was nothing to check" is worse than a red one, because it is indistinguishable from "everything passed". Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c755ed..7302b50 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -174,6 +174,21 @@ jobs: - name: Contracts, redirects and browser behaviour run: | + # `node --test` on a glob that matches nothing prints "pass 0" and + # exits 0. Both globs match today, so this changes nothing today — + # it is here because the sibling repository this pipeline was ported + # to spent an entire migration with an empty `tests/` directory, + # reporting success on every push while running no blocking tests at + # all, and nothing said so. + # + # A green tick for "there was nothing to check" is worse than a red + # one, because it is indistinguishable from "everything passed". + for glob in "tests/unit/*.test.mjs" "tests/*.test.mjs"; do + compgen -G "$glob" > /dev/null || { + echo "::error::No test files match $glob — the suite is empty, not passing." + exit 1 + } + done npx playwright install --with-deps chromium node --import tsx --test "tests/unit/*.test.mjs" node --test "tests/*.test.mjs" From 7023be9e2ac89f2412b8ee008bfe7c4d4ddd6da7 Mon Sep 17 00:00:00 2001 From: Kenny Baas-Schwegler Date: Sun, 2 Aug 2026 14:48:54 +0200 Subject: [PATCH 4/4] Fail closed when the content guard cannot attribute the change Three fixes from review, all inside the guards this branch adds. The range form has a hole the tip form did not: when `github.event.before` is newer than `github.sha`, which is what a force-push backwards looks like, the diff still shows content moving while `BEFORE..SHA` is empty. An empty author list then read as "nobody but the sync" and passed. A guard whose whole job is attribution must not pass when it cannot attribute, so an empty list is now a refusal. Replayed against this repository's history: a plain sync push still passes, a range mixing sync commits with hand-written code still passes because only the sync touched src/content, and the rollback case now fails where it used to wave through. `paste -sd', '` cycles its delimiter list rather than using it whole, so three authors came out as "alice,bob carol". It is the line someone reads while they are already confused about why their deploy failed. And the escaped quotes in the sync.yml comment were characters, not quoting. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 14 ++++++++++++-- .github/workflows/sync.yml | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7302b50..0a2cba6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -128,6 +128,16 @@ jobs: # longer any evidence that the sync produced it. Use a rebase or a # merge commit when a branch carries synced content. authors=$(git log --format='%an' "$BEFORE..$SHA" -- src/content | sort -u) + # Fail closed when there is nobody to attribute it to. A force-push + # backwards leaves `github.event.before` newer than `github.sha`, so + # the range is empty while the diff above still shows content moving: + # an empty author list would otherwise read as "nobody but the sync" + # and wave it through. A guard whose whole job is attribution must not + # pass when it cannot attribute. + if [ -z "$authors" ]; then + echo "::error::Content under src/content/ changed between $BEFORE and $SHA, but no commit in that range touched it. Cannot tell who did; refusing to deploy." + exit 1 + fi others=$(printf '%s\n' "$authors" | grep -v '^virtualddd-sync$' || true) if [ -z "$others" ]; then echo "Changed by the sync, which is the only thing allowed to." @@ -141,7 +151,7 @@ jobs: echo echo "$touched" | sed 's/^/ /' echo - echo "Changed by: $(printf '%s' "$others" | paste -sd', ')" + echo "Changed by: $(printf '%s' "$others" | paste -sd, | sed 's/,/, /g')" echo echo "Make the change in Notion instead. If the *shape* of the content needs to" echo "change, that is a schema change; see docs/content-model.md." @@ -150,7 +160,7 @@ jobs: echo "with a rebase or a merge commit rather than a squash — a squash rewrites" echo "the author and erases the evidence that the sync produced it." } >> "$GITHUB_STEP_SUMMARY" - echo "::error::Generated content under src/content/ was changed by $(printf '%s' "$others" | paste -sd', '). Change it in Notion." + echo "::error::Generated content under src/content/ was changed by $(printf '%s' "$others" | paste -sd, | sed 's/,/, /g'). Change it in Notion." exit 1 - uses: actions/setup-node@v7 diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 3922368..18af630 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -134,7 +134,7 @@ jobs: - name: What changed? id: diff - # Guarded too, or a skipped sync reports \"nothing changed in Notion\", + # Guarded too, or a skipped sync reports "nothing changed in Notion", # which is a different claim from \"we never looked\". if: steps.token.outputs.ready == 'true' run: |