diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5a18c66..0a2cba6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -110,8 +110,36 @@ 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) + # 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." exit 0 fi @@ -123,10 +151,16 @@ jobs: echo echo "$touched" | sed 's/^/ /' echo + 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." + 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, | sed 's/,/, /g'). Change it in Notion." exit 1 - uses: actions/setup-node@v7 @@ -150,6 +184,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" diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 037c087..18af630 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