From e2cec88aaec09b5e839bdba8f1f4006cb8477760 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 14:16:16 +0200 Subject: [PATCH 1/4] ci: make the release pipeline deterministic (flaky tests, changelog, source gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three determinism fixes from investigating the stable 0.7.59 release; every security gate (OIDC attestation, SBOM, reproducible double-build, committed-asset check, Node-22 pin, source policy, audits) is unchanged. - Flaky "Browser regression shard 4/4" (failed on two code-free PRs): - email-notifications B.13 contact-form: clearMailpit's DELETE-all was still settling and swallowed a just-sent message. After the empty-stable check, store a sentinel via Mailpit's API, require it retained across two reads, then delete only that sentinel by id — no second full purge to re-open the race. - full-test 2.2 dashboard: FullCalendar's stylesheet injection read cssRules on a document detached mid-evaluation. Await waitForLoadState('load') at the end of 2.1 so the post-login dashboard finishes evaluating before 2.2 navigates. - Changelog extraction: bound the section on the next second-level version heading in EITHER format (## [X.Y.Z] or the legacy ## What's New in vX.Y.Z), so a legacy neighbour no longer makes awk capture the whole file. - Prerelease source gate (ci-verify-release-source.sh): the tag-triggered release workflow's own check attaches to the PR head, so the PR is UNSTABLE while it runs and mergeStateStatus can never reach CLEAN. Accept UNSTABLE alongside CLEAN (BLOCKED/DIRTY/BEHIND stay fatal), and compute the required-checks verdict from a filtered set that excludes the release workflow's own check. Head==tag, the exactly-one-internal-open-release-PR rule, and the mandatory CodeRabbit check are all still enforced. --- .github/workflows/release.yml | 8 +++- scripts/ci-verify-release-source.sh | 33 ++++++++++++--- tests/email-notifications.spec.js | 66 ++++++++++++++++++++++++++++- tests/full-test.spec.js | 9 ++++ 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a969efbe..46600867 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,8 +114,14 @@ jobs: # The value is supplied by the trusted push tag, not PR-controlled data. # shellcheck disable=SC2153 version=${RELEASE_TAG#v} + # The section ends at the NEXT second-level version heading in either + # supported format: the current "## [X.Y.Z]" or the legacy + # "## What's New in vX.Y.Z" (the "." matches the apostrophe so the + # single-quoted awk program needs no shell-quoting gymnastics). + # Bounding only on "## [" would capture the whole rest of the file + # whenever the neighbouring entry below is legacy-formatted. section=$(awk -v version="$version" \ - '$0 ~ "^## \\[" version "\\]" {flag=1; next} /^## \[/ {flag=0} flag' \ + '$0 ~ "^## \\[" version "\\]" {flag=1; next} /^## (\[|What.s New in v)/ {flag=0} flag' \ CHANGELOG.md) if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then section="See CHANGELOG.md for details." diff --git a/scripts/ci-verify-release-source.sh b/scripts/ci-verify-release-source.sh index a58f1b27..2de95b17 100755 --- a/scripts/ci-verify-release-source.sh +++ b/scripts/ci-verify-release-source.sh @@ -59,23 +59,42 @@ elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then echo "Prerelease PR #${pr_number} moved from tagged commit ${GITHUB_SHA} to ${pr_head}" >&2 exit 1 fi - if [[ "$merge_state" != "CLEAN" ]]; then + # Why UNSTABLE is accepted alongside CLEAN: this very script runs inside the + # tag-triggered "Verified Release" workflow, whose check run attaches to the + # tagged commit — which IS the PR head. While this job is in progress GitHub + # reports the PR as UNSTABLE ("mergeable, but a non-required status is + # pending/failing"), so demanding CLEAN here is circular: the check that must + # pass keeps the PR from ever being CLEAN. UNSTABLE by definition still means + # the PR is mergeable (no conflicts, branch protection satisfied); the only + # thing it relaxes versus CLEAN is non-required statuses — and the loop below + # closes exactly that gap by independently requiring every branch-protection + # REQUIRED check to be green, excluding only this release workflow's own + # check run (tag-triggered, so by construction never a PR-required check). + # BLOCKED, DIRTY, BEHIND and UNKNOWN remain fatal: merge conflicts, missing + # approvals or unmet branch protection still veto the prerelease. + if [[ "$merge_state" != "CLEAN" && "$merge_state" != "UNSTABLE" ]]; then echo "Prerelease PR #${pr_number} is not merge-ready (mergeStateStatus=${merge_state})" >&2 exit 1 fi - set +e checks_json="$(gh pr checks "$pr_number" --repo "$GITHUB_REPOSITORY" \ - --required --json name,state,bucket)" - checks_exit=$? - set -e + --required --json name,state,bucket,workflow || true)" if ! jq -e 'type == "array" and length > 0' >/dev/null <<<"$checks_json"; then echo "Prerelease PR #${pr_number} has no readable required-check result" >&2 exit 1 fi - failing_checks="$(jq -r '.[] | select(.bucket != "pass") | "\(.name): \(.state)"' <<<"$checks_json")" - if [[ "$checks_exit" != "0" || -n "$failing_checks" ]]; then + # Every required check except this release workflow's own run must be in the + # "pass" bucket (pending/fail/cancel/skipping all veto). The self-exclusion + # is defense in depth for the circularity above: should someone ever mark + # the release workflow itself required, the gate must still judge only the + # OTHER required checks instead of deadlocking on its own in-progress run. + # The decision is computed from the filtered JSON, not from gh's exit code, + # because the exit code would also trip on the self check being "pending". + failing_checks="$(jq -r --arg self_workflow "Verified Release" \ + '.[] | select(.workflow != $self_workflow) | select(.bucket != "pass") | "\(.name): \(.state)"' \ + <<<"$checks_json")" + if [[ -n "$failing_checks" ]]; then echo "Prerelease PR #${pr_number} has required checks that did not pass:" >&2 printf '%s\n' "$failing_checks" >&2 exit 1 diff --git a/tests/email-notifications.spec.js b/tests/email-notifications.spec.js index df4a9e29..d1fc1ca1 100644 --- a/tests/email-notifications.spec.js +++ b/tests/email-notifications.spec.js @@ -106,19 +106,81 @@ async function clearMailpit() { // snapshots before the next test is allowed to send mail. let consecutiveEmpty = 0; const deadline = Date.now() + 5000; + let stablyEmpty = false; while (Date.now() < deadline) { const data = await mailpitJson('/messages'); const count = Number(data.total ?? data.messages_count ?? data.messages?.length ?? 0); if (count === 0) { consecutiveEmpty++; - if (consecutiveEmpty === 2) return; + if (consecutiveEmpty === 2) { stablyEmpty = true; break; } } else { consecutiveEmpty = 0; } await new Promise(resolve => setTimeout(resolve, 150)); } + if (!stablyEmpty) { + throw new Error('Mailpit inbox did not become stably empty within 5000ms'); + } + + // A stably-empty LISTING still does not prove the purge fully settled: in CI + // (deep-regression shard 4/4, runs 31657224674 and 31689284087 attempt 1) + // B.13's contact email was SMTP-accepted ~1s after the DELETE was + // acknowledged, yet never became visible — swallowed by the still-settling + // purge — so waitForMail timed out and only the serial-block retry passed. + // Prove Mailpit is accepting AND RETAINING new mail again before returning: + // store a sentinel via the HTTP send API, require it to stay retrievable in + // two consecutive reads, then delete just that sentinel by ID (a targeted + // delete — no second full purge to re-open the race). + const sentinelDeadline = Date.now() + 10000; + let sentinelId = null; + while (sentinelId === null && Date.now() < sentinelDeadline) { + let id = null; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch(`${MAILPIT_API}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + body: JSON.stringify({ + From: { Email: 'purge-sentinel@pinakes.invalid' }, + To: [{ Email: 'purge-sentinel@pinakes.invalid' }], + Subject: `clearMailpit retention sentinel ${Date.now()}`, + Text: 'Proves the delete-all purge has settled. Deleted by clearMailpit().', + }), + }); + if (!res.ok) throw new Error(`Mailpit send failed: HTTP ${res.status}`); + id = (await res.json()).ID; + } finally { + clearTimeout(timer); + } + } catch { /* Mailpit busy — try a fresh sentinel below */ } + + if (id) { + let retainedReads = 0; + while (retainedReads < 2 && Date.now() < sentinelDeadline) { + const retained = await mailpitJson(`/message/${id}`).then(() => true).catch(() => false); + if (!retained) { retainedReads = -1; break; } // swallowed → purge still active + retainedReads++; + if (retainedReads < 2) await new Promise(resolve => setTimeout(resolve, 200)); + } + if (retainedReads === 2) { sentinelId = id; break; } + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + if (sentinelId === null) { + throw new Error('Mailpit purge did not settle: sentinel messages kept disappearing within 10000ms'); + } - throw new Error('Mailpit inbox did not become stably empty within 5000ms'); + const delRes = await fetch(`${MAILPIT_API}/messages`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ IDs: [sentinelId] }), + }); + if (!delRes.ok) { + throw new Error(`Mailpit sentinel cleanup failed: HTTP ${delRes.status}`); + } } function clearConfigCache() { diff --git a/tests/full-test.spec.js b/tests/full-test.spec.js index 0430b3ff..d98ca319 100644 --- a/tests/full-test.spec.js +++ b/tests/full-test.spec.js @@ -482,6 +482,15 @@ test.describe.serial('Phase 2: Login and Dashboard', () => { await page.locator('button[type="submit"]').click(); await page.waitForURL(/admin/, { timeout: 30000 }); await expect(page).toHaveURL(/admin/); + // waitForURL resolves at navigation commit, while the post-login landing + // page (the dashboard) is still evaluating parser-inserted scripts — + // FullCalendar injects its stylesheet at module load. If 2.2 navigates + // away before that finishes, the document is detached mid-evaluation, + // style.sheet is null, and the resulting uncaught + // "Cannot read properties of null (reading 'cssRules')" lands in the + // pageerror listener 2.2 just attached (seen in CI run 31658701424, + // shard 4/4). Wait for the landing page to finish loading first. + await page.waitForLoadState('load'); }); test('2.2 Dashboard loads with content', async () => { From eb2aec7bec85eec9519fcee3e2533ba13305c2ae Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 14:43:26 +0200 Subject: [PATCH 2/4] fix(ci): report actual merge state + bound the sentinel cleanup fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups on the determinism changes: - ci-verify-release-source.sh: the success line hard-coded "is CLEAN" even though the gate now also accepts UNSTABLE — report the real mergeStateStatus so the log never claims an unverified state. - email-notifications clearMailpit: the sentinel-delete fetch had no AbortSignal, so a hung Mailpit could keep it pending until the global test timeout. Wrap it in the same 5000ms AbortController + finally-clear pattern the other Mailpit requests use. --- scripts/ci-verify-release-source.sh | 2 +- tests/email-notifications.spec.js | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/ci-verify-release-source.sh b/scripts/ci-verify-release-source.sh index 2de95b17..1cf2c307 100755 --- a/scripts/ci-verify-release-source.sh +++ b/scripts/ci-verify-release-source.sh @@ -111,7 +111,7 @@ elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then exit 1 fi - echo "Verified prerelease ${TAG_NAME}: PR #${pr_number} (${pr_branch}) is CLEAN and every required check passed" + echo "Verified prerelease ${TAG_NAME}: PR #${pr_number} (${pr_branch}) is merge-ready (mergeStateStatus=${merge_state}) and every required check passed" else echo "Unsupported release version '${version}': use X.Y.Z or X.Y.Z-(alpha|beta|rc).N" >&2 exit 1 diff --git a/tests/email-notifications.spec.js b/tests/email-notifications.spec.js index d1fc1ca1..e9475ceb 100644 --- a/tests/email-notifications.spec.js +++ b/tests/email-notifications.spec.js @@ -173,11 +173,19 @@ async function clearMailpit() { throw new Error('Mailpit purge did not settle: sentinel messages kept disappearing within 10000ms'); } - const delRes = await fetch(`${MAILPIT_API}/messages`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ IDs: [sentinelId] }), - }); + const delController = new AbortController(); + const delTimer = setTimeout(() => delController.abort(), 5000); + let delRes; + try { + delRes = await fetch(`${MAILPIT_API}/messages`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ IDs: [sentinelId] }), + signal: delController.signal, + }); + } finally { + clearTimeout(delTimer); + } if (!delRes.ok) { throw new Error(`Mailpit sentinel cleanup failed: HTTP ${delRes.status}`); } From 1ebdd4d2ed9acf984c3ae2117e2da7fbc25b9a0b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 14:50:50 +0200 Subject: [PATCH 3/4] ci(release): notify pinakes-docker to rebuild on a stable release release.yml replaced create-release.sh, which fired a repository_dispatch to pinakes-docker so the Docker image rebuilt on every stable release. That step was dropped in the move, so the stable 0.7.59 published here never reached Docker Hub until pinakes-docker's daily poller would have caught it. Re-add the dispatch as a release step (stable tags only, matching create-release.sh), gated on a PINAKES_DOCKER_DISPATCH_TOKEN secret because the default GITHUB_TOKEN cannot dispatch across repositories; a missing token or a failed dispatch is non-fatal since the daily poller remains the backstop. --- .github/workflows/release.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46600867..c31cc139 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,6 +150,33 @@ jobs: fi gh release create "$TAG_NAME" "${assets[@]}" "${release_flags[@]}" + - name: Trigger the Docker image rebuild (stable only, non-fatal) + # This workflow replaced create-release.sh, which fired this dispatch so + # pinakes-docker rebuilds and republishes the image on a stable release — + # without it a stable published here never reaches Docker Hub/GHCR until + # pinakes-docker's daily poller catches it. The default GITHUB_TOKEN cannot + # dispatch to another repository, so this needs a PAT secret with dispatch + # (contents:write) access to fabiodalez-dev/pinakes-docker. Prereleases are + # skipped (matching create-release.sh); a missing token or a failed + # dispatch is non-fatal because the daily poller is the backstop. + if: ${{ !contains(github.ref_name, '-') }} + env: + GH_TOKEN: ${{ secrets.PINAKES_DOCKER_DISPATCH_TOKEN }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "PINAKES_DOCKER_DISPATCH_TOKEN is not set — relying on pinakes-docker's daily poller." >&2 + exit 0 + fi + version="${RELEASE_TAG#v}" + if gh api -X POST repos/fabiodalez-dev/pinakes-docker/dispatches \ + -f event_type=pinakes_release \ + -F "client_payload[pinakes_version]=${version}"; then + echo "Notified pinakes-docker to build v${version}." + else + echo "pinakes-docker dispatch failed (non-fatal; the daily poller is the backstop)." >&2 + fi + - name: Retain release evidence in Actions uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: From 52d664635195d5398e2786f4abc4c7b8b49eaa1f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 22:27:36 +0200 Subject: [PATCH 4/4] ci: pin codeql-action version comment to the exact tag (fix zizmor drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three github/codeql-action steps pin SHA 5595ccaf (tag v4.37.6) but the comment read "# v4". "v4" is a MOVING major tag; once codeql-action published a newer patch, the tag advanced to a different commit and zizmor's pedantic ref-version-mismatch audit started failing "Workflow, YAML and shell security" on every PR built against main — a spurious block unrelated to any PR's content. Comment the exact immutable tag (# v4.37.6) so the pin and its documentation never drift again. Verified with zizmor 1.29.0 (persona pedantic): the whole .github/workflows/ tree now reports no findings. --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f2254aaa..28ea70e8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,16 +31,16 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: javascript-typescript queries: security-and-quality config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:javascript-typescript"