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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a969efbe..c31cc139 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." @@ -144,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: diff --git a/scripts/ci-verify-release-source.sh b/scripts/ci-verify-release-source.sh index a58f1b27..1cf2c307 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 @@ -92,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 df4a9e29..e9475ceb 100644 --- a/tests/email-notifications.spec.js +++ b/tests/email-notifications.spec.js @@ -106,19 +106,89 @@ 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'); + } - 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'); + } + + 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}`); + } } 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 () => {