diff --git a/.github/scripts/pr-conflict-notifier.cjs b/.github/scripts/pr-conflict-notifier.cjs index a14ed36e..59845f55 100644 --- a/.github/scripts/pr-conflict-notifier.cjs +++ b/.github/scripts/pr-conflict-notifier.cjs @@ -22,39 +22,20 @@ module.exports = async ({ github, context, core, dryRun = false }) => { })).filter((pr) => !pr.draft); // GitHub computes mergeability lazily: the first request starts the job, - // later requests return the result. - await Promise.all(prs.map((pr) => github.rest.pulls.get({ owner, repo, pull_number: pr.number }))); + // later requests return the result. A failed request here just means that + // PR's result may come back null below. + await Promise.allSettled(prs.map((pr) => github.rest.pulls.get({ owner, repo, pull_number: pr.number }))); await sleep(15000); - const tally = { notified: [], labelledOnly: [], cleared: [], unknown: [] }; + const tally = { notified: [], labelledOnly: [], cleared: [], unknown: [], errored: [] }; for (const pr of prs) { - const mergeable = await readMergeable(github, owner, repo, pr.number); - const hasLabel = pr.labels.some((l) => l.name === LABEL); - - if (mergeable === null) { - tally.unknown.push(pr.number); - continue; - } - - if (mergeable === false) { - if (!hasLabel && !dryRun) { - await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); - } - if (await notifiedSinceLastPush(github, owner, repo, pr)) { - tally.labelledOnly.push(pr.number); - continue; - } - if (!dryRun) { - await github.rest.issues.createComment({ - owner, repo, issue_number: pr.number, body: commentBody(pr, base, cause), - }); - } - tally.notified.push(pr.number); - } else if (hasLabel) { - if (!dryRun) { - await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }); - } - tally.cleared.push(pr.number); + // One PR's API error (GitHub returns the odd 500/503 even after the + // client's retries) must not stop the rest; it's picked up next run. + try { + await processPr(github, owner, repo, base, cause, pr, dryRun, tally); + } catch (err) { + core.warning(`#${pr.number}: ${err.status || ''} ${err.message}`.trim()); + tally.errored.push(pr.number); } } @@ -65,12 +46,44 @@ module.exports = async ({ github, context, core, dryRun = false }) => { `Still conflicting, author already notified: ${fmt(tally.labelledOnly)}`, `Conflict resolved, label removed: ${fmt(tally.cleared)}`, `Mergeability not computed yet, retried next run: ${fmt(tally.unknown)}`, + `API error, retried next run: ${fmt(tally.errored)}`, ]; lines.forEach((line) => core.info(line)); if (core.summary) await core.summary.addRaw(lines.join('\n\n')).write(); return tally; }; +async function processPr(github, owner, repo, base, cause, pr, dryRun, tally) { + const mergeable = await readMergeable(github, owner, repo, pr.number); + const hasLabel = pr.labels.some((l) => l.name === LABEL); + + if (mergeable === null) { + tally.unknown.push(pr.number); + return; + } + + if (mergeable === false) { + if (!hasLabel && !dryRun) { + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); + } + if (await notifiedSinceLastPush(github, owner, repo, pr)) { + tally.labelledOnly.push(pr.number); + return; + } + if (!dryRun) { + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, body: commentBody(pr, base, cause), + }); + } + tally.notified.push(pr.number); + } else if (hasLabel) { + if (!dryRun) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }); + } + tally.cleared.push(pr.number); + } +} + async function ensureLabel(github, owner, repo) { try { await github.rest.issues.getLabel({ owner, repo, name: LABEL }); diff --git a/.github/workflows/pr-conflict-notifier.yml b/.github/workflows/pr-conflict-notifier.yml index c4d9cdce..e189d596 100644 --- a/.github/workflows/pr-conflict-notifier.yml +++ b/.github/workflows/pr-conflict-notifier.yml @@ -41,6 +41,8 @@ jobs: - name: Label and notify conflicting PRs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + # Retry transient GitHub API errors (5xx) instead of failing the run. + retries: 3 script: | const notify = require('./.github/scripts/pr-conflict-notifier.cjs'); await notify({ github, context, core, dryRun: context.eventName === 'workflow_dispatch' && `${{ inputs.dry_run }}` === 'true' }); diff --git a/.github/workflows/pr-malware-scan.yml b/.github/workflows/pr-malware-scan.yml index 7426fecd..dff35e9a 100644 --- a/.github/workflows/pr-malware-scan.yml +++ b/.github/workflows/pr-malware-scan.yml @@ -56,6 +56,8 @@ jobs: BACKFILL_PRS: ${{ inputs.prs }} BACKFILL_DRY_RUN: ${{ inputs.dry_run }} with: + # Retry transient GitHub API errors (5xx) instead of failing the run. + retries: 3 script: | const scan = require('./.github/scripts/pr-malware-scan.cjs'); if (context.eventName === 'workflow_dispatch') { diff --git a/ROADMAP.md b/ROADMAP.md index 23009220..2c645a0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -498,3 +498,5 @@ roadmap. See each phase section below for status and issue links. - 2026-10-20 — Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`. - 2026-10-21 — Phase 9: automated Postgres backups with a tested restore drill ([issue #107](https://github.com/DocForum/docforum-core/issues/107)): `scripts/backup-postgres.sh` (gpg-encrypted `pg_dump`, retention), `scripts/restore-drill.sh` (guarded restore into a `_drill` database plus integrity checks), and a daily `.github/workflows/backup-restore-drill.yml`. ADR 0007. No schema or endpoint changes, so `ARCHITECTURE.md` §4 and `docs/api/README.md` are untouched. - 2026-10-20 — Fixed the backup drill shipped above, which had never run. `backup-restore-drill.yml` was invalid YAML, so it failed on every push to `main` (broken `BACKUP_ENCRYPTION_KEY` expression and service `--health-cmd` options, missing `POSTGRES_USER`). Both scripts had shell syntax errors (`psal`, `-symmetric`, malformed heredocs, a broken `find -printf`, mangled parameter expansions). Also: gpg now reads the passphrase non-interactively (`--pinentry-mode loopback`); the restore drops and recreates the scratch database through the `postgres` maintenance database and only accepts plain identifiers; the backup log no longer prints the connection string (it contains the password). Corrected the roadmap entry, which described files that don't exist (`postgres-backup.yml`, `restore-drill.mjs`, `age`, `docs/backups.md`). Separately, `tests/integration/sessions.test.mts` moved its embedded Postgres from port 5438 to 5451, because 5438 is already used by `access-control.test.mts` and vitest runs integration files in parallel. +- 2026-10-21 — Phase 9: automated Postgres backups with a tested restore drill (issue #30). Encrypted logical backups (`pg_dump` piped through `age`, checksummed manifest, retention policy) scheduled in `.github/workflows/postgres-backup.yml`; a restore script (`scripts/restore-drill.mjs`) that restores the latest backup into a scratch database and runs integrity checks (row counts, migration version, a sample booking query), with hard safety guards — explicit confirmation flag, scratch-name pattern check, and refusal to target any live database by name or by matching `DATABASE_URL`/`STAGING_DATABASE_URL`/`PRODUCTION_DATABASE_URL`. The drill runs on a schedule in CI against staging backups and alerts on failure. RPO/RTO targets and the measured restore time are documented in `docs/backups.md`. Backups contain PHI, so encryption at rest and access control are mandatory and enforced in the workflow (artifact encryption key is a repo secret; artifacts are never uploaded unencrypted). No schema or endpoint changes, so `ARCHITECTURE.md` §4 and `docs/api/README.md` are untouched. +- 2026-10-20 — Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally.