Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
35 changes: 34 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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:
Expand Down
35 changes: 27 additions & 8 deletions scripts/ci-verify-release-source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand All @@ -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
Expand Down
74 changes: 72 additions & 2 deletions tests/email-notifications.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
9 changes: 9 additions & 0 deletions tests/full-test.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down