From ae796f88ef9ca9ddecb4e32a5aea4ecefe6869e8 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Mon, 12 Oct 2026 23:12:16 +0200
Subject: [PATCH 01/13] ci(pages): reconcile owned deployment records safely
---
.github/pages-pruning-holds.json | 4 +
.github/workflows/prune-deployments.yml | 100 +---
CHANGELOG.md | 6 +
README.md | 8 +-
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 75 +++
scripts/prune-pages-deployments.mjs | 454 +++++++++++++++
.../tooling/prunePagesDeployments.test.mjs | 517 ++++++++++++++++++
.../tooling/prunePagesDeployments.test.ts | 14 +
8 files changed, 1098 insertions(+), 80 deletions(-)
create mode 100644 .github/pages-pruning-holds.json
create mode 100644 docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
create mode 100644 scripts/prune-pages-deployments.mjs
create mode 100644 tests/unit/tooling/prunePagesDeployments.test.mjs
create mode 100644 tests/unit/tooling/prunePagesDeployments.test.ts
diff --git a/.github/pages-pruning-holds.json b/.github/pages-pruning-holds.json
new file mode 100644
index 000000000..af9ba2f15
--- /dev/null
+++ b/.github/pages-pruning-holds.json
@@ -0,0 +1,4 @@
+{
+ "rollback": [],
+ "evidence": [6995722425, 6984593438, 6983983689, 6969003237, 6965361606, 6961183719]
+}
diff --git a/.github/workflows/prune-deployments.yml b/.github/workflows/prune-deployments.yml
index 01393f150..7cd72af4c 100644
--- a/.github/workflows/prune-deployments.yml
+++ b/.github/workflows/prune-deployments.yml
@@ -1,96 +1,44 @@
name: Prune old GitHub deployment records
on:
- workflow_run:
- workflows: [CI / CD]
- types: [completed]
schedule:
- # Weekly cleanup on Sundays at 03:00 UTC
- - cron: '0 3 * * 0'
+ # Daily reconciliation includes the Sunday 03:00 UTC backlog pass.
+ - cron: '0 3 * * *'
workflow_dispatch:
# QNBS-v3: top-level read-only; deployments:write scoped to job (CodeQL Token-Permissions).
permissions:
contents: read
+concurrency:
+ group: github-pages-record-pruning
+ cancel-in-progress: false
+
jobs:
prune:
- name: Keep latest 3 GitHub deployment records
+ name: Reconcile GitHub Pages records only
+ if: github.repository == 'qnbs/WorldScript-Studio' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
+ contents: read
+ actions: read
+ pages: read
deployments: write
steps:
- - name: Delete stale GitHub deployment records — keep latest 3 per environment
- # QNBS-v3: v9.0.0 uses node24 — v7 was node20 (deprecated June 2026)
+ - name: Check out immutable trusted main execution tree
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
+ - name: Plan or reconcile bounded Pages records
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+ retries: 0
script: |
- const KEEP = 3;
-
- // QNBS-v3: GitHub API only; Vercel artifacts require the protected #627 procedure.
- // QNBS-v3: Paginate all GitHub deployment records to prevent retention drift.
- const allDeployments = await github.paginate(
- github.rest.repos.listDeployments,
- {
- owner: context.repo.owner,
- repo: context.repo.repo,
- per_page: 100,
- },
- );
-
- // Group by environment, sort each group newest-first, keep the first KEEP
- const byEnv = {};
- for (const dep of allDeployments) {
- const env = dep.environment || 'unknown';
- (byEnv[env] ??= []).push(dep);
- }
-
- let totalDeleted = 0;
- let totalFailed = 0;
-
- for (const [env, deps] of Object.entries(byEnv)) {
- const sorted = deps.sort((a, b) => b.id - a.id);
- const toDelete = sorted.slice(KEEP);
-
- if (toDelete.length === 0) {
- core.info(`[${env}] Nothing to prune — only ${deps.length} deployment(s).`);
- continue;
- }
-
- core.info(`[${env}] ${deps.length} total → pruning ${toDelete.length}, keeping ${KEEP} latest.`);
-
- let deleted = 0;
- let failed = 0;
- for (const dep of toDelete) {
- // A deployment must be inactive before it can be deleted
- try {
- await github.rest.repos.createDeploymentStatus({
- owner: context.repo.owner,
- repo: context.repo.repo,
- deployment_id: dep.id,
- state: 'inactive',
- });
- } catch (e) {
- core.warning(`[${env}] Could not inactivate ${dep.id}: ${e.message}`);
- }
-
- try {
- await github.rest.repos.deleteDeployment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- deployment_id: dep.id,
- });
- deleted++;
- } catch (e) {
- core.warning(`[${env}] Could not delete ${dep.id}: ${e.message}`);
- failed++;
- }
- }
-
- core.info(`[${env}] Done: ${deleted} deleted, ${failed} failed.`);
- totalDeleted += deleted;
- totalFailed += failed;
- }
-
- core.info(`All environments: ${totalDeleted} deleted, ${totalFailed} failed.`);
+ const { readFileSync } = require('node:fs');
+ const { pathToFileURL } = require('node:url');
+ const root = process.env.GITHUB_WORKSPACE;
+ const { runAction } = await import(pathToFileURL(`${root}/scripts/prune-pages-deployments.mjs`).href);
+ const holds = JSON.parse(readFileSync(`${root}/.github/pages-pruning-holds.json`, 'utf8'));
+ await runAction({ github, context, core, holds, workflowRef: process.env.GITHUB_WORKFLOW_REF });
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb6e4e0e0..1579e0f0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- **CI / Pages:** reconcile owned deployment records safely, keeping the newest three
+ plus current publication, rollback and evidence holds. Daily bounded reconciliation
+ replaces broad CI-completion triggers; manual dispatch is read-only. Failed inactivation
+ stops deletion, and every mutation revalidates inventory and trusted-main authority.
+ The workflow remains disabled pending separate owner admission. Issue #1028.
+
- **R-15 Gate 4E evidence:** coordination-resource preservation checks inspect links without following them and reject nonempty files, FIFOs, sockets, and observation errors. Fixture-scoped regressions cover missing/empty resources and link variants. The ordinary-operation barrier is unchanged. Clean successor to PR #1021. PR #1026.
- **Docs:** a release tag that is not an ancestor of the checked history now fails as one tag-boundary class instead of a list of disjoint subjects. Real undocumented subjects still fail after a correct ancestor tag. A history read that fails after that ancestor check fails closed instead of passing as empty history. PR #1023.
diff --git a/README.md b/README.md
index 61cb7699b..787afdebb 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
-
+
@@ -809,7 +809,7 @@ The normal web PR pipeline is not the complete native release qualification surf
| DOCX | `docx` + JSZip | Word-compatible export |
| PWA | Service Worker + Web App Manifest | Offline shell/installability |
| i18n | Custom React i18n context | 2947 keys × 19 locales |
-| Testing | Vitest 5.x (8543+ tests / 636 files) + Playwright | Unit/integration/E2E |
+| Testing | Vitest 5.x (8544+ tests / 637 files) + Playwright | Unit/integration/E2E |
| Quality | Biome + tsgo + CodeQL/security tooling | Static and CI gates |
| Desktop | Tauri 2 | Current native shell |
@@ -886,7 +886,7 @@ WorldScript-Studio/
├── locales/ # Source locale trees
├── public/ # PWA assets, manifest, SW, runtime locale bundles
├── tests/
-│ ├── unit/ # Vitest unit tests (the 8543+ total also includes components/ and packages/*/tests; tests/e2e is excluded)
+│ ├── unit/ # Vitest unit tests (the 8544+ total also includes components/ and packages/*/tests; tests/e2e is excluded)
│ └── e2e/ # Playwright
├── docs/ # Canonical product/engineering documentation + ADRs
├── src-tauri/ # Tauri v2 desktop shell / Rust
@@ -1031,7 +1031,7 @@ Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendo
Current source-synchronized README metrics:
-- **8543+ unit tests** across **636 test files**
+- **8544+ unit tests** across **637 test files**
- i18n: **2947 keys × 19 locales**
CI remains authoritative for actual pass/fail and live coverage.
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
new file mode 100644
index 000000000..208378e05
--- /dev/null
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -0,0 +1,75 @@
+# GitHub Pages deployment-record reconciliation
+
+Owner: [#1028](https://github.com/qnbs/WorldScript-Studio/issues/1028).
+This procedure concerns GitHub deployment **records** for `github-pages` only.
+Vercel/provider objects and all other environments are outside its authority.
+
+The workflow remains manually disabled. Merging this implementation neither enables it
+nor admits a destructive operator run. Manual dispatch has no deletion input and always
+runs read-only. A local read-only inventory needs no workflow enablement:
+
+```sh
+node scripts/prune-pages-deployments.mjs --dry-run
+node --test tests/unit/tooling/prunePagesDeployments.test.mjs
+```
+
+## Retention and ownership
+
+Keep the newest three Pages records, ordered by `created_at` descending then numeric ID
+descending. Also retain the current and previous successful publication by successful
+status completion order, every explicit rollback/evidence hold, unknown ownership and
+unsafe status. Current/rollback protection can add at most two records to the normal
+floor; unknown metadata and evidence holds need owner disposition rather than expiry.
+
+Positive ownership requires exact environment/ref/task, a full commit SHA, the GitHub
+Actions app identity, canonical Pages URL and an own-repository Actions job log URL.
+Before candidate mutation, fetch that run and job: exact `CI / CD` workflow path,
+successful completed `main` push, own head repository, matching SHA and successful
+Pages deployment job/step. A PR, fork, tag, manual CI, cancelled or failed run supplies
+no deletion authority. Records without the app identity are preserved even when their
+environment happens to be named `github-pages`.
+
+The six IDs in `.github/pages-pruning-holds.json` are the complete observed Pages
+inventory on 2026-10-12. They preserve the existing incident evidence pending #723/#1028
+owner disposition; they are not an age grace period for future records. Three records
+are currently surplus to the normal newest-three floor. No historical non-execution or
+hold release is inferred from the terminal cancelled Actions run. Replace the evidence
+list with specifically retained IDs only after that disposition; rollback holds are
+independent. Such a change is reviewed on main before it acquires writer authority.
+
+## Trigger and mutation boundary
+
+Daily 03:00 UTC reconciliation includes the existing Sunday backlog pass. There is no
+`workflow_run` trigger: this avoids privileged follow-up jobs for PR/fork/failed/manual
+CI and separates event completion from publication proof. Only main schedule runs can
+write; main dispatch is dry-run. The executing workflow/ref/repository are checked
+again in the module, checkout uses the immutable event SHA without persisted credentials,
+and a changed main SHA invalidates the hold/executable snapshot before each mutation.
+Product CI, required statuses, CodeQL, signing and reviewer governance are unchanged.
+
+One concurrency group serializes pruning invocations; at most ten records are attempted.
+Every inactivate and delete operation requires a new complete inventory/status read and
+unchanged identity, current publication, protection and status evidence. A failed or
+ambiguous inactivation never reaches deletion. `auto_inactive: false` avoids affecting
+other records. GET retries are bounded at three and two seconds maximum delay; writes
+are never blindly retried. API failures stop the batch, fail the job and report confirmed
+partial outcomes with sanitized reason codes. A later pass must re-observe uncertain writes.
+
+GitHub does not offer conditional inactivation/deletion tied to a deployment-status
+version. Revalidation detects observed promotion/publication races; it cannot provide a
+transaction across the final GET and POST. GitHub refuses DELETE of an active record.
+An independent exact-delta review must assess this residual API boundary before merge;
+external status writers must not be treated as serialized by the pruning concurrency group.
+
+## Owner reactivation sequence
+
+1. Merge only after exact-head full CI/security/signature/governance success and substantive
+ independent review; prove resulting-main CI/CodeQL and Production separately.
+2. Record a current, zero-write dry-run; reconcile the evidence holds with their owners.
+3. Obtain a separate explicit owner decision to enable the workflow. This PR/CLI does not
+ perform that action. Observe the first admitted scheduled pass and assert counts,
+ protected IDs and unchanged non-Pages inventory.
+
+Until those steps are complete, implementation/review/operational verification remain
+separate states. Trigger-count measurements belong to #550/QNB-41; no monetary or CPU
+savings are inferred from workflow-start counts alone.
diff --git a/scripts/prune-pages-deployments.mjs b/scripts/prune-pages-deployments.mjs
new file mode 100644
index 000000000..c64d54b93
--- /dev/null
+++ b/scripts/prune-pages-deployments.mjs
@@ -0,0 +1,454 @@
+import { execFileSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+export const REPOSITORY = 'qnbs/WorldScript-Studio';
+const ROOT = `/repos/${REPOSITORY}`;
+const SITE = 'https://qnbs.github.io/WorldScript-Studio/';
+const WORKFLOW = `${REPOSITORY}/.github/workflows/prune-deployments.yml@refs/heads/main`;
+const MAX_BATCH = 10;
+const validId = (id) => Number.isSafeInteger(id) && id > 0;
+const timestamp = (value) => {
+ if (
+ typeof value !== 'string' ||
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
+ )
+ return false;
+ const parsed = Date.parse(value);
+ const canonical = value.length === 20 ? value.replace('Z', '.000Z') : value;
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === canonical;
+};
+const rank = (a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id;
+const refuse = (reason) => {
+ throw Object.assign(new Error(reason), { reason });
+};
+
+export function admission({ repository, ref, sha, workflowRef, eventName }) {
+ if (
+ repository !== REPOSITORY ||
+ ref !== 'refs/heads/main' ||
+ workflowRef !== WORKFLOW ||
+ !/^[a-f0-9]{40}$/.test(sha ?? '')
+ )
+ return 'refused';
+ if (eventName === 'schedule') return 'delete';
+ if (eventName === 'workflow_dispatch') return 'dry-run';
+ return 'refused';
+}
+
+export function validateHolds(holds) {
+ if (
+ !holds ||
+ Object.keys(holds).sort().join(',') !== 'evidence,rollback' ||
+ !['evidence', 'rollback'].every((key) => Array.isArray(holds[key]) && holds[key].every(validId))
+ )
+ refuse('invalid-holds');
+ return new Set([...holds.evidence, ...holds.rollback]);
+}
+
+function owned(record) {
+ return (
+ record.environment === 'github-pages' &&
+ record.ref === 'main' &&
+ record.task === 'deploy' &&
+ /^[a-f0-9]{40}$/.test(record.sha ?? '') &&
+ record.transient_environment === false &&
+ record.production_environment === false &&
+ record.performed_via_github_app?.id === 15368 &&
+ record.performed_via_github_app?.slug === 'github-actions'
+ );
+}
+
+function jobIdentity(status) {
+ const match =
+ /^https:\/\/github\.com\/qnbs\/WorldScript-Studio\/actions\/runs\/(\d+)\/job\/(\d+)$/.exec(
+ status?.log_url ?? '',
+ );
+ return match?.slice(1).map(Number).every(validId)
+ ? { run: Number(match[1]), job: Number(match[2]) }
+ : null;
+}
+
+export function planPruning(records, holds, complete = true) {
+ if (!complete || !Array.isArray(records)) refuse('incomplete-inventory');
+ const held = validateHolds(holds);
+ const ids = new Set();
+ const statusIds = new Set();
+ for (const record of records) {
+ if (!validId(record.id) || ids.has(record.id) || !timestamp(record.created_at))
+ refuse('invalid-inventory');
+ ids.add(record.id);
+ if (!Array.isArray(record.statuses)) refuse('incomplete-statuses');
+ for (const status of record.statuses) {
+ if (!validId(status.id) || statusIds.has(status.id) || !timestamp(status.created_at))
+ refuse('invalid-statuses');
+ statusIds.add(status.id);
+ }
+ }
+ const pages = records.filter((record) => record.environment === 'github-pages').sort(rank);
+ const latest = (record) => [...record.statuses].sort(rank)[0];
+ const successful = pages
+ .flatMap((record) => {
+ const status = [...record.statuses].filter((item) => item.state === 'success').sort(rank)[0];
+ return status ? [{ record, status }] : [];
+ })
+ .sort((a, b) => rank(a.status, b.status));
+ // QNBS-v3: Pages leaves historical records at success; successful completion order protects the current and previous publication, independently of creation order.
+ const current = successful[0];
+ const rollback = successful[1];
+ const currentProved =
+ current &&
+ owned(current.record) &&
+ latest(current.record)?.id === current.status.id &&
+ current.status.environment === 'github-pages' &&
+ current.status.environment_url === SITE &&
+ jobIdentity(current.status);
+ const unsettled = pages.some(
+ (record) => !['success', 'inactive', 'failure', 'error'].includes(latest(record)?.state),
+ );
+ const protectedRecords = [];
+ const candidates = [];
+ for (const record of records) {
+ const status = latest(record);
+ const success = successful.find((item) => item.record.id === record.id)?.status;
+ const reasons = [];
+ if (record.environment !== 'github-pages') reasons.push('other-environment');
+ if (pages.slice(0, 3).some((item) => item.id === record.id)) reasons.push('newest-three');
+ if ([current?.record.id, rollback?.record.id].includes(record.id))
+ reasons.push('current-or-rollback');
+ if (held.has(record.id)) reasons.push('explicit-hold');
+ if (
+ !owned(record) ||
+ !success ||
+ success.environment !== 'github-pages' ||
+ status?.environment !== 'github-pages' ||
+ success.environment_url !== SITE ||
+ !jobIdentity(success)
+ )
+ reasons.push('unknown-ownership');
+ if (!['success', 'inactive'].includes(status?.state)) reasons.push('unsafe-status');
+ if (reasons.length) protectedRecords.push({ id: record.id, reasons });
+ else candidates.push(record);
+ }
+ const blocked = pages.length > 3 && (!currentProved || unsettled);
+ return {
+ current: current?.record.id ?? null,
+ protectedRecords,
+ candidates: candidates.sort(rank),
+ blocked,
+ };
+}
+
+async function read(github, route, parameters, pause) {
+ for (let attempt = 0; ; attempt++) {
+ try {
+ return await github.request(`GET ${route}`, parameters);
+ } catch (error) {
+ const retryAfter = Number(error.response?.headers?.['retry-after']);
+ const delay = error.status === 429 ? retryAfter * 1000 : 250 * 2 ** attempt;
+ if (
+ attempt === 2 ||
+ ![429, 500, 502, 503, 504].includes(error.status) ||
+ !Number.isFinite(delay) ||
+ delay > 2000 ||
+ delay < 0
+ )
+ throw error;
+ await pause(delay);
+ }
+ }
+}
+
+async function collect(github, route, pause, maxPages = 20) {
+ const all = [];
+ for (let page = 1; page <= maxPages; page++) {
+ const response = await read(github, route, { per_page: 100, page }, pause);
+ if (!Array.isArray(response.data) || response.data.length > 100) refuse('invalid-page');
+ all.push(...response.data);
+ const next = /<([^>]+)>;\s*rel="next"/.exec(response.headers?.link ?? '');
+ if (!next) {
+ if (response.data.length < 100) return all;
+ continue;
+ }
+ const url = new URL(next[1]);
+ if (
+ !response.data.length ||
+ url.origin !== 'https://api.github.com' ||
+ url.pathname !== route.split('?')[0] ||
+ url.searchParams.get('page') !== String(page + 1) ||
+ url.searchParams.get('per_page') !== '100'
+ )
+ refuse('invalid-pagination-link');
+ }
+ refuse('pagination-limit');
+}
+
+async function inventory(github, pause) {
+ const response = await read(github, `${ROOT}/pages`, {}, pause);
+ if (response.data.build_type !== 'workflow' || response.data.html_url !== SITE)
+ refuse('unknown-pages-site');
+ const records = await collect(github, `${ROOT}/deployments?environment=github-pages`, pause);
+ for (const record of records) {
+ if (!validId(record.id)) refuse('invalid-inventory');
+ record.statuses = await collect(github, `${ROOT}/deployments/${record.id}/statuses`, pause, 5);
+ }
+ return records;
+}
+
+async function provePublication(github, record, pause) {
+ const success = [...record.statuses].filter((status) => status.state === 'success').sort(rank)[0];
+ const identity = jobIdentity(success);
+ if (!identity) refuse('unknown-publication');
+ const { data: run } = await read(github, `${ROOT}/actions/runs/${identity.run}`, {}, pause);
+ const { data: job } = await read(github, `${ROOT}/actions/jobs/${identity.job}`, {}, pause);
+ if (
+ run.id !== identity.run ||
+ run.repository?.full_name !== REPOSITORY ||
+ run.head_repository?.full_name !== REPOSITORY ||
+ run.name !== 'CI / CD' ||
+ run.path !== '.github/workflows/ci.yml' ||
+ run.event !== 'push' ||
+ run.head_branch !== 'main' ||
+ run.head_sha !== record.sha ||
+ run.status !== 'completed' ||
+ run.conclusion !== 'success' ||
+ job.id !== identity.job ||
+ job.run_id !== run.id ||
+ job.name !== '🚀 Deploy to GitHub Pages' ||
+ job.status !== 'completed' ||
+ job.conclusion !== 'success' ||
+ !job.steps?.some(
+ (step) => step.name === 'Deploy to GitHub Pages' && step.conclusion === 'success',
+ )
+ )
+ refuse('unknown-publication');
+}
+
+const fingerprint = (record) =>
+ JSON.stringify({
+ id: record.id,
+ sha: record.sha,
+ ref: record.ref,
+ environment: record.environment,
+ created_at: record.created_at,
+ task: record.task,
+ transient_environment: record.transient_environment,
+ production_environment: record.production_environment,
+ app: record.performed_via_github_app,
+ statuses: [...record.statuses].sort(rank),
+ });
+
+export async function reconcilePages({
+ github,
+ authority,
+ holds,
+ dryRun = false,
+ pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+}) {
+ const admitted = admission(authority);
+ const result = {
+ trigger: authority.eventName,
+ mode: dryRun ? 'dry-run' : admitted,
+ queried: 0,
+ classified: 0,
+ protected: 0,
+ candidate: 0,
+ attempted: 0,
+ inactivated: 0,
+ deleted: 0,
+ failed: 0,
+ skipped: 0,
+ reasons: {},
+ current: null,
+ manifest: [],
+ followUpRequired: false,
+ };
+ let considered = 0;
+ const reason = (key, count = 1) => {
+ result.reasons[key] = (result.reasons[key] ?? 0) + count;
+ };
+ try {
+ if (admitted === 'refused') refuse('untrusted-trigger');
+ const records = await inventory(github, pause);
+ result.queried = records.length;
+ const plan = planPruning(records, holds);
+ result.classified = records.length;
+ result.protected = plan.protectedRecords.length;
+ result.candidate = plan.candidates.length;
+ result.current = plan.current;
+ result.manifest = plan.protectedRecords
+ .map((record) => ({ ...record, disposition: 'protected' }))
+ .concat(
+ plan.candidates.map((record) => ({ id: record.id, disposition: 'candidate', reasons: [] })),
+ );
+ for (const record of plan.protectedRecords) for (const key of record.reasons) reason(key);
+ if (plan.blocked) refuse('unproved-current-or-unsettled');
+ if (plan.current)
+ await provePublication(
+ github,
+ records.find((record) => record.id === plan.current),
+ pause,
+ );
+ for (const candidate of plan.candidates.slice(0, MAX_BATCH))
+ await provePublication(github, candidate, pause);
+ if (result.mode === 'dry-run') {
+ result.skipped = plan.candidates.length;
+ if (result.skipped) reason('dry-run', result.skipped);
+ result.followUpRequired = records.length > 3;
+ return result;
+ }
+ // QNBS-v3: holds are immutable at the executing main SHA; a new main commit invalidates admission before each mutation.
+ const revalidate = async (candidate, expected) => {
+ const fresh = await inventory(github, pause);
+ const next = planPruning(fresh, holds);
+ const record = fresh.find((item) => item.id === candidate.id);
+ if (next.blocked || next.current !== plan.current) refuse('current-moved');
+ if (
+ !record ||
+ !next.candidates.some((item) => item.id === candidate.id) ||
+ fingerprint(record) !== expected
+ )
+ return null;
+ await provePublication(
+ github,
+ fresh.find((item) => item.id === next.current),
+ pause,
+ );
+ await provePublication(github, record, pause);
+ const { data: branch } = await read(github, `${ROOT}/branches/main`, {}, pause);
+ if (branch.commit?.sha !== authority.sha) refuse('main-moved');
+ return record;
+ };
+ for (const candidate of plan.candidates.slice(0, MAX_BATCH)) {
+ considered++;
+ let fresh = await revalidate(candidate, fingerprint(candidate));
+ if (!fresh) {
+ result.skipped++;
+ reason('stale-candidate');
+ continue;
+ }
+ result.attempted++;
+ const latest = [...fresh.statuses].sort(rank)[0];
+ if (latest.state !== 'inactive') {
+ const { data: inactive } = await github.request(
+ `POST ${ROOT}/deployments/${candidate.id}/statuses`,
+ {
+ state: 'inactive',
+ environment: 'github-pages',
+ environment_url: SITE,
+ log_url: latest.log_url,
+ auto_inactive: false,
+ },
+ );
+ if (
+ !validId(inactive.id) ||
+ inactive.state !== 'inactive' ||
+ !timestamp(inactive.created_at)
+ )
+ refuse('invalid-inactivation-response');
+ result.inactivated++;
+ fresh.statuses.push(inactive);
+ fresh = await revalidate(candidate, fingerprint(fresh));
+ if (!fresh) {
+ result.skipped++;
+ reason('status-moved');
+ continue;
+ }
+ }
+ // QNBS-v3: failed/ambiguous POST never reaches DELETE; writes are never retried against an assumed inventory.
+ await github.request(`DELETE ${ROOT}/deployments/${candidate.id}`);
+ result.deleted++;
+ }
+ result.followUpRequired = records.length - result.deleted > 3;
+ if (plan.candidates.length > MAX_BATCH)
+ reason('batch-limit', plan.candidates.length - MAX_BATCH);
+ } catch (error) {
+ result.failed++;
+ result.followUpRequired = true;
+ reason(
+ error.reason ??
+ ([404, 409, 429, 500, 502, 503, 504].includes(error.status)
+ ? `api-${error.status}`
+ : 'api-or-inventory-error'),
+ );
+ if (result.candidate > considered) reason('aborted-batch', result.candidate - considered);
+ }
+ result.skipped += result.candidate - considered;
+ return result;
+}
+
+export async function runAction({ github, context, core, holds, workflowRef }) {
+ const result = await reconcilePages({
+ github,
+ holds,
+ authority: {
+ repository: `${context.repo.owner}/${context.repo.repo}`,
+ eventName: context.eventName,
+ ref: context.ref,
+ sha: context.sha,
+ workflowRef,
+ },
+ });
+ const { manifest, ...summary } = result;
+ core.info(JSON.stringify(summary));
+ await core.summary
+ .addHeading('GitHub Pages record reconciliation')
+ .addCodeBlock(JSON.stringify(summary, null, 2), 'json')
+ .addCodeBlock(JSON.stringify(manifest, null, 2), 'json')
+ .write();
+ if (result.failed)
+ core.setFailed('Pages reconciliation failed closed; inspect sanitized counts and reasons.');
+}
+
+// QNBS-v3: the local CLI exposes GET only and forces dry-run; credentials never appear in output or request errors.
+async function localDryRun() {
+ if (process.argv.length !== 3 || process.argv[2] !== '--dry-run') refuse('invalid-cli-mode');
+ const token = execFileSync('gh', ['auth', 'token'], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ const github = {
+ request: async (route, params = {}) => {
+ if (!route.startsWith('GET ')) refuse('local-writes-forbidden');
+ const url = new URL(`https://api.github.com${route.slice(4)}`);
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value));
+ const response = await fetch(url, {
+ headers: {
+ authorization: `Bearer ${token}`,
+ accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!response.ok)
+ throw Object.assign(new Error('GitHub read failed'), {
+ status: response.status,
+ response: { headers: Object.fromEntries(response.headers) },
+ });
+ return { data: await response.json(), headers: Object.fromEntries(response.headers) };
+ },
+ };
+ const result = await reconcilePages({
+ github,
+ holds: JSON.parse(
+ readFileSync(new URL('../.github/pages-pruning-holds.json', import.meta.url), 'utf8'),
+ ),
+ dryRun: true,
+ authority: {
+ repository: REPOSITORY,
+ ref: 'refs/heads/main',
+ sha: '0'.repeat(40),
+ workflowRef: WORKFLOW,
+ eventName: 'workflow_dispatch',
+ },
+ });
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+ if (result.failed) process.exitCode = 1;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ localDryRun().catch(() => {
+ process.stderr.write('Read-only Pages inventory failed.\n');
+ process.exitCode = 1;
+ });
+}
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
new file mode 100644
index 000000000..b1d67988b
--- /dev/null
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -0,0 +1,517 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import {
+ admission,
+ planPruning,
+ REPOSITORY,
+ reconcilePages,
+ runAction,
+} from '../../../scripts/prune-pages-deployments.mjs';
+
+const ROOT = `/repos/${REPOSITORY}`;
+const SITE = 'https://qnbs.github.io/WorldScript-Studio/';
+const date = (id) => new Date(Date.UTC(2026, 0, 1) + id * 1000).toISOString();
+const authority = {
+ repository: REPOSITORY,
+ ref: 'refs/heads/main',
+ sha: 'a'.repeat(40),
+ workflowRef: `${REPOSITORY}/.github/workflows/prune-deployments.yml@refs/heads/main`,
+ eventName: 'schedule',
+};
+const holds = { rollback: [], evidence: [] };
+function record(id) {
+ return {
+ id,
+ created_at: date(id),
+ sha: id.toString(16).padStart(40, '0'),
+ environment: 'github-pages',
+ ref: 'main',
+ task: 'deploy',
+ transient_environment: false,
+ production_environment: false,
+ performed_via_github_app: { id: 15368, slug: 'github-actions' },
+ statuses: [
+ {
+ id: 100 + id,
+ created_at: date(id),
+ state: 'success',
+ environment: 'github-pages',
+ environment_url: SITE,
+ log_url: `https://github.com/${REPOSITORY}/actions/runs/${1000 + id}/job/${2000 + id}`,
+ },
+ ],
+ };
+}
+const records = (count) => Array.from({ length: count }, (_, index) => record(index + 1));
+const publication = (id) => ({
+ id: 1000 + id,
+ repository: { full_name: REPOSITORY },
+ head_repository: { full_name: REPOSITORY },
+ name: 'CI / CD',
+ path: '.github/workflows/ci.yml',
+ event: 'push',
+ head_branch: 'main',
+ head_sha: record(id).sha,
+ status: 'completed',
+ conclusion: 'success',
+});
+const apiError = (status, retryAfter) =>
+ Object.assign(new Error('PRIVATE provider detail'), {
+ status,
+ response: { headers: { 'retry-after': retryAfter } },
+ });
+function client(initial, intercept = () => undefined) {
+ const state = structuredClone(initial);
+ const calls = [];
+ let statusId = 10000;
+ return {
+ state,
+ calls,
+ request: async (route, parameters = {}) => {
+ calls.push({ route, parameters });
+ const override = await intercept(route, parameters, state, calls);
+ if (override) return override;
+ const response = (data) => ({ data: structuredClone(data), headers: {} });
+ if (route === `GET ${ROOT}/pages`)
+ return response({ build_type: 'workflow', html_url: SITE });
+ if (route === `GET ${ROOT}/branches/main`)
+ return response({ commit: { sha: authority.sha } });
+ if (route === `GET ${ROOT}/deployments?environment=github-pages`)
+ return response(
+ state
+ .slice((parameters.page - 1) * 100, parameters.page * 100)
+ .map(({ statuses, ...item }) => item),
+ );
+ const deployment = /\/deployments\/(\d+)(\/statuses)?$/.exec(route);
+ if (deployment) {
+ const item = state.find((value) => value.id === Number(deployment[1]));
+ if (!item) throw apiError(404);
+ if (route.startsWith('GET '))
+ return response(item.statuses.slice((parameters.page - 1) * 100, parameters.page * 100));
+ if (route.startsWith('POST ')) {
+ assert.equal(parameters.auto_inactive, false);
+ const status = { ...parameters, id: statusId++, created_at: date(statusId) };
+ item.statuses.push(status);
+ return response(status);
+ }
+ assert.equal([...item.statuses].sort((a, b) => b.id - a.id)[0].state, 'inactive');
+ state.splice(state.indexOf(item), 1);
+ return response(undefined);
+ }
+ const runId = /\/actions\/runs\/(\d+)$/.exec(route);
+ if (runId) {
+ const id = Number(runId[1]) - 1000;
+ return response(publication(id));
+ }
+ const jobId = /\/actions\/jobs\/(\d+)$/.exec(route);
+ if (jobId)
+ return response({
+ id: Number(jobId[1]),
+ run_id: Number(jobId[1]) - 1000,
+ name: '🚀 Deploy to GitHub Pages',
+ status: 'completed',
+ conclusion: 'success',
+ steps: [{ name: 'Deploy to GitHub Pages', conclusion: 'success' }],
+ });
+ throw new Error(`Unexpected fixture route ${route}`);
+ },
+ };
+}
+const writes = (github) => github.calls.filter((call) => !call.route.startsWith('GET '));
+const execute = (github, options = {}) =>
+ reconcilePages({ github, authority, holds, pause: async () => {}, ...options });
+
+for (const count of [0, 1, 2, 3, 4, 20])
+ test(`plans ${count} records with newest-three retention`, () => {
+ const plan = planPruning(records(count), holds);
+ assert.equal(plan.candidates.length, Math.max(0, count - 3));
+ assert.equal(plan.protectedRecords.length, Math.min(count, 3));
+ });
+
+test('ties rank by deployment ID regardless of inventory ordering', () => {
+ const input = records(5)
+ .reverse()
+ .map((item) => ({ ...item, created_at: date(1) }));
+ assert.deepEqual(
+ planPruning(input, holds).candidates.map((item) => item.id),
+ [2, 1],
+ );
+});
+
+test('protects current and prior successful publication outside newest three', () => {
+ const input = records(6);
+ input[0].statuses[0].created_at = date(90);
+ input[1].statuses[0].created_at = date(89);
+ const plan = planPruning(input, holds);
+ assert.equal(plan.current, 1);
+ assert.deepEqual(
+ plan.candidates.map((item) => item.id),
+ [3],
+ );
+});
+
+test('protects explicit rollback and evidence IDs', () => {
+ assert.deepEqual(
+ planPruning(records(6), { rollback: [2], evidence: [1] }).candidates.map((item) => item.id),
+ [3],
+ );
+});
+
+for (const environment of ['Production', 'Preview', 'vercel', 'unknown', '', undefined])
+ test(`preserves other environment ${environment}`, () => {
+ const input = records(5);
+ input[0].environment = environment;
+ assert.ok(
+ planPruning(input, holds)
+ .protectedRecords.find((item) => item.id === 1)
+ .reasons.includes('other-environment'),
+ );
+ });
+
+for (const change of [
+ { performed_via_github_app: null },
+ { performed_via_github_app: { id: 15368, slug: 'other' } },
+ { ref: 'feature' },
+ { task: 'other' },
+ { sha: 'unknown' },
+ { transient_environment: true },
+ { production_environment: true },
+])
+ test(`preserves unknown ownership ${JSON.stringify(change)}`, () => {
+ const input = records(5);
+ Object.assign(input[0], change);
+ assert.ok(!planPruning(input, holds).candidates.some((item) => item.id === 1));
+ });
+
+test('refuses malformed, duplicate or incomplete inventories and holds', () => {
+ assert.throws(() => planPruning(records(5), holds, false), /incomplete/);
+ assert.throws(() => planPruning([record(1), record(1)], holds), /invalid-inventory/);
+ assert.throws(
+ () => planPruning([{ ...record(1), created_at: 'invalid' }], holds),
+ /invalid-inventory/,
+ );
+ assert.throws(
+ () => planPruning([{ ...record(1), statuses: undefined }], holds),
+ /incomplete-statuses/,
+ );
+ assert.throws(() => planPruning(records(5), { rollback: [] }), /invalid-holds/);
+ assert.throws(() => planPruning(records(5), { rollback: [], evidence: ['1'] }), /invalid-holds/);
+});
+
+for (const state of ['queued', 'pending', 'waiting', 'in_progress', 'unknown'])
+ test(`refuses unsettled status ${state}`, () => {
+ const input = records(5);
+ input[0].statuses.push({ ...input[0].statuses[0], id: 999, state, created_at: date(99) });
+ assert.equal(planPruning(input, holds).blocked, true);
+ });
+
+for (const eventName of ['workflow_run', 'pull_request', 'pull_request_target', 'push', 'unknown'])
+ test(`rejects trigger ${eventName} with zero API writes`, async () => {
+ const github = client(records(5));
+ const result = await execute(github, { authority: { ...authority, eventName } });
+ assert.equal(result.failed, 1);
+ assert.equal(github.calls.length, 0);
+ });
+
+for (const change of [
+ { repository: 'fork/WorldScript-Studio' },
+ { ref: 'refs/tags/v1.30' },
+ { ref: 'refs/pull/1/merge' },
+ { sha: 'unknown' },
+ { workflowRef: 'other@refs/heads/main' },
+])
+ test(`rejects execution authority ${JSON.stringify(change)}`, () => {
+ assert.equal(admission({ ...authority, ...change }), 'refused');
+ });
+
+test('dispatch always performs a zero-write dry-run, regardless of caller payload', async () => {
+ const github = client(records(5));
+ const result = await execute(github, {
+ authority: { ...authority, eventName: 'workflow_dispatch', inputs: { delete: true } },
+ });
+ assert.equal(result.mode, 'dry-run');
+ assert.equal(result.candidate, 2);
+ assert.equal(result.failed, 0);
+ assert.deepEqual(writes(github), []);
+});
+
+test('inactivates then revalidates before deletion; repeated run is idempotent', async () => {
+ const github = client(records(5));
+ const first = await execute(github);
+ assert.equal(first.failed, 0);
+ assert.equal(first.deleted, 2);
+ assert.equal(first.inactivated, 2);
+ const second = await execute(github);
+ assert.equal(second.deleted, 0);
+ assert.equal(writes(github).length, 4);
+ const routes = github.calls.map((call) => call.route);
+ for (const id of [1, 2]) {
+ const start = routes.indexOf(`POST ${ROOT}/deployments/${id}/statuses`);
+ const end = routes.indexOf(`DELETE ${ROOT}/deployments/${id}`);
+ assert.ok(start < end);
+ assert.ok(routes.slice(start + 1, end).includes(`GET ${ROOT}/deployments/${id}/statuses`));
+ }
+});
+
+test('already inactive candidates require no POST', async () => {
+ const input = records(4);
+ input[0].statuses.push({
+ ...input[0].statuses[0],
+ id: 900,
+ created_at: date(900),
+ state: 'inactive',
+ });
+ const github = client(input);
+ const result = await execute(github);
+ assert.equal(result.deleted, 1);
+ assert.equal(writes(github).length, 1);
+});
+
+for (const status of [404, 409, 429, 500, 503])
+ test(`failed inactivation ${status} never reaches DELETE or retries writes`, async () => {
+ const github = client(records(4), (route) => {
+ if (route.startsWith('POST ')) throw apiError(status, '1');
+ });
+ const result = await execute(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.deleted, 0);
+ assert.deepEqual(
+ writes(github).map((call) => call.route),
+ [`POST ${ROOT}/deployments/1/statuses`],
+ );
+ assert.ok(!JSON.stringify(result).includes('PRIVATE'));
+ });
+
+for (const status of [404, 409, 429, 500, 503])
+ test(`failed DELETE ${status} is non-success and never blindly retried`, async () => {
+ const github = client(records(4), (route) => {
+ if (route.startsWith('DELETE ')) throw apiError(status);
+ });
+ const result = await execute(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.inactivated, 1);
+ assert.equal(result.deleted, 0);
+ assert.equal(writes(github).filter((call) => call.route.startsWith('DELETE ')).length, 1);
+ });
+
+test('bounded reads exhaust retries without writes', async () => {
+ const github = client(records(4), () => {
+ throw apiError(503);
+ });
+ assert.equal((await execute(github)).failed, 1);
+ assert.equal(github.calls.length, 3);
+ assert.deepEqual(writes(github), []);
+});
+
+test('bounded transient read retry succeeds; long 429 Retry-After fails closed', async () => {
+ const github = client(records(4), (_route, _parameters, _state, calls) => {
+ if (calls.length === 1) throw apiError(503);
+ });
+ assert.equal((await execute(github)).deleted, 1);
+ const limited = client(records(4), () => {
+ throw apiError(429, '60');
+ });
+ assert.equal((await execute(limited)).failed, 1);
+ assert.equal(limited.calls.length, 1);
+});
+
+test('truncated pagination cannot authorize writes', async () => {
+ const github = client(records(4), (route) =>
+ route.includes('?environment=') ? { data: records(100), headers: {} } : undefined,
+ );
+ const result = await execute(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['pagination-limit'], 1);
+ assert.deepEqual(writes(github), []);
+});
+
+test('a failed later inventory page is failure, never a partial clean result', async () => {
+ const github = client(records(4), (route, parameters) => {
+ if (!route.includes('?environment=')) return;
+ if (parameters.page === 2) throw apiError(404);
+ return {
+ data: [record(1)],
+ headers: {
+ link: `; rel="next"`,
+ },
+ };
+ });
+ assert.equal((await execute(github)).failed, 1);
+ assert.deepEqual(writes(github), []);
+});
+
+for (const change of [
+ { event: 'pull_request' },
+ { event: 'workflow_dispatch' },
+ { head_branch: 'v1.30' },
+ { head_repository: { full_name: 'fork/repo' } },
+ { conclusion: 'failure' },
+ { conclusion: 'cancelled' },
+ { path: '.github/workflows/other.yml' },
+])
+ test(`rejects publication evidence ${JSON.stringify(change)}`, async () => {
+ const github = client(records(4), (route) =>
+ route.includes('/actions/runs/')
+ ? {
+ data: { ...publication(Number(route.split('/').at(-1)) - 1000), ...change },
+ headers: {},
+ }
+ : undefined,
+ );
+ const result = await execute(github);
+ assert.equal(result.failed, 1);
+ assert.deepEqual(writes(github), []);
+ });
+
+test('main moving invalidates hold and executable authority before mutation', async () => {
+ const github = client(records(4), (route) =>
+ route.endsWith('/branches/main')
+ ? { data: { commit: { sha: 'b'.repeat(40) } }, headers: {} }
+ : undefined,
+ );
+ assert.equal((await execute(github)).reasons['main-moved'], 1);
+ assert.deepEqual(writes(github), []);
+});
+
+test('candidate SHA drift is skipped with zero writes', async () => {
+ let inventories = 0;
+ const github = client(records(4), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++inventories === 2) state[0].sha = 'b'.repeat(40);
+ });
+ const result = await execute(github);
+ assert.equal(result.skipped, 1);
+ assert.deepEqual(writes(github), []);
+});
+
+test('status promotion after inactivation prevents deletion', async () => {
+ let inventories = 0;
+ const github = client(records(4), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++inventories === 3)
+ state[0].statuses.push({ ...state[0].statuses[0], id: 99999, created_at: date(99999) });
+ });
+ const result = await execute(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.deleted, 0);
+ assert.equal(writes(github).length, 1);
+});
+
+test('mixed partial outcomes report confirmed deletion and subsequent failure', async () => {
+ const github = client(records(6), (route) => {
+ if (route === `POST ${ROOT}/deployments/2/statuses`) throw apiError(409);
+ });
+ const result = await execute(github);
+ assert.equal(result.deleted, 1);
+ assert.equal(result.failed, 1);
+ assert.equal(result.followUpRequired, true);
+ assert.ok(github.state.some((item) => item.id === 2));
+});
+
+test('caps each invocation at ten records and reports remaining backlog', async () => {
+ const github = client(records(20));
+ const result = await execute(github);
+ assert.equal(result.deleted, 10);
+ assert.equal(result.attempted, 10);
+ assert.equal(result.followUpRequired, true);
+ assert.equal(result.skipped, 7);
+ assert.equal(result.reasons['batch-limit'], 7);
+});
+
+test('real workflow admits only main schedule and read-only dispatch with serialized writer', () => {
+ const source = readFileSync(
+ new URL('../../../.github/workflows/prune-deployments.yml', import.meta.url),
+ 'utf8',
+ );
+ assert.ok(!source.includes('workflow_run:'));
+ assert.ok(!source.includes('inputs:'));
+ assert.ok(source.includes("cron: '0 3 * * *'"));
+ assert.ok(source.includes('cancel-in-progress: false'));
+ assert.ok(source.includes("github.repository == 'qnbs/WorldScript-Studio'"));
+ assert.ok(source.includes("github.ref == 'refs/heads/main'"));
+ assert.ok(source.includes(`ref: \${{ github.sha }}`));
+ assert.ok(source.includes('persist-credentials: false'));
+ assert.ok(source.includes('retries: 0'));
+});
+
+test('complete multi-page inventory makes stable progress', async () => {
+ const github = client(records(5), (route, parameters, state) => {
+ if (!route.includes('?environment=')) return;
+ return parameters.page === 1
+ ? {
+ data: structuredClone(state.slice(0, 2)),
+ headers: {
+ link: `; rel="next"`,
+ },
+ }
+ : { data: structuredClone(state.slice(2)), headers: {} };
+ });
+ assert.equal((await execute(github)).deleted, 2);
+});
+
+test('contradictory status environment is unknown ownership and preserved', () => {
+ const input = records(5);
+ input[0].statuses[0].environment = 'Production';
+ assert.ok(!planPruning(input, holds).candidates.some((item) => item.id === 1));
+});
+
+test('Actions summary reports a partial failure without provider or author metadata', async () => {
+ const blocks = [];
+ const logs = [];
+ const failures = [];
+ const summary = {
+ addHeading() {
+ return this;
+ },
+ addCodeBlock(value) {
+ blocks.push(JSON.parse(value));
+ return this;
+ },
+ async write() {},
+ };
+ const github = client(records(4), (route) => {
+ if (route.startsWith('POST ')) throw apiError(409);
+ });
+ await runAction({
+ github,
+ holds,
+ workflowRef: authority.workflowRef,
+ core: {
+ summary,
+ info: (value) => logs.push(value),
+ setFailed: (value) => failures.push(value),
+ },
+ context: {
+ repo: { owner: 'qnbs', repo: 'WorldScript-Studio' },
+ eventName: 'schedule',
+ ref: authority.ref,
+ sha: authority.sha,
+ },
+ });
+ assert.equal(blocks[0].attempted, 1);
+ assert.equal(blocks[0].deleted, 0);
+ assert.equal(blocks[0].failed, 1);
+ assert.equal(blocks[0].followUpRequired, true);
+ assert.equal(failures.length, 1);
+ assert.ok(!JSON.stringify([blocks, logs, failures]).includes('PRIVATE'));
+ assert.ok(!JSON.stringify(blocks).includes('log_url'));
+});
+
+test('exactly full terminal page is probed for completeness in zero-write dry-run', async () => {
+ const github = client(records(100));
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 0);
+ assert.equal(result.queried, 100);
+ assert.ok(
+ github.calls.some((call) => call.route.includes('?environment=') && call.parameters.page === 2),
+ );
+ assert.deepEqual(writes(github), []);
+});
+
+test('refuses normalized malformed dates and cross-record status ID collisions', () => {
+ for (const created_at of ['2026-02-31T00:00:00Z', '2026-01-01T24:00:00Z', 'Jan 1 2026Z']) {
+ assert.throws(() => planPruning([{ ...record(1), created_at }], holds), /invalid-inventory/);
+ }
+ const input = records(4);
+ input[0].statuses[0].id = input[1].statuses[0].id;
+ assert.throws(() => planPruning(input, holds), /invalid-statuses/);
+});
diff --git a/tests/unit/tooling/prunePagesDeployments.test.ts b/tests/unit/tooling/prunePagesDeployments.test.ts
new file mode 100644
index 000000000..416a40085
--- /dev/null
+++ b/tests/unit/tooling/prunePagesDeployments.test.ts
@@ -0,0 +1,14 @@
+// @vitest-environment node
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { expect, it } from 'vitest';
+
+// QNBS-v3: the lightweight Node suite runs standalone locally and through required Vitest CI without importing its node:test runner into Vitest.
+it('passes the standalone Pages pruning authority, retention, API and race regressions', () => {
+ const path = fileURLToPath(new URL('./prunePagesDeployments.test.mjs', import.meta.url));
+ const output = execFileSync(process.execPath, ['--test', '--test-reporter=tap', path], {
+ encoding: 'utf8',
+ timeout: 30000,
+ });
+ expect(output).toContain('# fail 0');
+});
From a06078dd982e05ba60146ba21027b7f58a2385f6 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Mon, 12 Oct 2026 23:28:41 +0200
Subject: [PATCH 02/13] docs(changelog): link Pages pruning review
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1579e0f0f..12fa7b974 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
plus current publication, rollback and evidence holds. Daily bounded reconciliation
replaces broad CI-completion triggers; manual dispatch is read-only. Failed inactivation
stops deletion, and every mutation revalidates inventory and trusted-main authority.
- The workflow remains disabled pending separate owner admission. Issue #1028.
+ The workflow remains disabled pending separate owner admission. Issue #1028. PR #1029.
- **R-15 Gate 4E evidence:** coordination-resource preservation checks inspect links without following them and reject nonempty files, FIFOs, sockets, and observation errors. Fixture-scoped regressions cover missing/empty resources and link variants. The ordinary-operation barrier is unchanged. Clean successor to PR #1021. PR #1026.
From d1d3c9e434af4a99003c5825251cd073f6c58efb Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 01:05:42 +0200
Subject: [PATCH 03/13] ci(pages): prove rollback and publication provenance
before pruning
Resolve the independent-review findings on the Pages record reconciler.
Rollback slot: the current and previous publication are now proven against
the API before any candidate is classified. An unproved newer success is
protected and no longer consumes the rollback slot; no provable rollback
stops the run instead of exposing the prior known-good record.
Provenance: log_url is caller-written, so a record is deletable only when
the deployment and its newest success status were authored by the actor of
the referenced run and not by github-actions[bot]. This holds for all six
live records. A candidate that cannot be proven is protected and skipped
rather than failing the whole run.
Writer fence: GitHub has no compare-and-delete. A stale snapshot is shown
to stay safe against genuine publications, and in-repository record
writers are pinned by a test. Hold changes and external status writers
remain unfenced, so destructive writes stay closed in code and the
workflow token holds deployments: read only.
Scale: revalidation re-reads the list plus the candidate and both slots
instead of every history. A run is budgeted at 450 requests with a live
rate-limit reserve and a graceful stop. /repositories/{id} next links are
accepted for the verified repository id only.
Complexity: split into evidence, policy, github, provenance and reconcile
modules with single-purpose predicates.
---
.github/workflows/prune-deployments.yml | 4 +-
CHANGELOG.md | 10 +-
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 179 +++--
scripts/pages-pruning/evidence.mjs | 57 ++
scripts/pages-pruning/github.mjs | 145 ++++
scripts/pages-pruning/policy.mjs | 192 +++++
scripts/pages-pruning/provenance.mjs | 129 +++
scripts/pages-pruning/reconcile.mjs | 231 ++++++
scripts/prune-pages-deployments.mjs | 416 +---------
.../tooling/prunePagesDeployments.test.mjs | 737 ++++++++++++------
.../tooling/support/pagesPruningFixture.mjs | 162 ++++
11 files changed, 1606 insertions(+), 656 deletions(-)
create mode 100644 scripts/pages-pruning/evidence.mjs
create mode 100644 scripts/pages-pruning/github.mjs
create mode 100644 scripts/pages-pruning/policy.mjs
create mode 100644 scripts/pages-pruning/provenance.mjs
create mode 100644 scripts/pages-pruning/reconcile.mjs
create mode 100644 tests/unit/tooling/support/pagesPruningFixture.mjs
diff --git a/.github/workflows/prune-deployments.yml b/.github/workflows/prune-deployments.yml
index 7cd72af4c..633b6d459 100644
--- a/.github/workflows/prune-deployments.yml
+++ b/.github/workflows/prune-deployments.yml
@@ -6,7 +6,7 @@ on:
- cron: '0 3 * * *'
workflow_dispatch:
-# QNBS-v3: top-level read-only; deployments:write scoped to job (CodeQL Token-Permissions).
+# QNBS-v3: top-level read-only; the job holds no write scope while the destructive gate in scripts/pages-pruning/policy.mjs is closed (CodeQL Token-Permissions).
permissions:
contents: read
@@ -24,7 +24,7 @@ jobs:
contents: read
actions: read
pages: read
- deployments: write
+ deployments: read
steps:
- name: Check out immutable trusted main execution tree
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12fa7b974..083fa135a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,9 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **CI / Pages:** reconcile owned deployment records safely, keeping the newest three
plus current publication, rollback and evidence holds. Daily bounded reconciliation
- replaces broad CI-completion triggers; manual dispatch is read-only. Failed inactivation
- stops deletion, and every mutation revalidates inventory and trusted-main authority.
- The workflow remains disabled pending separate owner admission. Issue #1028. PR #1029.
+ replaces broad CI-completion triggers; manual dispatch is read-only. Both publication
+ slots are proven against the triggering run's actor before any candidate is classified,
+ so a forged status can neither consume the rollback slot nor vouch for a record.
+ Failed inactivation stops deletion, and every mutation revalidates the candidate, both
+ slots and trusted-main authority within a measured per-run request budget.
+ Destructive writes stay closed in code and in the workflow token until the owner admits a
+ writer-fence protocol, so even an enabled schedule only reports. Issue #1028. PR #1029.
- **R-15 Gate 4E evidence:** coordination-resource preservation checks inspect links without following them and reject nonempty files, FIFOs, sockets, and observation errors. Fixture-scoped regressions cover missing/empty resources and link variants. The ordinary-operation barrier is unchanged. Clean successor to PR #1021. PR #1026.
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index 208378e05..fe76be4de 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -4,9 +4,13 @@ Owner: [#1028](https://github.com/qnbs/WorldScript-Studio/issues/1028).
This procedure concerns GitHub deployment **records** for `github-pages` only.
Vercel/provider objects and all other environments are outside its authority.
-The workflow remains manually disabled. Merging this implementation neither enables it
-nor admits a destructive operator run. Manual dispatch has no deletion input and always
-runs read-only. A local read-only inventory needs no workflow enablement:
+**Status: implemented, not enabled.** The destructive path is closed twice, independently:
+`DESTRUCTIVE_WRITES_ADMITTED` in `scripts/pages-pruning/policy.mjs` is `false`, and the workflow
+token holds `deployments: read` only. A scheduled run, even if the workflow is switched on in the
+Actions UI, therefore performs the full read-only reconciliation, reports `destructive-gate-closed`,
+and cannot write. Opening the gate needs both reviewed changes plus the owner decision below; a test
+pins that the permission and the constant move together. Manual dispatch has no deletion input and
+always runs read-only. A local read-only inventory needs no workflow enablement:
```sh
node scripts/prune-pages-deployments.mjs --dry-run
@@ -16,60 +20,135 @@ node --test tests/unit/tooling/prunePagesDeployments.test.mjs
## Retention and ownership
Keep the newest three Pages records, ordered by `created_at` descending then numeric ID
-descending. Also retain the current and previous successful publication by successful
-status completion order, every explicit rollback/evidence hold, unknown ownership and
-unsafe status. Current/rollback protection can add at most two records to the normal
-floor; unknown metadata and evidence holds need owner disposition rather than expiry.
-
-Positive ownership requires exact environment/ref/task, a full commit SHA, the GitHub
-Actions app identity, canonical Pages URL and an own-repository Actions job log URL.
-Before candidate mutation, fetch that run and job: exact `CI / CD` workflow path,
-successful completed `main` push, own head repository, matching SHA and successful
-Pages deployment job/step. A PR, fork, tag, manual CI, cancelled or failed run supplies
-no deletion authority. Records without the app identity are preserved even when their
-environment happens to be named `github-pages`.
-
-The six IDs in `.github/pages-pruning-holds.json` are the complete observed Pages
-inventory on 2026-10-12. They preserve the existing incident evidence pending #723/#1028
-owner disposition; they are not an age grace period for future records. Three records
-are currently surplus to the normal newest-three floor. No historical non-execution or
-hold release is inferred from the terminal cancelled Actions run. Replace the evidence
-list with specifically retained IDs only after that disposition; rollback holds are
-independent. Such a change is reviewed on main before it acquires writer authority.
+descending. Also retain the current and previous **proven** successful publication by successful
+status completion order, every explicit rollback/evidence hold, unknown ownership, unproved
+publications and unsafe status. Current/rollback protection can add at most two records to the
+normal floor; unknown metadata and evidence holds need owner disposition rather than expiry.
+
+The six IDs in `.github/pages-pruning-holds.json` are the complete observed Pages inventory on
+2026-10-12. They preserve the existing incident evidence pending #723/#1028 owner disposition; they
+are not an age grace period for future records. No historical non-execution or hold release is
+inferred from the terminal cancelled Actions run. Replace the evidence list with specifically
+retained IDs only after that disposition; rollback holds are independent. Such a change is reviewed
+on main before it acquires writer authority.
+
+## Publication proof
+
+`deployment_status.log_url` is written by the status author, so a matching job URL proves nothing
+on its own. A record is deletable only after all of these hold, read fresh from the API:
+
+- deployment shape: `github-pages`, `main`, task `deploy`, full SHA, non-transient, non-production,
+ no GitHub App other than Actions;
+- the referenced run is the own-repository `CI / CD` push on `main` for the same SHA, completed
+ successfully, and its job `🚀 Deploy to GitHub Pages` and step `Deploy to GitHub Pages` succeeded;
+- **causal link:** the deployment record and its newest success status were authored by the actor
+ or triggering actor of that run, and neither author is `github-actions[bot]`.
+
+The causal link is what a `GITHUB_TOKEN` writer cannot imitate: any other workflow writes as
+`github-actions[bot]`, while the Pages publisher records the principal that triggered the run.
+Observed on all six live records on 2026-10-13: deployment author, status author and run actor
+agree in 6/6 (`qnbs` and `cursor[bot]`); the bot-triggered records carry no GitHub App,
+which is why the App identity is accepted as absent rather than required. Time-window matching was
+rejected: the success status falls outside the job's `started_at..completed_at` in 3 of 6 records.
+
+Residual: a principal holding the run actor's own credentials can still write matching records.
+That principal can delete records directly, so this adds no new authority. If the proof cannot be
+established for a record (run deleted, fields missing, mismatch), the record is protected as
+`unproved-publication`; it is never deleted and never blocks the rest of the batch.
+
+## Current and rollback slots
+
+Both publication slots are proven by API before any candidate is classified. The newest success
+must prove or the run stops (`unproved-current`). Up to four older successes are walked in order;
+an unproved one is protected and does not consume the rollback slot, and the first proven one is
+the rollback. If none proves, the run stops (`no-proven-rollback`). Shape-only classification
+exists for the pure plan and never authorizes a write.
## Trigger and mutation boundary
Daily 03:00 UTC reconciliation includes the existing Sunday backlog pass. There is no
-`workflow_run` trigger: this avoids privileged follow-up jobs for PR/fork/failed/manual
-CI and separates event completion from publication proof. Only main schedule runs can
-write; main dispatch is dry-run. The executing workflow/ref/repository are checked
-again in the module, checkout uses the immutable event SHA without persisted credentials,
-and a changed main SHA invalidates the hold/executable snapshot before each mutation.
-Product CI, required statuses, CodeQL, signing and reviewer governance are unchanged.
-
-One concurrency group serializes pruning invocations; at most ten records are attempted.
-Every inactivate and delete operation requires a new complete inventory/status read and
-unchanged identity, current publication, protection and status evidence. A failed or
-ambiguous inactivation never reaches deletion. `auto_inactive: false` avoids affecting
-other records. GET retries are bounded at three and two seconds maximum delay; writes
-are never blindly retried. API failures stop the batch, fail the job and report confirmed
-partial outcomes with sanitized reason codes. A later pass must re-observe uncertain writes.
-
-GitHub does not offer conditional inactivation/deletion tied to a deployment-status
-version. Revalidation detects observed promotion/publication races; it cannot provide a
-transaction across the final GET and POST. GitHub refuses DELETE of an active record.
-An independent exact-delta review must assess this residual API boundary before merge;
-external status writers must not be treated as serialized by the pruning concurrency group.
-
-## Owner reactivation sequence
+`workflow_run` trigger: this avoids privileged follow-up jobs for PR/fork/failed/manual CI and
+separates event completion from publication proof. Only main schedule runs can write (once the gate
+is open); main dispatch is dry-run. The executing workflow/ref/repository are checked again in the
+module, checkout uses the immutable event SHA without persisted credentials, and a changed main SHA
+invalidates the hold/executable snapshot before each mutation.
+
+At most ten records are attempted per invocation. Every inactivate and delete operation requires a
+fresh read of the deployment list plus the full status history of the candidate and of both
+publication slots, an unchanged publication-order prefix, an unchanged candidate fingerprint and
+the trusted-main SHA. Other records are re-read only when their list entry (including `updated_at`)
+changed; GitHub bumped `updated_at` past the newest status on all six live records, but that is an
+optimization, not the authority for the candidate or the slots. A failed or ambiguous inactivation
+never reaches deletion. `auto_inactive: false` avoids affecting other records. GET retries are
+bounded at three attempts and two seconds maximum delay; writes are never retried. API failures
+stop the batch, fail the job and report confirmed partial outcomes with sanitized reason codes. A
+later pass must re-observe uncertain writes.
+
+### Pagination
+
+GitHub returns `rel="next"` links under `/repositories/{id}/…` rather than `/repos/{owner}/{repo}/…`.
+Both forms are accepted only for the verified repository ID (one extra read of the repository),
+the API origin, the exact next page and the original query. Any other origin, path, extra,
+changed or duplicated parameter, or malformed link fails closed.
+
+### Request budget
+
+A run spends at most 450 requests (45% of GitHub's documented 1,000 requests per hour and
+repository for `GITHUB_TOKEN`, shared with CI); the live `x-ratelimit-remaining` header can only
+lower that. Before each candidate the run checks the remaining budget and rate allowance and stops
+gracefully with `budget-limit` rather than half-working. An inventory that alone exceeds the budget
+fails closed with `request-budget` before any write. Measured by the test fixture (GET + POST +
+DELETE, ten candidates):
+
+| Records | Previous design (full re-inventory per write) | This design |
+| ---: | ---: | ---: |
+| 6 | – | 54 |
+| 50 | ≈1,214 | 196 |
+| 100 | ≈2,264 | 249 |
+| 200 | ≈4,364 | 370 |
+| 300 | ≈6,464 | 411 (stops after 5 deletions) |
+
+A read-only dry-run costs 18, 76, 127 and 329 requests for 6, 50, 100 and 300 records.
+
+## Writer fence
+
+GitHub offers no compare-and-delete tied to a status version, so no check can make the final read
+and the write atomic. What is and is not protected:
+
+| Writer | Effect on a chosen candidate | Handling |
+| --- | --- | --- |
+| Pages publisher (`ci.yml` `deploy`) | A publication creates a **new** record; it cannot give an old record a success status. A stale snapshot is therefore never less protective than the fresh state (a test checks this for successful, failed and in-flight arrivals). | Structurally safe; mid-run arrivals stop the run (`current-moved`) before any write. |
+| In-repository status writers | None other than the publisher and this workflow exist; a test pins the set of workflows that can write deployment records. | Mechanical. |
+| Changes to the hold list on main | A hold landing between the final trusted-main check and the DELETE would not stop that delete. | **Not fenced.** Gate closed. |
+| External status writers (PAT holders, third-party Apps) | Could add a success status to an old record. | **Not fenced.** Gate closed. |
+
+The pruning concurrency group serializes pruning invocations only. It does not serialize the
+publisher, hold changes or external writers, and must not be read as a fence.
+
+## Capacity
+
+Verified from the Actions API: 44 successful `CI / CD` runs on `main` in the 7 days to 2026-10-13
+(mean ≈6.3 per day, one day with 15, then 8, 10, 8, 2, 1). Each success publishes one record. One
+pass retires at most ten, so the mean leaves ≈3.7 per day of headroom and a 15-run day leaves a
+backlog of five that the following days absorb. The run reports `batch-limit` or `budget-limit`
+and raises an Actions warning when a backlog remains. If that warning persists over three
+consecutive scheduled runs, raising the cadence (for example `0 3,15 * * *`) is an owner decision;
+`workflow_run` triggers on PR CI are not an option. Trigger-count and runner-minute measurements
+belong to #550/QNB-41; no monetary or CPU savings are inferred from workflow-start counts alone.
+
+## Owner enablement sequence
1. Merge only after exact-head full CI/security/signature/governance success and substantive
independent review; prove resulting-main CI/CodeQL and Production separately.
2. Record a current, zero-write dry-run; reconcile the evidence holds with their owners.
-3. Obtain a separate explicit owner decision to enable the workflow. This PR/CLI does not
- perform that action. Observe the first admitted scheduled pass and assert counts,
- protected IDs and unchanged non-Pages inventory.
+3. Decide the open fence items: how hold changes are frozen during the run window, and whether
+ external status writers are excluded by policy or by a shared serialization with the publisher.
+ Adding the publisher to a shared concurrency group needs its own review because a queued
+ deploy can be replaced by a newer pending run.
+4. Obtain a separate explicit owner decision, then open the gate in one reviewed change that sets
+ `DESTRUCTIVE_WRITES_ADMITTED` to `true` and restores `deployments: write` for the job. This
+ PR/CLI does not perform that action. Observe the first admitted scheduled pass and assert
+ counts, protected IDs and unchanged non-Pages inventory.
Until those steps are complete, implementation/review/operational verification remain
-separate states. Trigger-count measurements belong to #550/QNB-41; no monetary or CPU
-savings are inferred from workflow-start counts alone.
+separate states.
diff --git a/scripts/pages-pruning/evidence.mjs b/scripts/pages-pruning/evidence.mjs
new file mode 100644
index 000000000..5c81e8756
--- /dev/null
+++ b/scripts/pages-pruning/evidence.mjs
@@ -0,0 +1,57 @@
+export const validId = (id) => Number.isSafeInteger(id) && id > 0;
+
+export const anyOf = (...flags) => flags.some(Boolean);
+
+export const refuse = (reason) => {
+ throw Object.assign(new Error(reason), { reason });
+};
+
+const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
+
+export function timestamp(value) {
+ if (typeof value !== 'string' || !ISO_UTC.test(value)) return false;
+ const parsed = Date.parse(value);
+ const canonical = value.length === 20 ? value.replace('Z', '.000Z') : value;
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === canonical;
+}
+
+export const rank = (a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id;
+
+export const latestStatus = (record) => [...record.statuses].sort(rank)[0];
+
+export const newestSuccess = (record) =>
+ [...record.statuses].filter((status) => status.state === 'success').sort(rank)[0];
+
+// QNBS-v3: keys use selected stable fields, never whole payloads, so a POST response and a later GET list entry for the same status compare equal.
+const statusKey = (status) => [
+ status.id,
+ status.state,
+ status.created_at,
+ status.environment,
+ status.environment_url,
+ status.log_url,
+ status.creator?.login ?? null,
+];
+
+const metaKey = (record) => [
+ record.id,
+ record.sha,
+ record.ref,
+ record.environment,
+ record.created_at,
+ record.task,
+ record.transient_environment,
+ record.production_environment,
+ record.creator?.login ?? null,
+ record.performed_via_github_app?.id ?? null,
+];
+
+export const fingerprint = (record) =>
+ JSON.stringify([metaKey(record), [...record.statuses].sort(rank).map(statusKey)]);
+
+export function proofKey(record) {
+ const success = newestSuccess(record);
+ return JSON.stringify([metaKey(record), success ? statusKey(success) : null]);
+}
+
+export const listKey = (item) => JSON.stringify([metaKey(item), item.updated_at ?? null]);
diff --git a/scripts/pages-pruning/github.mjs b/scripts/pages-pruning/github.mjs
new file mode 100644
index 000000000..27be777bf
--- /dev/null
+++ b/scripts/pages-pruning/github.mjs
@@ -0,0 +1,145 @@
+import { refuse, validId } from './evidence.mjs';
+import { REPOSITORY, ROOT, SITE } from './policy.mjs';
+
+// QNBS-v3: 450 reads and writes per run stays under half of GitHub's documented 1,000 requests per hour per repository for GITHUB_TOKEN; the live rate headers can only lower the budget.
+export const REQUEST_LIMIT = 450;
+const API_ORIGIN = 'https://api.github.com';
+const PAGE_SIZE = 100;
+const RETRYABLE = new Set([429, 500, 502, 503, 504]);
+const BACKOFF_CAP_MS = 2000;
+
+export const defaultPause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export function createContext({ github, authority, pause = defaultPause, limit = REQUEST_LIMIT }) {
+ return {
+ github,
+ authority,
+ pause,
+ limit,
+ used: 0,
+ remaining: null,
+ repositoryId: null,
+ proven: new Map(),
+ };
+}
+
+function spend(ctx) {
+ if (ctx.used >= ctx.limit) refuse('request-budget');
+ ctx.used += 1;
+}
+
+function noteRate(ctx, headers) {
+ const remaining = Number(headers?.['x-ratelimit-remaining']);
+ if (Number.isFinite(remaining)) ctx.remaining = remaining;
+}
+
+const retryDelay = (error, attempt) =>
+ error.status === 429
+ ? Number(error.response?.headers?.['retry-after']) * 1000
+ : 250 * 2 ** attempt;
+
+function shouldRetry(error, attempt, delay) {
+ return [
+ attempt < 2,
+ RETRYABLE.has(error.status),
+ Number.isFinite(delay),
+ delay >= 0,
+ delay <= BACKOFF_CAP_MS,
+ ].every(Boolean);
+}
+
+export async function read(ctx, route, parameters = {}) {
+ for (let attempt = 0; ; attempt++) {
+ spend(ctx);
+ try {
+ const response = await ctx.github.request(`GET ${route}`, parameters);
+ noteRate(ctx, response.headers);
+ return response;
+ } catch (error) {
+ const delay = retryDelay(error, attempt);
+ if (!shouldRetry(error, attempt, delay)) throw error;
+ await ctx.pause(delay);
+ }
+ }
+}
+
+// QNBS-v3: writes are never retried; an ambiguous outcome is re-observed by a later pass instead of repeated against an assumed inventory.
+export async function write(ctx, route, parameters) {
+ spend(ctx);
+ const response = await ctx.github.request(route, parameters);
+ noteRate(ctx, response?.headers);
+ return response;
+}
+
+async function repositoryId(ctx) {
+ if (ctx.repositoryId === null) {
+ const { data } = await read(ctx, ROOT);
+ if (!validId(data.id) || data.full_name !== REPOSITORY) refuse('unknown-repository');
+ ctx.repositoryId = data.id;
+ }
+ return ctx.repositoryId;
+}
+
+const queryKey = (params) =>
+ [...params]
+ .map(([key, value]) => `${key}=${value}`)
+ .sort()
+ .join('&');
+
+function parseNext(href) {
+ try {
+ return new URL(href);
+ } catch {
+ return refuse('invalid-pagination-link');
+ }
+}
+
+// QNBS-v3: GitHub emits next links under /repositories/{id}; accept that form only for the verified repository id, with the original query and the exact next page.
+async function assertCanonicalNext(ctx, href, route, page) {
+ const url = parseNext(href);
+ const [path, query = ''] = route.split('?');
+ const expected = new URLSearchParams(query);
+ expected.set('page', String(page + 1));
+ expected.set('per_page', String(PAGE_SIZE));
+ if (url.origin !== API_ORIGIN || queryKey(url.searchParams) !== queryKey(expected))
+ refuse('invalid-pagination-link');
+ if (url.pathname === path) return;
+ const byId = path.replace(`/repos/${REPOSITORY}`, `/repositories/${await repositoryId(ctx)}`);
+ if (url.pathname !== byId) refuse('invalid-pagination-link');
+}
+
+const nextHref = (headers) => /<([^>]+)>;\s*rel="next"/.exec(headers?.link ?? '')?.[1] ?? null;
+
+export async function collect(ctx, route, maxPages = 20) {
+ const all = [];
+ for (let page = 1; page <= maxPages; page++) {
+ const response = await read(ctx, route, { per_page: PAGE_SIZE, page });
+ if (!Array.isArray(response.data) || response.data.length > PAGE_SIZE) refuse('invalid-page');
+ all.push(...response.data);
+ const href = nextHref(response.headers);
+ if (href === null && response.data.length < PAGE_SIZE) return all;
+ if (href === null) continue;
+ if (response.data.length === 0) refuse('invalid-pagination-link');
+ await assertCanonicalNext(ctx, href, route, page);
+ }
+ return refuse('pagination-limit');
+}
+
+export async function assertPagesSite(ctx) {
+ const { data } = await read(ctx, `${ROOT}/pages`);
+ if (data.build_type !== 'workflow' || data.html_url !== SITE) refuse('unknown-pages-site');
+}
+
+export const listRecords = (ctx) => collect(ctx, `${ROOT}/deployments?environment=github-pages`);
+
+export function loadStatuses(ctx, record) {
+ if (!validId(record.id)) refuse('invalid-inventory');
+ return collect(ctx, `${ROOT}/deployments/${record.id}/statuses`, 5);
+}
+
+export async function inventory(ctx) {
+ await assertPagesSite(ctx);
+ const records = await listRecords(ctx);
+ for (const record of records) record.statuses = await loadStatuses(ctx, record);
+ return records;
+}
diff --git a/scripts/pages-pruning/policy.mjs b/scripts/pages-pruning/policy.mjs
new file mode 100644
index 000000000..9ad074110
--- /dev/null
+++ b/scripts/pages-pruning/policy.mjs
@@ -0,0 +1,192 @@
+import {
+ anyOf,
+ latestStatus,
+ newestSuccess,
+ rank,
+ refuse,
+ timestamp,
+ validId,
+} from './evidence.mjs';
+
+export const REPOSITORY = 'qnbs/WorldScript-Studio';
+export const ROOT = `/repos/${REPOSITORY}`;
+export const SITE = 'https://qnbs.github.io/WorldScript-Studio/';
+export const WORKFLOW = `${REPOSITORY}/.github/workflows/prune-deployments.yml@refs/heads/main`;
+export const MAX_BATCH = 10;
+export const ACTIONS_BOT = 'github-actions[bot]';
+// QNBS-v3: GitHub offers no compare-and-delete; destructive writes stay closed until the owner admits a writer-fence protocol for #1028, and the workflow token carries no deployments:write while this is false.
+export const DESTRUCTIVE_WRITES_ADMITTED = false;
+
+const FULL_SHA = /^[a-f0-9]{40}$/;
+const JOB_URL =
+ /^https:\/\/github\.com\/qnbs\/WorldScript-Studio\/actions\/runs\/(\d+)\/job\/(\d+)$/;
+const ACTIONS_APP_ID = 15368;
+const SETTLED = new Set(['success', 'inactive', 'failure', 'error']);
+const MODES = new Map([
+ ['schedule', 'delete'],
+ ['workflow_dispatch', 'dry-run'],
+]);
+
+const trusted = (authority) =>
+ [
+ authority.repository === REPOSITORY,
+ authority.ref === 'refs/heads/main',
+ authority.workflowRef === WORKFLOW,
+ FULL_SHA.test(authority.sha ?? ''),
+ ].every(Boolean);
+
+export function admission(authority) {
+ return trusted(authority) ? (MODES.get(authority.eventName) ?? 'refused') : 'refused';
+}
+
+const isIdList = (value) => Array.isArray(value) && value.every(validId);
+
+export function validateHolds(holds) {
+ const keys = holds && typeof holds === 'object' ? Object.keys(holds).sort().join(',') : '';
+ if (keys !== 'evidence,rollback') refuse('invalid-holds');
+ if (!isIdList(holds.evidence) || !isIdList(holds.rollback)) refuse('invalid-holds');
+ return new Set([...holds.evidence, ...holds.rollback]);
+}
+
+export const creatorLogin = (actor) =>
+ typeof actor?.login === 'string' && actor.login !== '' ? actor.login : null;
+
+const isActionsApp = (app) => app.id === ACTIONS_APP_ID && app.slug === 'github-actions';
+const appIsActionsOrAbsent = (app) => app == null || isActionsApp(app);
+
+// QNBS-v3: bot-triggered pushes carry no GitHub App on the deployment, so identity is proven against the run actor later, not by the app id.
+export function owned(record) {
+ const creator = creatorLogin(record.creator);
+ return [
+ record.environment === 'github-pages',
+ record.ref === 'main',
+ record.task === 'deploy',
+ FULL_SHA.test(record.sha ?? ''),
+ record.transient_environment === false,
+ record.production_environment === false,
+ appIsActionsOrAbsent(record.performed_via_github_app),
+ creator !== null,
+ creator !== ACTIONS_BOT,
+ ].every(Boolean);
+}
+
+export function jobIdentity(status) {
+ const match = JOB_URL.exec(status?.log_url ?? '');
+ if (!match) return null;
+ const identity = { run: Number(match[1]), job: Number(match[2]) };
+ return validId(identity.run) && validId(identity.job) ? identity : null;
+}
+
+export const isPagesStatus = (status) =>
+ status?.environment === 'github-pages' && status?.environment_url === SITE;
+
+function assertStatuses(statuses, seen) {
+ for (const status of statuses) {
+ if (anyOf(!validId(status.id), seen.has(status.id), !timestamp(status.created_at)))
+ refuse('invalid-statuses');
+ seen.add(status.id);
+ }
+}
+
+export function assertInventory(records) {
+ const ids = new Set();
+ const statusIds = new Set();
+ for (const record of records) {
+ if (anyOf(!validId(record.id), ids.has(record.id), !timestamp(record.created_at)))
+ refuse('invalid-inventory');
+ ids.add(record.id);
+ if (!Array.isArray(record.statuses)) refuse('incomplete-statuses');
+ assertStatuses(record.statuses, statusIds);
+ }
+}
+
+const isPages = (record) => record.environment === 'github-pages';
+
+export function publicationOrder(records) {
+ return records
+ .filter(isPages)
+ .flatMap((record) => {
+ const status = newestSuccess(record);
+ return status ? [{ record, status }] : [];
+ })
+ .sort((a, b) => rank(a.status, b.status));
+}
+
+function hasPagesProvenance({ record, success, latest }) {
+ return [
+ owned(record),
+ isPagesStatus(success),
+ latest?.environment === 'github-pages',
+ jobIdentity(success) !== null,
+ ].every(Boolean);
+}
+
+const PROTECTIONS = [
+ ['other-environment', ({ record }) => !isPages(record)],
+ ['newest-three', ({ record, newest }) => newest.has(record.id)],
+ ['current-or-rollback', ({ record, slotIds }) => slotIds.has(record.id)],
+ ['unproved-publication', ({ record, unproved }) => unproved.has(record.id)],
+ ['explicit-hold', ({ record, held }) => held.has(record.id)],
+ ['unknown-ownership', (entry) => !hasPagesProvenance(entry)],
+ ['unsafe-status', ({ latest }) => !['success', 'inactive'].includes(latest?.state)],
+];
+
+function classify(record, context) {
+ const entry = {
+ ...context,
+ record,
+ success: context.successes.get(record.id),
+ latest: latestStatus(record),
+ };
+ const reasons = PROTECTIONS.filter(([, applies]) => applies(entry)).map(([reason]) => reason);
+ return { record, reasons };
+}
+
+const shapeSlots = (ordered) => ({
+ current: ordered[0]?.record.id ?? null,
+ rollback: ordered[1]?.record.id ?? null,
+ unproved: [],
+});
+
+function currentIsSettled(head) {
+ if (!head) return false;
+ return [
+ owned(head.record),
+ latestStatus(head.record)?.id === head.status.id,
+ isPagesStatus(head.status),
+ jobIdentity(head.status) !== null,
+ ].every(Boolean);
+}
+
+const hasUnsettled = (pages) => pages.some((record) => !SETTLED.has(latestStatus(record)?.state));
+
+// QNBS-v3: slots come from API-proven publications when supplied; the shape-only default exists for the pure plan and never authorizes a write alone.
+export function planPruning(records, holds, { complete = true, slots } = {}) {
+ if (!complete || !Array.isArray(records)) refuse('incomplete-inventory');
+ const held = validateHolds(holds);
+ assertInventory(records);
+ const pages = records.filter(isPages).sort(rank);
+ const ordered = publicationOrder(records);
+ const chosen = slots ?? shapeSlots(ordered);
+ const unsettled = anyOf(!currentIsSettled(ordered[0]), hasUnsettled(pages));
+ const context = {
+ held,
+ newest: new Set(pages.slice(0, 3).map((record) => record.id)),
+ slotIds: new Set([chosen.current, chosen.rollback].filter(validId)),
+ unproved: new Set(chosen.unproved),
+ successes: new Map(ordered.map(({ record, status }) => [record.id, status])),
+ };
+ const entries = records.map((record) => classify(record, context));
+ return {
+ current: chosen.current ?? null,
+ rollback: chosen.rollback ?? null,
+ protectedRecords: entries
+ .filter((entry) => entry.reasons.length)
+ .map(({ record, reasons }) => ({ id: record.id, reasons })),
+ candidates: entries
+ .filter((entry) => !entry.reasons.length)
+ .map((entry) => entry.record)
+ .sort(rank),
+ blocked: pages.length > 3 && unsettled,
+ };
+}
diff --git a/scripts/pages-pruning/provenance.mjs b/scripts/pages-pruning/provenance.mjs
new file mode 100644
index 000000000..b2dbd3caf
--- /dev/null
+++ b/scripts/pages-pruning/provenance.mjs
@@ -0,0 +1,129 @@
+import { newestSuccess, proofKey, refuse } from './evidence.mjs';
+import { read } from './github.mjs';
+import {
+ ACTIONS_BOT,
+ creatorLogin,
+ jobIdentity,
+ MAX_BATCH,
+ publicationOrder,
+ REPOSITORY,
+ ROOT,
+} from './policy.mjs';
+
+export const SLOT_DEPTH = 4;
+const UNKNOWN = 'unknown-publication';
+
+function runFacts(run, record, identity) {
+ return [
+ run.id === identity.run,
+ run.repository?.full_name === REPOSITORY,
+ run.head_repository?.full_name === REPOSITORY,
+ run.name === 'CI / CD',
+ run.path === '.github/workflows/ci.yml',
+ run.event === 'push',
+ run.head_branch === 'main',
+ run.head_sha === record.sha,
+ run.status === 'completed',
+ run.conclusion === 'success',
+ ];
+}
+
+function jobFacts(job, run, identity) {
+ const deployed = job.steps?.some(
+ (step) => step.name === 'Deploy to GitHub Pages' && step.conclusion === 'success',
+ );
+ return [
+ job.id === identity.job,
+ job.run_id === run.id,
+ job.name === '🚀 Deploy to GitHub Pages',
+ job.status === 'completed',
+ job.conclusion === 'success',
+ deployed === true,
+ ];
+}
+
+// QNBS-v3: log_url is caller-written, so the record and its success status must both be authored by the principal that triggered the referenced run; a GITHUB_TOKEN writer is always github-actions[bot].
+function actorFacts(record, success, run) {
+ const actors = new Set([creatorLogin(run.actor), creatorLogin(run.triggering_actor)]);
+ actors.delete(null);
+ const deployer = creatorLogin(record.creator);
+ const publisher = creatorLogin(success.creator);
+ return [
+ actors.size > 0,
+ actors.has(deployer),
+ actors.has(publisher),
+ deployer !== ACTIONS_BOT,
+ publisher !== ACTIONS_BOT,
+ ];
+}
+
+async function fetchEvidence(ctx, identity) {
+ try {
+ const { data: run } = await read(ctx, `${ROOT}/actions/runs/${identity.run}`);
+ const { data: job } = await read(ctx, `${ROOT}/actions/jobs/${identity.job}`);
+ return { run, job };
+ } catch (error) {
+ if (error.status === 404) refuse(UNKNOWN);
+ throw error;
+ }
+}
+
+export async function provePublication(ctx, record) {
+ const success = newestSuccess(record);
+ const identity = jobIdentity(success);
+ if (!identity) refuse(UNKNOWN);
+ const { run, job } = await fetchEvidence(ctx, identity);
+ const facts = [
+ ...runFacts(run, record, identity),
+ ...jobFacts(job, run, identity),
+ ...actorFacts(record, success, run),
+ ];
+ if (!facts.every(Boolean)) refuse(UNKNOWN);
+}
+
+export async function ensureProven(ctx, record) {
+ const key = proofKey(record);
+ if (ctx.proven.get(record.id) === key) return;
+ await provePublication(ctx, record);
+ ctx.proven.set(record.id, key);
+}
+
+async function proves(ctx, record) {
+ try {
+ await ensureProven(ctx, record);
+ return true;
+ } catch (error) {
+ if (error.reason === UNKNOWN) return false;
+ throw error;
+ }
+}
+
+// QNBS-v3: both publication slots are proven before any candidate is classified; an unproved newer status is protected and never consumes the rollback slot.
+export async function proveSlots(ctx, records) {
+ const ordered = publicationOrder(records)
+ .slice(0, SLOT_DEPTH + 1)
+ .map((entry) => entry.record);
+ const [head, ...older] = ordered;
+ if (!head || !(await proves(ctx, head))) refuse('unproved-current');
+ const unproved = [];
+ for (const record of older) {
+ if (await proves(ctx, record)) {
+ // QNBS-v3: only the order up to the rollback slot is authority; older records are candidates that later deletions may remove.
+ const order = [head.id, ...unproved, record.id];
+ return { current: head.id, rollback: record.id, unproved, order };
+ }
+ unproved.push(record.id);
+ }
+ return refuse('no-proven-rollback');
+}
+
+// QNBS-v3: a candidate without causal proof is protected and skipped, so one unprovable record cannot stall the rest of the batch or turn the schedule permanently red.
+export async function selectBatch(ctx, candidates, slots) {
+ const batch = [];
+ for (const record of candidates.slice(0, MAX_BATCH * 2)) {
+ if (batch.length === MAX_BATCH) break;
+ if (await proves(ctx, record)) batch.push(record);
+ else slots.unproved.push(record.id);
+ }
+ return batch;
+}
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
new file mode 100644
index 000000000..b87538c8f
--- /dev/null
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -0,0 +1,231 @@
+import {
+ anyOf,
+ fingerprint,
+ latestStatus,
+ listKey,
+ refuse,
+ timestamp,
+ validId,
+} from './evidence.mjs';
+import { createContext, inventory, listRecords, loadStatuses, read, write } from './github.mjs';
+import {
+ admission,
+ DESTRUCTIVE_WRITES_ADMITTED,
+ MAX_BATCH,
+ planPruning,
+ publicationOrder,
+ ROOT,
+ SITE,
+} from './policy.mjs';
+import { ensureProven, proveSlots, selectBatch } from './provenance.mjs';
+
+const CANDIDATE_CEILING = 40;
+const RATE_RESERVE = 100;
+const API_FAILURES = new Set([403, 404, 409, 429, 500, 502, 503, 504]);
+
+function newRun(authority, mode) {
+ const result = {
+ trigger: authority.eventName,
+ mode,
+ queried: 0,
+ classified: 0,
+ protected: 0,
+ candidate: 0,
+ attempted: 0,
+ inactivated: 0,
+ deleted: 0,
+ failed: 0,
+ skipped: 0,
+ requests: 0,
+ reasons: {},
+ current: null,
+ rollback: null,
+ manifest: [],
+ followUpRequired: false,
+ };
+ const reason = (key, count = 1) => {
+ result.reasons[key] = (result.reasons[key] ?? 0) + count;
+ };
+ return { result, reason, considered: 0, batch: [], finalized: false };
+}
+
+function resolveMode(admitted, dryRun, destructive) {
+ if (admitted === 'refused') return 'refused';
+ return anyOf(dryRun, admitted !== 'delete', !destructive) ? 'dry-run' : 'delete';
+}
+
+function report(run, records, plan) {
+ const { result } = run;
+ result.classified = records.length;
+ result.protected = plan.protectedRecords.length;
+ result.candidate = plan.candidates.length;
+ result.current = plan.current;
+ result.rollback = plan.rollback;
+ result.manifest = [
+ ...plan.protectedRecords.map((entry) => ({ ...entry, disposition: 'protected' })),
+ ...plan.candidates.map((entry) => ({ id: entry.id, disposition: 'candidate', reasons: [] })),
+ ];
+ for (const entry of plan.protectedRecords) for (const key of entry.reasons) run.reason(key);
+}
+
+function finishDryRun(run, plan, records) {
+ run.result.skipped = plan.candidates.length;
+ if (run.result.skipped) run.reason('dry-run', run.result.skipped);
+ run.result.followUpRequired = records.length > 3;
+ run.finalized = true;
+}
+
+async function prepare(ctx, holds, records, first) {
+ if (first.candidates.length === 0) return { plan: first, slots: null, batch: [] };
+ const slots = await proveSlots(ctx, records);
+ const batch = await selectBatch(ctx, planPruning(records, holds, { slots }).candidates, slots);
+ return { plan: planPruning(records, holds, { slots }), slots, batch };
+}
+
+function reusableStatuses(before, item, focus) {
+ if (!before || focus.has(item.id)) return null;
+ return listKey(item) === listKey(before) ? before.statuses : null;
+}
+
+async function refresh(ctx, state, focusId) {
+ const previous = new Map(state.records.map((record) => [record.id, record]));
+ const focus = new Set([focusId, state.slots.current, state.slots.rollback]);
+ const fresh = [];
+ for (const item of await listRecords(ctx)) {
+ const kept = reusableStatuses(previous.get(item.id), item, focus);
+ fresh.push({ ...item, statuses: kept ?? (await loadStatuses(ctx, item)) });
+ }
+ return fresh;
+}
+
+function assertStable(state, fresh, next) {
+ const order = publicationOrder(fresh)
+ .slice(0, state.slots.order.length)
+ .map((entry) => entry.record.id);
+ if (next.blocked || order.join() !== state.slots.order.join()) refuse('current-moved');
+}
+
+async function assertMainUnchanged(ctx) {
+ const { data } = await read(ctx, `${ROOT}/branches/main`);
+ if (data.commit?.sha !== ctx.authority.sha) refuse('main-moved');
+}
+
+// QNBS-v3: holds are immutable at the executing main SHA; every write re-reads the list plus the candidate and both publication slots, and unchanged other records are trusted only while their list key is identical.
+async function revalidate(ctx, holds, state, candidate, expected) {
+ const fresh = await refresh(ctx, state, candidate.id);
+ const next = planPruning(fresh, holds, { slots: state.slots });
+ assertStable(state, fresh, next);
+ state.records = fresh;
+ const record = fresh.find((item) => item.id === candidate.id);
+ const candidateStillEligible = next.candidates.some((item) => item.id === candidate.id);
+ if (!record || !candidateStillEligible) return null;
+ if (fingerprint(record) !== expected) return null;
+ for (const id of [state.slots.current, state.slots.rollback])
+ await ensureProven(ctx, fresh.find((item) => item.id === id) ?? refuse('current-moved'));
+ await ensureProven(ctx, record);
+ await assertMainUnchanged(ctx);
+ return record;
+}
+
+async function inactivate(ctx, record) {
+ const { data } = await write(ctx, `POST ${ROOT}/deployments/${record.id}/statuses`, {
+ state: 'inactive',
+ environment: 'github-pages',
+ environment_url: SITE,
+ log_url: latestStatus(record).log_url,
+ auto_inactive: false,
+ });
+ if (anyOf(!validId(data.id), data.state !== 'inactive', !timestamp(data.created_at)))
+ refuse('invalid-inactivation-response');
+ return data;
+}
+
+function skip(run, reason) {
+ run.result.skipped += 1;
+ run.reason(reason);
+}
+
+// QNBS-v3: a failed or ambiguous inactivation never reaches DELETE, and writes are never retried against an assumed inventory.
+async function retire(ctx, run, holds, state, candidate) {
+ let fresh = await revalidate(ctx, holds, state, candidate, fingerprint(candidate));
+ if (!fresh) return skip(run, 'stale-candidate');
+ run.result.attempted += 1;
+ if (latestStatus(fresh).state !== 'inactive') {
+ fresh.statuses.push(await inactivate(ctx, fresh));
+ run.result.inactivated += 1;
+ fresh = await revalidate(ctx, holds, state, candidate, fingerprint(fresh));
+ if (!fresh) return skip(run, 'status-moved');
+ }
+ await write(ctx, `DELETE ${ROOT}/deployments/${candidate.id}`);
+ run.result.deleted += 1;
+ state.records = state.records.filter((record) => record.id !== candidate.id);
+}
+
+function affordable(ctx) {
+ const room = ctx.used + CANDIDATE_CEILING <= ctx.limit;
+ const rate = ctx.remaining === null || ctx.remaining >= RATE_RESERVE + CANDIDATE_CEILING;
+ return room && rate;
+}
+
+async function mutate(ctx, run, holds, state) {
+ for (const candidate of run.batch) {
+ if (!affordable(ctx)) {
+ run.reason('budget-limit', run.batch.length - run.considered);
+ return;
+ }
+ run.considered += 1;
+ await retire(ctx, run, holds, state, candidate);
+ }
+}
+
+async function execute(ctx, run, holds) {
+ if (run.result.mode === 'refused') refuse('untrusted-trigger');
+ const records = await inventory(ctx);
+ run.result.queried = records.length;
+ const first = planPruning(records, holds);
+ if (first.blocked) {
+ report(run, records, first);
+ refuse('unproved-current-or-unsettled');
+ }
+ const { plan, slots, batch } = await prepare(ctx, holds, records, first);
+ run.batch = batch;
+ report(run, records, plan);
+ if (run.result.mode !== 'delete') return finishDryRun(run, plan, records);
+ await mutate(ctx, run, holds, { records, slots });
+ run.result.followUpRequired = records.length - run.result.deleted > 3;
+ if (plan.candidates.length > MAX_BATCH)
+ run.reason('batch-limit', plan.candidates.length - MAX_BATCH);
+}
+
+function failureReason(error) {
+ if (error.reason) return error.reason;
+ return API_FAILURES.has(error.status) ? `api-${error.status}` : 'api-or-inventory-error';
+}
+
+export async function reconcilePages({
+ github,
+ authority,
+ holds,
+ dryRun = false,
+ destructive = DESTRUCTIVE_WRITES_ADMITTED,
+ pause,
+ limit,
+}) {
+ const admitted = admission(authority);
+ const run = newRun(authority, resolveMode(admitted, dryRun, destructive));
+ const gateClosed = [admitted === 'delete', !dryRun, !destructive].every(Boolean);
+ if (gateClosed) run.reason('destructive-gate-closed');
+ const ctx = createContext({ github, authority, pause, limit });
+ try {
+ await execute(ctx, run, holds);
+ } catch (error) {
+ run.result.failed += 1;
+ run.result.followUpRequired = true;
+ run.reason(failureReason(error));
+ if (run.result.candidate > run.considered)
+ run.reason('aborted-batch', run.result.candidate - run.considered);
+ }
+ if (!run.finalized) run.result.skipped += run.result.candidate - run.considered;
+ run.result.requests = ctx.used;
+ return run.result;
+}
diff --git a/scripts/prune-pages-deployments.mjs b/scripts/prune-pages-deployments.mjs
index c64d54b93..c6c46f2e0 100644
--- a/scripts/prune-pages-deployments.mjs
+++ b/scripts/prune-pages-deployments.mjs
@@ -1,384 +1,30 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
+import { refuse } from './pages-pruning/evidence.mjs';
+import { REPOSITORY, WORKFLOW } from './pages-pruning/policy.mjs';
+import { reconcilePages } from './pages-pruning/reconcile.mjs';
-export const REPOSITORY = 'qnbs/WorldScript-Studio';
-const ROOT = `/repos/${REPOSITORY}`;
-const SITE = 'https://qnbs.github.io/WorldScript-Studio/';
-const WORKFLOW = `${REPOSITORY}/.github/workflows/prune-deployments.yml@refs/heads/main`;
-const MAX_BATCH = 10;
-const validId = (id) => Number.isSafeInteger(id) && id > 0;
-const timestamp = (value) => {
- if (
- typeof value !== 'string' ||
- !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
- )
- return false;
- const parsed = Date.parse(value);
- const canonical = value.length === 20 ? value.replace('Z', '.000Z') : value;
- return Number.isFinite(parsed) && new Date(parsed).toISOString() === canonical;
-};
-const rank = (a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id;
-const refuse = (reason) => {
- throw Object.assign(new Error(reason), { reason });
-};
+export {
+ admission,
+ DESTRUCTIVE_WRITES_ADMITTED,
+ planPruning,
+ REPOSITORY,
+ validateHolds,
+} from './pages-pruning/policy.mjs';
+export { reconcilePages };
-export function admission({ repository, ref, sha, workflowRef, eventName }) {
- if (
- repository !== REPOSITORY ||
- ref !== 'refs/heads/main' ||
- workflowRef !== WORKFLOW ||
- !/^[a-f0-9]{40}$/.test(sha ?? '')
- )
- return 'refused';
- if (eventName === 'schedule') return 'delete';
- if (eventName === 'workflow_dispatch') return 'dry-run';
- return 'refused';
-}
-
-export function validateHolds(holds) {
- if (
- !holds ||
- Object.keys(holds).sort().join(',') !== 'evidence,rollback' ||
- !['evidence', 'rollback'].every((key) => Array.isArray(holds[key]) && holds[key].every(validId))
- )
- refuse('invalid-holds');
- return new Set([...holds.evidence, ...holds.rollback]);
-}
-
-function owned(record) {
- return (
- record.environment === 'github-pages' &&
- record.ref === 'main' &&
- record.task === 'deploy' &&
- /^[a-f0-9]{40}$/.test(record.sha ?? '') &&
- record.transient_environment === false &&
- record.production_environment === false &&
- record.performed_via_github_app?.id === 15368 &&
- record.performed_via_github_app?.slug === 'github-actions'
- );
-}
-
-function jobIdentity(status) {
- const match =
- /^https:\/\/github\.com\/qnbs\/WorldScript-Studio\/actions\/runs\/(\d+)\/job\/(\d+)$/.exec(
- status?.log_url ?? '',
- );
- return match?.slice(1).map(Number).every(validId)
- ? { run: Number(match[1]), job: Number(match[2]) }
- : null;
-}
-
-export function planPruning(records, holds, complete = true) {
- if (!complete || !Array.isArray(records)) refuse('incomplete-inventory');
- const held = validateHolds(holds);
- const ids = new Set();
- const statusIds = new Set();
- for (const record of records) {
- if (!validId(record.id) || ids.has(record.id) || !timestamp(record.created_at))
- refuse('invalid-inventory');
- ids.add(record.id);
- if (!Array.isArray(record.statuses)) refuse('incomplete-statuses');
- for (const status of record.statuses) {
- if (!validId(status.id) || statusIds.has(status.id) || !timestamp(status.created_at))
- refuse('invalid-statuses');
- statusIds.add(status.id);
- }
- }
- const pages = records.filter((record) => record.environment === 'github-pages').sort(rank);
- const latest = (record) => [...record.statuses].sort(rank)[0];
- const successful = pages
- .flatMap((record) => {
- const status = [...record.statuses].filter((item) => item.state === 'success').sort(rank)[0];
- return status ? [{ record, status }] : [];
- })
- .sort((a, b) => rank(a.status, b.status));
- // QNBS-v3: Pages leaves historical records at success; successful completion order protects the current and previous publication, independently of creation order.
- const current = successful[0];
- const rollback = successful[1];
- const currentProved =
- current &&
- owned(current.record) &&
- latest(current.record)?.id === current.status.id &&
- current.status.environment === 'github-pages' &&
- current.status.environment_url === SITE &&
- jobIdentity(current.status);
- const unsettled = pages.some(
- (record) => !['success', 'inactive', 'failure', 'error'].includes(latest(record)?.state),
- );
- const protectedRecords = [];
- const candidates = [];
- for (const record of records) {
- const status = latest(record);
- const success = successful.find((item) => item.record.id === record.id)?.status;
- const reasons = [];
- if (record.environment !== 'github-pages') reasons.push('other-environment');
- if (pages.slice(0, 3).some((item) => item.id === record.id)) reasons.push('newest-three');
- if ([current?.record.id, rollback?.record.id].includes(record.id))
- reasons.push('current-or-rollback');
- if (held.has(record.id)) reasons.push('explicit-hold');
- if (
- !owned(record) ||
- !success ||
- success.environment !== 'github-pages' ||
- status?.environment !== 'github-pages' ||
- success.environment_url !== SITE ||
- !jobIdentity(success)
- )
- reasons.push('unknown-ownership');
- if (!['success', 'inactive'].includes(status?.state)) reasons.push('unsafe-status');
- if (reasons.length) protectedRecords.push({ id: record.id, reasons });
- else candidates.push(record);
- }
- const blocked = pages.length > 3 && (!currentProved || unsettled);
- return {
- current: current?.record.id ?? null,
- protectedRecords,
- candidates: candidates.sort(rank),
- blocked,
- };
-}
-
-async function read(github, route, parameters, pause) {
- for (let attempt = 0; ; attempt++) {
- try {
- return await github.request(`GET ${route}`, parameters);
- } catch (error) {
- const retryAfter = Number(error.response?.headers?.['retry-after']);
- const delay = error.status === 429 ? retryAfter * 1000 : 250 * 2 ** attempt;
- if (
- attempt === 2 ||
- ![429, 500, 502, 503, 504].includes(error.status) ||
- !Number.isFinite(delay) ||
- delay > 2000 ||
- delay < 0
- )
- throw error;
- await pause(delay);
- }
- }
-}
-
-async function collect(github, route, pause, maxPages = 20) {
- const all = [];
- for (let page = 1; page <= maxPages; page++) {
- const response = await read(github, route, { per_page: 100, page }, pause);
- if (!Array.isArray(response.data) || response.data.length > 100) refuse('invalid-page');
- all.push(...response.data);
- const next = /<([^>]+)>;\s*rel="next"/.exec(response.headers?.link ?? '');
- if (!next) {
- if (response.data.length < 100) return all;
- continue;
- }
- const url = new URL(next[1]);
- if (
- !response.data.length ||
- url.origin !== 'https://api.github.com' ||
- url.pathname !== route.split('?')[0] ||
- url.searchParams.get('page') !== String(page + 1) ||
- url.searchParams.get('per_page') !== '100'
- )
- refuse('invalid-pagination-link');
- }
- refuse('pagination-limit');
-}
-
-async function inventory(github, pause) {
- const response = await read(github, `${ROOT}/pages`, {}, pause);
- if (response.data.build_type !== 'workflow' || response.data.html_url !== SITE)
- refuse('unknown-pages-site');
- const records = await collect(github, `${ROOT}/deployments?environment=github-pages`, pause);
- for (const record of records) {
- if (!validId(record.id)) refuse('invalid-inventory');
- record.statuses = await collect(github, `${ROOT}/deployments/${record.id}/statuses`, pause, 5);
- }
- return records;
-}
-
-async function provePublication(github, record, pause) {
- const success = [...record.statuses].filter((status) => status.state === 'success').sort(rank)[0];
- const identity = jobIdentity(success);
- if (!identity) refuse('unknown-publication');
- const { data: run } = await read(github, `${ROOT}/actions/runs/${identity.run}`, {}, pause);
- const { data: job } = await read(github, `${ROOT}/actions/jobs/${identity.job}`, {}, pause);
- if (
- run.id !== identity.run ||
- run.repository?.full_name !== REPOSITORY ||
- run.head_repository?.full_name !== REPOSITORY ||
- run.name !== 'CI / CD' ||
- run.path !== '.github/workflows/ci.yml' ||
- run.event !== 'push' ||
- run.head_branch !== 'main' ||
- run.head_sha !== record.sha ||
- run.status !== 'completed' ||
- run.conclusion !== 'success' ||
- job.id !== identity.job ||
- job.run_id !== run.id ||
- job.name !== '🚀 Deploy to GitHub Pages' ||
- job.status !== 'completed' ||
- job.conclusion !== 'success' ||
- !job.steps?.some(
- (step) => step.name === 'Deploy to GitHub Pages' && step.conclusion === 'success',
- )
- )
- refuse('unknown-publication');
-}
-
-const fingerprint = (record) =>
- JSON.stringify({
- id: record.id,
- sha: record.sha,
- ref: record.ref,
- environment: record.environment,
- created_at: record.created_at,
- task: record.task,
- transient_environment: record.transient_environment,
- production_environment: record.production_environment,
- app: record.performed_via_github_app,
- statuses: [...record.statuses].sort(rank),
- });
+const BACKLOG_REASONS = ['batch-limit', 'budget-limit'];
-export async function reconcilePages({
+export async function runAction({
github,
- authority,
+ context,
+ core,
holds,
- dryRun = false,
- pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ workflowRef,
+ reconcile = reconcilePages,
}) {
- const admitted = admission(authority);
- const result = {
- trigger: authority.eventName,
- mode: dryRun ? 'dry-run' : admitted,
- queried: 0,
- classified: 0,
- protected: 0,
- candidate: 0,
- attempted: 0,
- inactivated: 0,
- deleted: 0,
- failed: 0,
- skipped: 0,
- reasons: {},
- current: null,
- manifest: [],
- followUpRequired: false,
- };
- let considered = 0;
- const reason = (key, count = 1) => {
- result.reasons[key] = (result.reasons[key] ?? 0) + count;
- };
- try {
- if (admitted === 'refused') refuse('untrusted-trigger');
- const records = await inventory(github, pause);
- result.queried = records.length;
- const plan = planPruning(records, holds);
- result.classified = records.length;
- result.protected = plan.protectedRecords.length;
- result.candidate = plan.candidates.length;
- result.current = plan.current;
- result.manifest = plan.protectedRecords
- .map((record) => ({ ...record, disposition: 'protected' }))
- .concat(
- plan.candidates.map((record) => ({ id: record.id, disposition: 'candidate', reasons: [] })),
- );
- for (const record of plan.protectedRecords) for (const key of record.reasons) reason(key);
- if (plan.blocked) refuse('unproved-current-or-unsettled');
- if (plan.current)
- await provePublication(
- github,
- records.find((record) => record.id === plan.current),
- pause,
- );
- for (const candidate of plan.candidates.slice(0, MAX_BATCH))
- await provePublication(github, candidate, pause);
- if (result.mode === 'dry-run') {
- result.skipped = plan.candidates.length;
- if (result.skipped) reason('dry-run', result.skipped);
- result.followUpRequired = records.length > 3;
- return result;
- }
- // QNBS-v3: holds are immutable at the executing main SHA; a new main commit invalidates admission before each mutation.
- const revalidate = async (candidate, expected) => {
- const fresh = await inventory(github, pause);
- const next = planPruning(fresh, holds);
- const record = fresh.find((item) => item.id === candidate.id);
- if (next.blocked || next.current !== plan.current) refuse('current-moved');
- if (
- !record ||
- !next.candidates.some((item) => item.id === candidate.id) ||
- fingerprint(record) !== expected
- )
- return null;
- await provePublication(
- github,
- fresh.find((item) => item.id === next.current),
- pause,
- );
- await provePublication(github, record, pause);
- const { data: branch } = await read(github, `${ROOT}/branches/main`, {}, pause);
- if (branch.commit?.sha !== authority.sha) refuse('main-moved');
- return record;
- };
- for (const candidate of plan.candidates.slice(0, MAX_BATCH)) {
- considered++;
- let fresh = await revalidate(candidate, fingerprint(candidate));
- if (!fresh) {
- result.skipped++;
- reason('stale-candidate');
- continue;
- }
- result.attempted++;
- const latest = [...fresh.statuses].sort(rank)[0];
- if (latest.state !== 'inactive') {
- const { data: inactive } = await github.request(
- `POST ${ROOT}/deployments/${candidate.id}/statuses`,
- {
- state: 'inactive',
- environment: 'github-pages',
- environment_url: SITE,
- log_url: latest.log_url,
- auto_inactive: false,
- },
- );
- if (
- !validId(inactive.id) ||
- inactive.state !== 'inactive' ||
- !timestamp(inactive.created_at)
- )
- refuse('invalid-inactivation-response');
- result.inactivated++;
- fresh.statuses.push(inactive);
- fresh = await revalidate(candidate, fingerprint(fresh));
- if (!fresh) {
- result.skipped++;
- reason('status-moved');
- continue;
- }
- }
- // QNBS-v3: failed/ambiguous POST never reaches DELETE; writes are never retried against an assumed inventory.
- await github.request(`DELETE ${ROOT}/deployments/${candidate.id}`);
- result.deleted++;
- }
- result.followUpRequired = records.length - result.deleted > 3;
- if (plan.candidates.length > MAX_BATCH)
- reason('batch-limit', plan.candidates.length - MAX_BATCH);
- } catch (error) {
- result.failed++;
- result.followUpRequired = true;
- reason(
- error.reason ??
- ([404, 409, 429, 500, 502, 503, 504].includes(error.status)
- ? `api-${error.status}`
- : 'api-or-inventory-error'),
- );
- if (result.candidate > considered) reason('aborted-batch', result.candidate - considered);
- }
- result.skipped += result.candidate - considered;
- return result;
-}
-
-export async function runAction({ github, context, core, holds, workflowRef }) {
- const result = await reconcilePages({
+ const result = await reconcile({
github,
holds,
authority: {
@@ -396,18 +42,15 @@ export async function runAction({ github, context, core, holds, workflowRef }) {
.addCodeBlock(JSON.stringify(summary, null, 2), 'json')
.addCodeBlock(JSON.stringify(manifest, null, 2), 'json')
.write();
+ if (BACKLOG_REASONS.some((key) => result.reasons[key]))
+ core.warning?.('Pages record backlog exceeds one bounded pass; see the capacity guidance.');
if (result.failed)
core.setFailed('Pages reconciliation failed closed; inspect sanitized counts and reasons.');
}
// QNBS-v3: the local CLI exposes GET only and forces dry-run; credentials never appear in output or request errors.
-async function localDryRun() {
- if (process.argv.length !== 3 || process.argv[2] !== '--dry-run') refuse('invalid-cli-mode');
- const token = execFileSync('gh', ['auth', 'token'], {
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'ignore'],
- }).trim();
- const github = {
+function readOnlyClient(token) {
+ return {
request: async (route, params = {}) => {
if (!route.startsWith('GET ')) refuse('local-writes-forbidden');
const url = new URL(`https://api.github.com${route.slice(4)}`);
@@ -428,11 +71,18 @@ async function localDryRun() {
return { data: await response.json(), headers: Object.fromEntries(response.headers) };
},
};
+}
+
+async function localDryRun() {
+ if (process.argv.length !== 3 || process.argv[2] !== '--dry-run') refuse('invalid-cli-mode');
+ const token = execFileSync('gh', ['auth', 'token'], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ const holdsUrl = new URL('../.github/pages-pruning-holds.json', import.meta.url);
const result = await reconcilePages({
- github,
- holds: JSON.parse(
- readFileSync(new URL('../.github/pages-pruning-holds.json', import.meta.url), 'utf8'),
- ),
+ github: readOnlyClient(token),
+ holds: JSON.parse(readFileSync(holdsUrl, 'utf8')),
dryRun: true,
authority: {
repository: REPOSITORY,
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index b1d67988b..f10d78196 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -1,126 +1,53 @@
import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { readdirSync, readFileSync } from 'node:fs';
import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { REQUEST_LIMIT } from '../../../scripts/pages-pruning/github.mjs';
import {
admission,
+ DESTRUCTIVE_WRITES_ADMITTED,
planPruning,
- REPOSITORY,
- reconcilePages,
runAction,
+ validateHolds,
} from '../../../scripts/prune-pages-deployments.mjs';
-
-const ROOT = `/repos/${REPOSITORY}`;
-const SITE = 'https://qnbs.github.io/WorldScript-Studio/';
-const date = (id) => new Date(Date.UTC(2026, 0, 1) + id * 1000).toISOString();
-const authority = {
- repository: REPOSITORY,
- ref: 'refs/heads/main',
- sha: 'a'.repeat(40),
- workflowRef: `${REPOSITORY}/.github/workflows/prune-deployments.yml@refs/heads/main`,
- eventName: 'schedule',
+import {
+ apiError,
+ authority,
+ client,
+ date,
+ execute,
+ executeDestructive,
+ holds,
+ job as jobFixture,
+ jobUrl,
+ linkHeader,
+ publication,
+ REPO_ID,
+ ROOT,
+ reads,
+ record,
+ records,
+ status,
+ writes,
+} from './support/pagesPruningFixture.mjs';
+
+const BOT = { login: 'github-actions[bot]' };
+const candidates = (result) =>
+ result.manifest.filter((item) => item.disposition === 'candidate').map((item) => item.id);
+const disposition = (result, id) => result.manifest.find((item) => item.id === id);
+const nonSuccess = (item, state = 'failure') => {
+ item.statuses = [status(item.id, { state })];
+ return item;
};
-const holds = { rollback: [], evidence: [] };
-function record(id) {
- return {
- id,
- created_at: date(id),
- sha: id.toString(16).padStart(40, '0'),
- environment: 'github-pages',
- ref: 'main',
- task: 'deploy',
- transient_environment: false,
- production_environment: false,
- performed_via_github_app: { id: 15368, slug: 'github-actions' },
- statuses: [
- {
- id: 100 + id,
- created_at: date(id),
- state: 'success',
- environment: 'github-pages',
- environment_url: SITE,
- log_url: `https://github.com/${REPOSITORY}/actions/runs/${1000 + id}/job/${2000 + id}`,
- },
- ],
- };
-}
-const records = (count) => Array.from({ length: count }, (_, index) => record(index + 1));
-const publication = (id) => ({
- id: 1000 + id,
- repository: { full_name: REPOSITORY },
- head_repository: { full_name: REPOSITORY },
- name: 'CI / CD',
- path: '.github/workflows/ci.yml',
- event: 'push',
- head_branch: 'main',
- head_sha: record(id).sha,
- status: 'completed',
- conclusion: 'success',
-});
-const apiError = (status, retryAfter) =>
- Object.assign(new Error('PRIVATE provider detail'), {
- status,
- response: { headers: { 'retry-after': retryAfter } },
- });
-function client(initial, intercept = () => undefined) {
- const state = structuredClone(initial);
- const calls = [];
- let statusId = 10000;
- return {
- state,
- calls,
- request: async (route, parameters = {}) => {
- calls.push({ route, parameters });
- const override = await intercept(route, parameters, state, calls);
- if (override) return override;
- const response = (data) => ({ data: structuredClone(data), headers: {} });
- if (route === `GET ${ROOT}/pages`)
- return response({ build_type: 'workflow', html_url: SITE });
- if (route === `GET ${ROOT}/branches/main`)
- return response({ commit: { sha: authority.sha } });
- if (route === `GET ${ROOT}/deployments?environment=github-pages`)
- return response(
- state
- .slice((parameters.page - 1) * 100, parameters.page * 100)
- .map(({ statuses, ...item }) => item),
- );
- const deployment = /\/deployments\/(\d+)(\/statuses)?$/.exec(route);
- if (deployment) {
- const item = state.find((value) => value.id === Number(deployment[1]));
- if (!item) throw apiError(404);
- if (route.startsWith('GET '))
- return response(item.statuses.slice((parameters.page - 1) * 100, parameters.page * 100));
- if (route.startsWith('POST ')) {
- assert.equal(parameters.auto_inactive, false);
- const status = { ...parameters, id: statusId++, created_at: date(statusId) };
- item.statuses.push(status);
- return response(status);
- }
- assert.equal([...item.statuses].sort((a, b) => b.id - a.id)[0].state, 'inactive');
- state.splice(state.indexOf(item), 1);
- return response(undefined);
- }
- const runId = /\/actions\/runs\/(\d+)$/.exec(route);
- if (runId) {
- const id = Number(runId[1]) - 1000;
- return response(publication(id));
- }
- const jobId = /\/actions\/jobs\/(\d+)$/.exec(route);
- if (jobId)
- return response({
- id: Number(jobId[1]),
- run_id: Number(jobId[1]) - 1000,
- name: '🚀 Deploy to GitHub Pages',
- status: 'completed',
- conclusion: 'success',
- steps: [{ name: 'Deploy to GitHub Pages', conclusion: 'success' }],
- });
- throw new Error(`Unexpected fixture route ${route}`);
- },
- };
-}
-const writes = (github) => github.calls.filter((call) => !call.route.startsWith('GET '));
-const execute = (github, options = {}) =>
- reconcilePages({ github, authority, holds, pause: async () => {}, ...options });
+// A history with failed publications between the current and the previous good one.
+const gapped = () => {
+ const input = records(8);
+ for (const id of [5, 6, 7]) nonSuccess(input[id - 1]);
+ return input;
+};
+const forgedSuccess = (input, id, at) =>
+ input[id - 1].statuses.push(status(id, { id: 700 + id, created_at: date(at), creator: BOT }));
for (const count of [0, 1, 2, 3, 4, 20])
test(`plans ${count} records with newest-three retention`, () => {
@@ -170,8 +97,11 @@ for (const environment of ['Production', 'Preview', 'vercel', 'unknown', '', und
});
for (const change of [
- { performed_via_github_app: null },
{ performed_via_github_app: { id: 15368, slug: 'other' } },
+ { performed_via_github_app: { id: 1, slug: 'vercel' } },
+ { creator: null },
+ { creator: { login: '' } },
+ { creator: BOT },
{ ref: 'feature' },
{ task: 'other' },
{ sha: 'unknown' },
@@ -184,8 +114,15 @@ for (const change of [
assert.ok(!planPruning(input, holds).candidates.some((item) => item.id === 1));
});
+test('accepts bot-triggered records that carry no GitHub App identity', () => {
+ const input = records(5);
+ input[0].performed_via_github_app = null;
+ input[0].creator = { login: 'cursor[bot]' };
+ assert.ok(planPruning(input, holds).candidates.some((item) => item.id === 1));
+});
+
test('refuses malformed, duplicate or incomplete inventories and holds', () => {
- assert.throws(() => planPruning(records(5), holds, false), /incomplete/);
+ assert.throws(() => planPruning(records(5), holds, { complete: false }), /incomplete/);
assert.throws(() => planPruning([record(1), record(1)], holds), /invalid-inventory/);
assert.throws(
() => planPruning([{ ...record(1), created_at: 'invalid' }], holds),
@@ -197,6 +134,8 @@ test('refuses malformed, duplicate or incomplete inventories and holds', () => {
);
assert.throws(() => planPruning(records(5), { rollback: [] }), /invalid-holds/);
assert.throws(() => planPruning(records(5), { rollback: [], evidence: ['1'] }), /invalid-holds/);
+ for (const bad of [null, [], 'holds', { rollback: [], evidence: [], extra: [] }])
+ assert.throws(() => validateHolds(bad), /invalid-holds/);
});
for (const state of ['queued', 'pending', 'waiting', 'in_progress', 'unknown'])
@@ -206,6 +145,21 @@ for (const state of ['queued', 'pending', 'waiting', 'in_progress', 'unknown'])
assert.equal(planPruning(input, holds).blocked, true);
});
+test('refuses normalized malformed dates and cross-record status ID collisions', () => {
+ for (const created_at of ['2026-02-31T00:00:00Z', '2026-01-01T24:00:00Z', 'Jan 1 2026Z']) {
+ assert.throws(() => planPruning([{ ...record(1), created_at }], holds), /invalid-inventory/);
+ }
+ const input = records(4);
+ input[0].statuses[0].id = input[1].statuses[0].id;
+ assert.throws(() => planPruning(input, holds), /invalid-statuses/);
+});
+
+test('contradictory status environment is unknown ownership and preserved', () => {
+ const input = records(5);
+ input[0].statuses[0].environment = 'Production';
+ assert.ok(!planPruning(input, holds).candidates.some((item) => item.id === 1));
+});
+
for (const eventName of ['workflow_run', 'pull_request', 'pull_request_target', 'push', 'unknown'])
test(`rejects trigger ${eventName} with zero API writes`, async () => {
const github = client(records(5));
@@ -220,6 +174,8 @@ for (const change of [
{ ref: 'refs/pull/1/merge' },
{ sha: 'unknown' },
{ workflowRef: 'other@refs/heads/main' },
+ { eventName: 'constructor' },
+ { eventName: 'toString' },
])
test(`rejects execution authority ${JSON.stringify(change)}`, () => {
assert.equal(admission({ ...authority, ...change }), 'refused');
@@ -227,7 +183,7 @@ for (const change of [
test('dispatch always performs a zero-write dry-run, regardless of caller payload', async () => {
const github = client(records(5));
- const result = await execute(github, {
+ const result = await executeDestructive(github, {
authority: { ...authority, eventName: 'workflow_dispatch', inputs: { delete: true } },
});
assert.equal(result.mode, 'dry-run');
@@ -236,13 +192,67 @@ test('dispatch always performs a zero-write dry-run, regardless of caller payloa
assert.deepEqual(writes(github), []);
});
+test('an explicit dry-run stays read-only even when the destructive gate is open', async () => {
+ const github = client(records(5));
+ const result = await executeDestructive(github, { dryRun: true });
+ assert.equal(result.mode, 'dry-run');
+ assert.equal(result.deleted, 0);
+ assert.deepEqual(writes(github), []);
+});
+
+test('the shipped destructive gate is closed and a scheduled run stays read-only', async () => {
+ assert.equal(DESTRUCTIVE_WRITES_ADMITTED, false);
+ const github = client(records(5));
+ const result = await execute(github);
+ assert.equal(result.mode, 'dry-run');
+ assert.equal(result.candidate, 2);
+ assert.equal(result.deleted, 0);
+ assert.equal(result.failed, 0);
+ assert.equal(result.reasons['destructive-gate-closed'], 1);
+ assert.deepEqual(writes(github), []);
+});
+
+const workflowSource = (name) =>
+ readFileSync(new URL(`../../../.github/workflows/${name}`, import.meta.url), 'utf8');
+
+test('the workflow token carries deployments:write only while the destructive gate is open', () => {
+ const grantsWrite = /^\s+deployments:\s*write\s*$/m.test(workflowSource('prune-deployments.yml'));
+ assert.equal(grantsWrite, DESTRUCTIVE_WRITES_ADMITTED);
+});
+
+test('only the Pages publisher and the gated pruner can write deployment records in this repository', () => {
+ const directory = new URL('../../../.github/workflows/', import.meta.url);
+ const names = readdirSync(directory).filter((name) => name.endsWith('.yml'));
+ const using = (pattern) => names.filter((name) => pattern.test(workflowSource(name))).sort();
+ assert.deepEqual(
+ using(/^\s+deployments:\s*write\s*$/m),
+ DESTRUCTIVE_WRITES_ADMITTED ? ['prune-deployments.yml'] : [],
+ );
+ assert.deepEqual(using(/^\s+pages:\s*write\s*$/m), ['ci.yml']);
+ assert.deepEqual(using(/actions\/deploy-pages@/), ['ci.yml']);
+ assert.deepEqual(using(/createDeployment(Status)?\b|deployments\/\$\{[^}]+\}\/statuses/), []);
+});
+
+test('real workflow admits only main schedule and read-only dispatch with serialized writer', () => {
+ const source = workflowSource('prune-deployments.yml');
+ assert.ok(!source.includes('workflow_run:'));
+ assert.ok(!source.includes('inputs:'));
+ assert.ok(source.includes("cron: '0 3 * * *'"));
+ assert.ok(source.includes('cancel-in-progress: false'));
+ assert.ok(source.includes("github.repository == 'qnbs/WorldScript-Studio'"));
+ assert.ok(source.includes("github.ref == 'refs/heads/main'"));
+ assert.ok(source.includes(`ref: \${{ github.sha }}`));
+ assert.ok(source.includes('persist-credentials: false'));
+ assert.ok(source.includes('retries: 0'));
+});
+
test('inactivates then revalidates before deletion; repeated run is idempotent', async () => {
const github = client(records(5));
- const first = await execute(github);
+ const first = await executeDestructive(github);
assert.equal(first.failed, 0);
assert.equal(first.deleted, 2);
assert.equal(first.inactivated, 2);
- const second = await execute(github);
+ const second = await executeDestructive(github);
assert.equal(second.deleted, 0);
assert.equal(writes(github).length, 4);
const routes = github.calls.map((call) => call.route);
@@ -263,17 +273,17 @@ test('already inactive candidates require no POST', async () => {
state: 'inactive',
});
const github = client(input);
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.deleted, 1);
assert.equal(writes(github).length, 1);
});
-for (const status of [404, 409, 429, 500, 503])
- test(`failed inactivation ${status} never reaches DELETE or retries writes`, async () => {
+for (const code of [403, 404, 409, 429, 500, 503])
+ test(`failed inactivation ${code} never reaches DELETE or retries writes`, async () => {
const github = client(records(4), (route) => {
- if (route.startsWith('POST ')) throw apiError(status, '1');
+ if (route.startsWith('POST ')) throw apiError(code, '1');
});
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.failed, 1);
assert.equal(result.deleted, 0);
assert.deepEqual(
@@ -283,23 +293,34 @@ for (const status of [404, 409, 429, 500, 503])
assert.ok(!JSON.stringify(result).includes('PRIVATE'));
});
-for (const status of [404, 409, 429, 500, 503])
- test(`failed DELETE ${status} is non-success and never blindly retried`, async () => {
+for (const code of [404, 409, 429, 500, 503])
+ test(`failed DELETE ${code} is non-success and never blindly retried`, async () => {
const github = client(records(4), (route) => {
- if (route.startsWith('DELETE ')) throw apiError(status);
+ if (route.startsWith('DELETE ')) throw apiError(code);
});
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.failed, 1);
assert.equal(result.inactivated, 1);
assert.equal(result.deleted, 0);
assert.equal(writes(github).filter((call) => call.route.startsWith('DELETE ')).length, 1);
});
+test('a malformed inactivation response never reaches DELETE', async () => {
+ const github = client(records(4), (route) =>
+ route.startsWith('POST ')
+ ? { data: { id: 'x', state: 'success', created_at: 'now' }, headers: {} }
+ : undefined,
+ );
+ const result = await executeDestructive(github);
+ assert.equal(result.reasons['invalid-inactivation-response'], 1);
+ assert.equal(writes(github).filter((call) => call.route.startsWith('DELETE ')).length, 0);
+});
+
test('bounded reads exhaust retries without writes', async () => {
const github = client(records(4), () => {
throw apiError(503);
});
- assert.equal((await execute(github)).failed, 1);
+ assert.equal((await executeDestructive(github)).failed, 1);
assert.equal(github.calls.length, 3);
assert.deepEqual(writes(github), []);
});
@@ -308,11 +329,11 @@ test('bounded transient read retry succeeds; long 429 Retry-After fails closed',
const github = client(records(4), (_route, _parameters, _state, calls) => {
if (calls.length === 1) throw apiError(503);
});
- assert.equal((await execute(github)).deleted, 1);
+ assert.equal((await executeDestructive(github)).deleted, 1);
const limited = client(records(4), () => {
throw apiError(429, '60');
});
- assert.equal((await execute(limited)).failed, 1);
+ assert.equal((await executeDestructive(limited)).failed, 1);
assert.equal(limited.calls.length, 1);
});
@@ -320,7 +341,7 @@ test('truncated pagination cannot authorize writes', async () => {
const github = client(records(4), (route) =>
route.includes('?environment=') ? { data: records(100), headers: {} } : undefined,
);
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.failed, 1);
assert.equal(result.reasons['pagination-limit'], 1);
assert.deepEqual(writes(github), []);
@@ -330,18 +351,138 @@ test('a failed later inventory page is failure, never a partial clean result', a
const github = client(records(4), (route, parameters) => {
if (!route.includes('?environment=')) return;
if (parameters.page === 2) throw apiError(404);
+ return { data: [record(1)], headers: linkHeader(`${ROOT}/deployments`, 2) };
+ });
+ assert.equal((await executeDestructive(github)).failed, 1);
+ assert.deepEqual(writes(github), []);
+});
+
+test('exactly full terminal page is probed for completeness in zero-write dry-run', async () => {
+ const github = client(records(100));
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 0);
+ assert.equal(result.queried, 100);
+ assert.ok(
+ github.calls.some((call) => call.route.includes('?environment=') && call.parameters.page === 2),
+ );
+ assert.deepEqual(writes(github), []);
+});
+
+const paged = (input, headers) =>
+ client(input, (route, parameters, state) => {
+ if (!route.includes('?environment=')) return;
+ const first = parameters.page === 1;
return {
- data: [record(1)],
- headers: {
- link: `; rel="next"`,
- },
+ data: structuredClone(first ? state.slice(0, 2) : state.slice(2)),
+ headers: first ? headers : {},
};
});
- assert.equal((await execute(github)).failed, 1);
+
+for (const [name, path] of [
+ ['repository path', `${ROOT}/deployments`],
+ ['repository id path', `/repositories/${REPO_ID}/deployments`],
+])
+ test(`a complete multi-page inventory accepts the ${name} next link`, async () => {
+ const github = paged(records(5), linkHeader(path, 2));
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 2);
+ const lookups = reads(github, /^GET \/repos\/qnbs\/WorldScript-Studio$/).length;
+ assert.equal(lookups, path.startsWith('/repositories') ? 1 : 0);
+ });
+
+const query = 'environment=github-pages&per_page=100';
+const unsafeLinks = {
+ 'a different repository id': `https://api.github.com/repositories/9999/deployments?${query}&page=2`,
+ 'a foreign origin': `https://example.test/repositories/${REPO_ID}/deployments?${query}&page=2`,
+ 'a foreign repository path': `https://api.github.com/repos/other/Repo/deployments?${query}&page=2`,
+ 'the wrong next page': `https://api.github.com${ROOT}/deployments?${query}&page=3`,
+ 'an extra query parameter': `https://api.github.com${ROOT}/deployments?${query}&page=2&state=active`,
+ 'a different environment': `https://api.github.com${ROOT}/deployments?environment=Production&per_page=100&page=2`,
+ 'a duplicated parameter': `https://api.github.com${ROOT}/deployments?${query}&page=2&page=2`,
+ 'a malformed url': '::not a url::',
+};
+for (const [name, href] of Object.entries(unsafeLinks))
+ test(`refuses a next link with ${name} and never writes`, async () => {
+ const github = paged(records(5), { link: `<${href}>; rel="next"` });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['invalid-pagination-link'], 1);
+ assert.deepEqual(writes(github), []);
+ });
+
+test('refuses a repository id lookup that names another repository', async () => {
+ const github = client(records(5), (route, _parameters, state) => {
+ if (route === `GET ${ROOT}`)
+ return { data: { id: REPO_ID, full_name: 'other/Repo' }, headers: {} };
+ if (route.includes('?environment='))
+ return {
+ data: structuredClone(state.slice(0, 2)),
+ headers: linkHeader(`/repositories/${REPO_ID}/deployments`, 2),
+ };
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.reasons['unknown-repository'], 1);
assert.deepEqual(writes(github), []);
});
-for (const change of [
+test('status history over several pages loads completely under the repository id path', async () => {
+ const input = records(5);
+ const older = Array.from({ length: 149 }, (_, index) =>
+ status(3, {
+ id: 5000 + index,
+ state: 'queued',
+ created_at: new Date(Date.UTC(2025, 0, 1) + index * 1000).toISOString(),
+ }),
+ );
+ input[2].statuses = [...older, status(3)];
+ const github = client(input, (route, parameters, state) => {
+ if (route !== `GET ${ROOT}/deployments/3/statuses`) return;
+ const first = parameters.page === 1;
+ return {
+ data: structuredClone(state[2].statuses.slice(first ? 0 : 100, first ? 100 : 200)),
+ headers: first
+ ? {
+ link: `; rel="next"`,
+ }
+ : {},
+ };
+ });
+ const result = await execute(github);
+ assert.equal(result.failed, 0);
+ assert.ok(reads(github, /deployments\/3\/statuses$/).some((call) => call.parameters.page === 2));
+});
+
+test('an unproved success between current and a known-good rollback never consumes the rollback slot', async () => {
+ const input = gapped();
+ forgedSuccess(input, 2, 4.5);
+ const shapeOnly = planPruning(input, holds);
+ assert.ok(
+ shapeOnly.candidates.some((item) => item.id === 4),
+ 'the shape-only plan alone would expose the genuine rollback',
+ );
+ const result = await execute(client(input));
+ assert.equal(result.failed, 0);
+ assert.equal(result.current, 8);
+ assert.equal(result.rollback, 4);
+ assert.equal(disposition(result, 4).disposition, 'protected');
+ assert.ok(disposition(result, 2).reasons.includes('unproved-publication'));
+ assert.deepEqual(candidates(result), [3, 1]);
+});
+
+test('refuses when no rollback publication can be proven', async () => {
+ const github = client(records(6), (route) =>
+ route.includes('/actions/runs/') && !route.endsWith('/1006')
+ ? { data: publication(1, { head_branch: 'feature' }), headers: {} }
+ : undefined,
+ );
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['no-proven-rollback'], 1);
+ assert.deepEqual(writes(github), []);
+});
+
+const runChanges = [
{ event: 'pull_request' },
{ event: 'workflow_dispatch' },
{ head_branch: 'v1.30' },
@@ -349,7 +490,10 @@ for (const change of [
{ conclusion: 'failure' },
{ conclusion: 'cancelled' },
{ path: '.github/workflows/other.yml' },
-])
+ { actor: { login: 'intruder' }, triggering_actor: { login: 'intruder' } },
+ { actor: null, triggering_actor: null },
+];
+for (const change of runChanges)
test(`rejects publication evidence ${JSON.stringify(change)}`, async () => {
const github = client(records(4), (route) =>
route.includes('/actions/runs/')
@@ -359,48 +503,182 @@ for (const change of [
}
: undefined,
);
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.failed, 1);
+ assert.equal(result.reasons['unproved-current'], 1);
assert.deepEqual(writes(github), []);
});
+for (const change of [
+ { conclusion: 'failure' },
+ { name: 'Other job' },
+ { steps: [] },
+ { run_id: 1 },
+])
+ test(`rejects job evidence ${JSON.stringify(change)}`, async () => {
+ const github = client(records(4), (route) =>
+ route.includes('/actions/jobs/')
+ ? { data: { ...jobFixture(Number(route.split('/').at(-1))), ...change }, headers: {} }
+ : undefined,
+ );
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 1);
+ assert.deepEqual(writes(github), []);
+ });
+
+const forgeries = {
+ 'a status authored by the Actions bot': (item) => {
+ item.statuses[0].creator = BOT;
+ },
+ 'a status authored by another principal': (item) => {
+ item.statuses[0].creator = { login: 'intruder' };
+ },
+ 'a status without an author': (item) => {
+ delete item.statuses[0].creator;
+ },
+ 'a deployment authored by another principal': (item) => {
+ item.creator = { login: 'intruder' };
+ },
+ 'a log_url naming another legitimate publication job': (item) => {
+ item.statuses[0].log_url = jobUrl(5);
+ },
+};
+for (const [name, forge] of Object.entries(forgeries))
+ test(`a candidate with ${name} is protected and never deleted`, async () => {
+ const input = records(5);
+ forge(input[0]);
+ const github = client(input);
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 1);
+ assert.equal(result.reasons['unproved-publication'], 1);
+ assert.deepEqual(
+ github.state.map((item) => item.id),
+ [1, 3, 4, 5],
+ );
+ });
+
+test('a candidate whose publication run no longer exists is protected', async () => {
+ const github = client(records(5), (route) => {
+ if (route.endsWith('/actions/runs/1001')) throw apiError(404);
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.reasons['unproved-publication'], 1);
+ assert.ok(github.state.some((item) => item.id === 1));
+});
+
+test('a bot-triggered record is retired when its run actor matches the record authors', async () => {
+ const input = records(5);
+ input[0].performed_via_github_app = null;
+ input[0].creator = { login: 'cursor[bot]' };
+ input[0].statuses[0].creator = { login: 'cursor[bot]' };
+ const github = client(input, (route) =>
+ route.endsWith('/actions/runs/1001')
+ ? {
+ data: publication(1, {
+ actor: { login: 'cursor[bot]' },
+ triggering_actor: { login: 'cursor[bot]' },
+ }),
+ headers: {},
+ }
+ : undefined,
+ );
+ const result = await executeDestructive(github);
+ assert.equal(result.deleted, 2);
+ assert.deepEqual(
+ github.state.map((item) => item.id),
+ [3, 4, 5],
+ );
+});
+
test('main moving invalidates hold and executable authority before mutation', async () => {
const github = client(records(4), (route) =>
route.endsWith('/branches/main')
? { data: { commit: { sha: 'b'.repeat(40) } }, headers: {} }
: undefined,
);
- assert.equal((await execute(github)).reasons['main-moved'], 1);
+ assert.equal((await executeDestructive(github)).reasons['main-moved'], 1);
assert.deepEqual(writes(github), []);
});
test('candidate SHA drift is skipped with zero writes', async () => {
- let inventories = 0;
+ let lists = 0;
const github = client(records(4), (route, _parameters, state) => {
- if (route.includes('?environment=') && ++inventories === 2) state[0].sha = 'b'.repeat(40);
+ if (route.includes('?environment=') && ++lists === 2) state[0].sha = 'b'.repeat(40);
});
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.skipped, 1);
assert.deepEqual(writes(github), []);
});
test('status promotion after inactivation prevents deletion', async () => {
- let inventories = 0;
+ let lists = 0;
const github = client(records(4), (route, _parameters, state) => {
- if (route.includes('?environment=') && ++inventories === 3)
+ if (route.includes('?environment=') && ++lists === 3)
state[0].statuses.push({ ...state[0].statuses[0], id: 99999, created_at: date(99999) });
});
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.failed, 1);
assert.equal(result.deleted, 0);
assert.equal(writes(github).length, 1);
});
+test('a record whose list key changed is re-read and a new publication head stops the run', async () => {
+ let lists = 0;
+ const github = client(records(8), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++lists === 2) {
+ state[1].statuses.push(status(2, { id: 5555, created_at: date(500) }));
+ state[1].updated_at = date(500);
+ }
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['current-moved'], 1);
+ assert.deepEqual(writes(github), []);
+});
+
+for (const [name, extra] of [
+ ['a successful', record(99)],
+ ['an in-flight', record(99, { statuses: [status(99, { state: 'in_progress' })] })],
+])
+ test(`${name} publication appearing during the run stops before any write`, async () => {
+ let lists = 0;
+ const github = client(records(6), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++lists === 2) state.push(structuredClone(extra));
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.reasons['current-moved'], 1);
+ assert.deepEqual(writes(github), []);
+ });
+
+const arrivals = [
+ (size) => record(size + 1),
+ (size) => nonSuccess(record(size + 1)),
+ (size) => nonSuccess(record(size + 1), 'in_progress'),
+];
+const withFailures = (size) => {
+ const input = records(size);
+ for (const item of input) if (item.id % 3 === 0) nonSuccess(item);
+ return input;
+};
+test('a stale snapshot never turns a genuine candidate into a protected record after a new publication', () => {
+ for (const size of [4, 6, 9, 14])
+ for (const build of [records, withFailures])
+ for (const arrive of arrivals) {
+ const stale = build(size);
+ const fresh = [...stale, arrive(size)];
+ const before = planPruning(stale, holds).candidates.map((item) => item.id);
+ const after = new Set(planPruning(fresh, holds).candidates.map((item) => item.id));
+ for (const id of before) assert.ok(after.has(id), `record ${id} of ${size}`);
+ }
+});
+
test('mixed partial outcomes report confirmed deletion and subsequent failure', async () => {
const github = client(records(6), (route) => {
if (route === `POST ${ROOT}/deployments/2/statuses`) throw apiError(409);
});
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.deleted, 1);
assert.equal(result.failed, 1);
assert.equal(result.followUpRequired, true);
@@ -409,7 +687,7 @@ test('mixed partial outcomes report confirmed deletion and subsequent failure',
test('caps each invocation at ten records and reports remaining backlog', async () => {
const github = client(records(20));
- const result = await execute(github);
+ const result = await executeDestructive(github);
assert.equal(result.deleted, 10);
assert.equal(result.attempted, 10);
assert.equal(result.followUpRequired, true);
@@ -417,101 +695,124 @@ test('caps each invocation at ten records and reports remaining backlog', async
assert.equal(result.reasons['batch-limit'], 7);
});
-test('real workflow admits only main schedule and read-only dispatch with serialized writer', () => {
- const source = readFileSync(
- new URL('../../../.github/workflows/prune-deployments.yml', import.meta.url),
- 'utf8',
- );
- assert.ok(!source.includes('workflow_run:'));
- assert.ok(!source.includes('inputs:'));
- assert.ok(source.includes("cron: '0 3 * * *'"));
- assert.ok(source.includes('cancel-in-progress: false'));
- assert.ok(source.includes("github.repository == 'qnbs/WorldScript-Studio'"));
- assert.ok(source.includes("github.ref == 'refs/heads/main'"));
- assert.ok(source.includes(`ref: \${{ github.sha }}`));
- assert.ok(source.includes('persist-credentials: false'));
- assert.ok(source.includes('retries: 0'));
+const statusReads = (github) => reads(github, /\/statuses$/).length;
+for (const [count, bound] of [
+ [6, 60],
+ [50, 200],
+ [100, 260],
+])
+ test(`request volume stays bounded for ${count} records`, async () => {
+ const github = client(records(count));
+ const result = await executeDestructive(github);
+ const expected = Math.min(10, count - 3);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, expected);
+ assert.equal(result.requests, github.calls.length);
+ assert.ok(result.requests <= bound, `${result.requests} requests for ${count} records`);
+ assert.ok(result.requests < REQUEST_LIMIT);
+ // Inventory reads every history once; each write then re-reads only the candidate and both slots, twice.
+ assert.equal(statusReads(github), count + 6 * expected);
+ });
+
+test('a request budget that cannot fund another candidate stops gracefully before writes', async () => {
+ const github = client(records(100));
+ const result = await executeDestructive(github, { limit: 160 });
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 0);
+ assert.equal(result.reasons['budget-limit'], 10);
+ assert.equal(result.followUpRequired, true);
+ assert.deepEqual(writes(github), []);
});
-test('complete multi-page inventory makes stable progress', async () => {
- const github = client(records(5), (route, parameters, state) => {
- if (!route.includes('?environment=')) return;
- return parameters.page === 1
- ? {
- data: structuredClone(state.slice(0, 2)),
- headers: {
- link: `; rel="next"`,
- },
- }
- : { data: structuredClone(state.slice(2)), headers: {} };
- });
- assert.equal((await execute(github)).deleted, 2);
+test('an inventory larger than the request budget fails closed without writes', async () => {
+ const github = client(records(100));
+ const result = await executeDestructive(github, { limit: 50 });
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['request-budget'], 1);
+ assert.equal(result.requests, 50);
+ assert.deepEqual(writes(github), []);
});
-test('contradictory status environment is unknown ownership and preserved', () => {
- const input = records(5);
- input[0].statuses[0].environment = 'Production';
- assert.ok(!planPruning(input, holds).candidates.some((item) => item.id === 1));
+test('a low live rate-limit allowance stops before the next candidate', async () => {
+ const github = client(records(30), undefined, { 'x-ratelimit-remaining': '120' });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 0);
+ assert.ok(result.reasons['budget-limit'] > 0);
+ assert.deepEqual(writes(github), []);
});
-test('Actions summary reports a partial failure without provider or author metadata', async () => {
+const summaryStub = (blocks) => ({
+ addHeading() {
+ return this;
+ },
+ addCodeBlock(value) {
+ blocks.push(JSON.parse(value));
+ return this;
+ },
+ async write() {},
+});
+const actionContext = {
+ repo: { owner: 'qnbs', repo: 'WorldScript-Studio' },
+ eventName: 'schedule',
+ ref: authority.ref,
+ sha: authority.sha,
+};
+
+test('Actions summary reports a failure without provider or author metadata', async () => {
const blocks = [];
const logs = [];
const failures = [];
- const summary = {
- addHeading() {
- return this;
- },
- addCodeBlock(value) {
- blocks.push(JSON.parse(value));
- return this;
- },
- async write() {},
- };
const github = client(records(4), (route) => {
- if (route.startsWith('POST ')) throw apiError(409);
+ if (route.includes('/actions/runs/')) throw apiError(503);
});
await runAction({
github,
holds,
workflowRef: authority.workflowRef,
+ context: actionContext,
core: {
- summary,
+ summary: summaryStub(blocks),
info: (value) => logs.push(value),
setFailed: (value) => failures.push(value),
},
- context: {
- repo: { owner: 'qnbs', repo: 'WorldScript-Studio' },
- eventName: 'schedule',
- ref: authority.ref,
- sha: authority.sha,
- },
});
- assert.equal(blocks[0].attempted, 1);
- assert.equal(blocks[0].deleted, 0);
assert.equal(blocks[0].failed, 1);
- assert.equal(blocks[0].followUpRequired, true);
+ assert.equal(blocks[0].deleted, 0);
assert.equal(failures.length, 1);
assert.ok(!JSON.stringify([blocks, logs, failures]).includes('PRIVATE'));
assert.ok(!JSON.stringify(blocks).includes('log_url'));
});
-test('exactly full terminal page is probed for completeness in zero-write dry-run', async () => {
- const github = client(records(100));
- const result = await execute(github, { dryRun: true });
- assert.equal(result.failed, 0);
- assert.equal(result.queried, 100);
- assert.ok(
- github.calls.some((call) => call.route.includes('?environment=') && call.parameters.page === 2),
- );
- assert.deepEqual(writes(github), []);
+test('Actions warns when the backlog exceeds one bounded pass', async () => {
+ const warnings = [];
+ await runAction({
+ github: {},
+ holds,
+ workflowRef: authority.workflowRef,
+ context: actionContext,
+ reconcile: async () => ({ manifest: [], reasons: { 'batch-limit': 3 }, failed: 0 }),
+ core: {
+ summary: summaryStub([]),
+ info: () => {},
+ warning: (value) => warnings.push(value),
+ setFailed: () => assert.fail('a backlog is not a failure'),
+ },
+ });
+ assert.equal(warnings.length, 1);
});
-test('refuses normalized malformed dates and cross-record status ID collisions', () => {
- for (const created_at of ['2026-02-31T00:00:00Z', '2026-01-01T24:00:00Z', 'Jan 1 2026Z']) {
- assert.throws(() => planPruning([{ ...record(1), created_at }], holds), /invalid-inventory/);
+test('the local CLI refuses every mode except --dry-run before any network access', () => {
+ const script = fileURLToPath(
+ new URL('../../../scripts/prune-pages-deployments.mjs', import.meta.url),
+ );
+ for (const flag of ['--delete', '--execute', '']) {
+ const result = spawnSync(process.execPath, flag ? [script, flag] : [script], {
+ encoding: 'utf8',
+ timeout: 15000,
+ });
+ assert.equal(result.status, 1);
+ assert.equal(result.stdout, '');
+ assert.match(result.stderr, /Read-only Pages inventory failed/);
}
- const input = records(4);
- input[0].statuses[0].id = input[1].statuses[0].id;
- assert.throws(() => planPruning(input, holds), /invalid-statuses/);
});
diff --git a/tests/unit/tooling/support/pagesPruningFixture.mjs b/tests/unit/tooling/support/pagesPruningFixture.mjs
new file mode 100644
index 000000000..56b59b06f
--- /dev/null
+++ b/tests/unit/tooling/support/pagesPruningFixture.mjs
@@ -0,0 +1,162 @@
+import assert from 'node:assert/strict';
+import { REPOSITORY, reconcilePages } from '../../../../scripts/prune-pages-deployments.mjs';
+
+export const ROOT = `/repos/${REPOSITORY}`;
+export const SITE = 'https://qnbs.github.io/WorldScript-Studio/';
+export const REPO_ID = 4242;
+export const ACTOR = 'maintainer';
+export const date = (id) => new Date(Date.UTC(2026, 0, 1) + id * 1000).toISOString();
+export const authority = {
+ repository: REPOSITORY,
+ ref: 'refs/heads/main',
+ sha: 'a'.repeat(40),
+ workflowRef: `${REPOSITORY}/.github/workflows/prune-deployments.yml@refs/heads/main`,
+ eventName: 'schedule',
+};
+export const holds = { rollback: [], evidence: [] };
+
+export const jobUrl = (id) =>
+ `https://github.com/${REPOSITORY}/actions/runs/${1000 + id}/job/${2000 + id}`;
+
+export function status(id, overrides = {}) {
+ return {
+ id: 100 + id,
+ created_at: date(id),
+ state: 'success',
+ environment: 'github-pages',
+ environment_url: SITE,
+ creator: { login: ACTOR },
+ log_url: jobUrl(id),
+ ...overrides,
+ };
+}
+
+export function record(id, overrides = {}) {
+ return {
+ id,
+ created_at: date(id),
+ updated_at: date(id),
+ sha: id.toString(16).padStart(40, '0'),
+ environment: 'github-pages',
+ ref: 'main',
+ task: 'deploy',
+ transient_environment: false,
+ production_environment: false,
+ creator: { login: ACTOR },
+ performed_via_github_app: { id: 15368, slug: 'github-actions' },
+ statuses: [status(id)],
+ ...overrides,
+ };
+}
+
+export const records = (count) => Array.from({ length: count }, (_, index) => record(index + 1));
+
+export const publication = (id, overrides = {}) => ({
+ id: 1000 + id,
+ repository: { full_name: REPOSITORY },
+ head_repository: { full_name: REPOSITORY },
+ name: 'CI / CD',
+ path: '.github/workflows/ci.yml',
+ event: 'push',
+ head_branch: 'main',
+ head_sha: record(id).sha,
+ status: 'completed',
+ conclusion: 'success',
+ actor: { login: ACTOR },
+ triggering_actor: { login: ACTOR },
+ ...overrides,
+});
+
+export const job = (jobId) => ({
+ id: jobId,
+ run_id: jobId - 1000,
+ name: '🚀 Deploy to GitHub Pages',
+ status: 'completed',
+ conclusion: 'success',
+ steps: [{ name: 'Deploy to GitHub Pages', conclusion: 'success' }],
+});
+
+export const apiError = (code, retryAfter) =>
+ Object.assign(new Error('PRIVATE provider detail'), {
+ status: code,
+ response: { headers: { 'retry-after': retryAfter } },
+ });
+
+const page = (items, parameters) => items.slice((parameters.page - 1) * 100, parameters.page * 100);
+const withoutStatuses = ({ statuses: _statuses, ...item }) => item;
+
+function routeTable(state, next) {
+ const find = (id) =>
+ state.find((item) => item.id === Number(id)) ?? Promise.reject(apiError(404));
+ return [
+ [`GET ${ROOT}`, () => ({ id: REPO_ID, full_name: REPOSITORY })],
+ [`GET ${ROOT}/pages`, () => ({ build_type: 'workflow', html_url: SITE })],
+ [`GET ${ROOT}/branches/main`, () => ({ commit: { sha: authority.sha } })],
+ [
+ `GET ${ROOT}/deployments?environment=github-pages`,
+ (_m, p) => page(state, p).map(withoutStatuses),
+ ],
+ [
+ /^GET .*\/deployments\/(\d+)\/statuses$/,
+ async (m, p) => page((await find(m[1])).statuses, p),
+ ],
+ [
+ /^POST .*\/deployments\/(\d+)\/statuses$/,
+ async (m, p) => {
+ assert.equal(p.auto_inactive, false);
+ const item = await find(m[1]);
+ const created = { ...p, id: next(), created_at: date(next()) };
+ item.statuses.push(created);
+ item.updated_at = created.created_at;
+ return created;
+ },
+ ],
+ [
+ /^DELETE .*\/deployments\/(\d+)$/,
+ async (m) => {
+ const item = await find(m[1]);
+ const latest = [...item.statuses].sort((a, b) => b.id - a.id)[0];
+ assert.equal(latest.state, 'inactive');
+ state.splice(state.indexOf(item), 1);
+ },
+ ],
+ [/^GET .*\/actions\/runs\/(\d+)$/, (m) => publication(Number(m[1]) - 1000)],
+ [/^GET .*\/actions\/jobs\/(\d+)$/, (m) => job(Number(m[1]))],
+ ];
+}
+
+export function client(initial, intercept = () => undefined, headers = {}) {
+ const state = structuredClone(initial);
+ const calls = [];
+ let counter = 10000;
+ const table = routeTable(state, () => counter++);
+ return {
+ state,
+ calls,
+ request: async (route, parameters = {}) => {
+ calls.push({ route, parameters });
+ const override = await intercept(route, parameters, state, calls);
+ if (override) return override;
+ const hit = table.find(([pattern]) =>
+ typeof pattern === 'string' ? pattern === route : pattern.test(route),
+ );
+ if (!hit) throw new Error(`Unexpected fixture route ${route}`);
+ const match = typeof hit[0] === 'string' ? null : hit[0].exec(route);
+ return { data: structuredClone(await hit[1](match, parameters)), headers };
+ },
+ };
+}
+
+export const writes = (github) => github.calls.filter((call) => !call.route.startsWith('GET '));
+export const reads = (github, pattern) =>
+ github.calls.filter((call) => call.route.startsWith('GET ') && pattern.test(call.route));
+
+export const execute = (github, options = {}) =>
+ reconcilePages({ github, authority, holds, pause: async () => {}, ...options });
+
+export const executeDestructive = (github, options = {}) =>
+ execute(github, { destructive: true, ...options });
+
+export const linkHeader = (path, pageNumber, query = 'environment=github-pages') => ({
+ link: `; rel="next"`,
+});
From 9d75944ddc2dbfc48c3738eacbbc2b8a02f1978f Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 02:32:21 +0200
Subject: [PATCH 04/13] ci(pages): reserve candidate cost by observed depth and
prove every reported candidate
Second correction wave on the Pages record reconciler.
Gate: revalidate and retire took five arguments, which fails the CodeScene
argument limit. The reconcile steps now share one session object, so no
function takes more than three arguments.
Candidates: the manifest reported records as candidates that never received
causal proof once more than ten were eligible. Up to twenty eligible records
are now examined and every one of them is proven; the remainder is reported as
unexamined and never counted or listed as a candidate.
Publication events: ci.yml publishes Pages from push and workflow_dispatch on
main, but only push was accepted, so a manual publication as the current
record would have blocked every pass. Both events are accepted, and a test
binds the set to the triggers declared in ci.yml.
Budget: the fixed 40-request allowance under-reserved inventories with many
list or history pages and could leave a candidate inactivated but not deleted.
The reservation is now computed from the page depths observed in the run.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 33 ++++--
scripts/pages-pruning/github.mjs | 18 ++-
scripts/pages-pruning/provenance.mjs | 22 ++--
scripts/pages-pruning/reconcile.mjs | 103 +++++++++++-------
scripts/prune-pages-deployments.mjs | 2 +-
.../tooling/prunePagesDeployments.test.mjs | 96 +++++++++++++++-
6 files changed, 206 insertions(+), 68 deletions(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index fe76be4de..9f3120e3d 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -39,8 +39,10 @@ on its own. A record is deletable only after all of these hold, read fresh from
- deployment shape: `github-pages`, `main`, task `deploy`, full SHA, non-transient, non-production,
no GitHub App other than Actions;
-- the referenced run is the own-repository `CI / CD` push on `main` for the same SHA, completed
- successfully, and its job `🚀 Deploy to GitHub Pages` and step `Deploy to GitHub Pages` succeeded;
+- the referenced run is the own-repository `CI / CD` run on `main` for the same SHA, started by a
+ `push` or a `workflow_dispatch` (the only non-pull-request events `ci.yml` can publish from; a
+ test binds this set to the workflow file), completed successfully, and its job
+ `🚀 Deploy to GitHub Pages` and step `Deploy to GitHub Pages` succeeded;
- **causal link:** the deployment record and its newest success status were authored by the actor
or triggering actor of that run, and neither author is `github-actions[bot]`.
@@ -56,6 +58,11 @@ That principal can delete records directly, so this adds no new authority. If th
established for a record (run deleted, fields missing, mismatch), the record is protected as
`unproved-publication`; it is never deleted and never blocks the rest of the batch.
+Proof costs two requests per record, so at most 20 structurally eligible records are examined per
+pass, newest first, and every one of them is proven even when only ten can be deleted. Eligible
+records beyond that are reported as `unexamined`: they are not counted or listed as candidates and
+are never deleted until a later pass examines them.
+
## Current and rollback slots
Both publication slots are proven by API before any candidate is classified. The newest success
@@ -95,20 +102,24 @@ changed or duplicated parameter, or malformed link fails closed.
A run spends at most 450 requests (45% of GitHub's documented 1,000 requests per hour and
repository for `GITHUB_TOKEN`, shared with CI); the live `x-ratelimit-remaining` header can only
-lower that. Before each candidate the run checks the remaining budget and rate allowance and stops
-gracefully with `budget-limit` rather than half-working. An inventory that alone exceeds the budget
-fails closed with `request-budget` before any write. Measured by the test fixture (GET + POST +
-DELETE, ten candidates):
+lower that. Before each candidate the run reserves its full cost: two complete refreshes (the
+deployment list pages, the three focused histories each allowed one more page, the main read), the
+POST and DELETE, and a worst-case re-proof of both slots and the candidate. The reservation is
+computed from the page depths observed in this run, from 24 requests for a small inventory to 54
+for a 400-record list with five-page histories, and the run stops gracefully with `budget-limit`
+instead of leaving a half-finished candidate. An inventory that alone exceeds the budget fails
+closed with `request-budget` before any write. Measured by the test fixture (GET + POST + DELETE,
+ten candidates):
| Records | Previous design (full re-inventory per write) | This design |
| ---: | ---: | ---: |
| 6 | – | 54 |
-| 50 | ≈1,214 | 196 |
-| 100 | ≈2,264 | 249 |
-| 200 | ≈4,364 | 370 |
-| 300 | ≈6,464 | 411 (stops after 5 deletions) |
+| 50 | ≈1,214 | 216 |
+| 100 | ≈2,264 | 269 |
+| 200 | ≈4,364 | 390 |
+| 300 | ≈6,464 | 431 (stops after 5 deletions) |
-A read-only dry-run costs 18, 76, 127 and 329 requests for 6, 50, 100 and 300 records.
+A read-only dry-run costs 18, 96, 147 and 349 requests for 6, 50, 100 and 300 records.
## Writer fence
diff --git a/scripts/pages-pruning/github.mjs b/scripts/pages-pruning/github.mjs
index 27be777bf..37ee55116 100644
--- a/scripts/pages-pruning/github.mjs
+++ b/scripts/pages-pruning/github.mjs
@@ -20,6 +20,7 @@ export function createContext({ github, authority, pause = defaultPause, limit =
remaining: null,
repositoryId: null,
proven: new Map(),
+ observed: { list: 1, status: 1 },
};
}
@@ -110,14 +111,22 @@ async function assertCanonicalNext(ctx, href, route, page) {
const nextHref = (headers) => /<([^>]+)>;\s*rel="next"/.exec(headers?.link ?? '')?.[1] ?? null;
-export async function collect(ctx, route, maxPages = 20) {
+// QNBS-v3: the deepest page count seen per kind feeds the per-candidate cost ceiling, so the reservation grows with the real inventory instead of a fixed guess.
+function noteDepth(ctx, kind, pages) {
+ if (kind) ctx.observed[kind] = Math.max(ctx.observed[kind], pages);
+}
+
+export async function collect(ctx, route, maxPages = 20, kind = null) {
const all = [];
for (let page = 1; page <= maxPages; page++) {
const response = await read(ctx, route, { per_page: PAGE_SIZE, page });
if (!Array.isArray(response.data) || response.data.length > PAGE_SIZE) refuse('invalid-page');
all.push(...response.data);
const href = nextHref(response.headers);
- if (href === null && response.data.length < PAGE_SIZE) return all;
+ if (href === null && response.data.length < PAGE_SIZE) {
+ noteDepth(ctx, kind, page);
+ return all;
+ }
if (href === null) continue;
if (response.data.length === 0) refuse('invalid-pagination-link');
await assertCanonicalNext(ctx, href, route, page);
@@ -130,11 +139,12 @@ export async function assertPagesSite(ctx) {
if (data.build_type !== 'workflow' || data.html_url !== SITE) refuse('unknown-pages-site');
}
-export const listRecords = (ctx) => collect(ctx, `${ROOT}/deployments?environment=github-pages`);
+export const listRecords = (ctx) =>
+ collect(ctx, `${ROOT}/deployments?environment=github-pages`, 20, 'list');
export function loadStatuses(ctx, record) {
if (!validId(record.id)) refuse('invalid-inventory');
- return collect(ctx, `${ROOT}/deployments/${record.id}/statuses`, 5);
+ return collect(ctx, `${ROOT}/deployments/${record.id}/statuses`, 5, 'status');
}
export async function inventory(ctx) {
diff --git a/scripts/pages-pruning/provenance.mjs b/scripts/pages-pruning/provenance.mjs
index b2dbd3caf..d6f6d8d1c 100644
--- a/scripts/pages-pruning/provenance.mjs
+++ b/scripts/pages-pruning/provenance.mjs
@@ -11,6 +11,9 @@ import {
} from './policy.mjs';
export const SLOT_DEPTH = 4;
+export const EXAMINE_LIMIT = 20;
+// QNBS-v3: ci.yml publishes Pages from every non-pull_request event on main, and its only such triggers are push and workflow_dispatch; a test keeps this set bound to the workflow file.
+export const PUBLISH_EVENTS = new Set(['push', 'workflow_dispatch']);
const UNKNOWN = 'unknown-publication';
function runFacts(run, record, identity) {
@@ -20,7 +23,7 @@ function runFacts(run, record, identity) {
run.head_repository?.full_name === REPOSITORY,
run.name === 'CI / CD',
run.path === '.github/workflows/ci.yml',
- run.event === 'push',
+ PUBLISH_EVENTS.has(run.event),
run.head_branch === 'main',
run.head_sha === record.sha,
run.status === 'completed',
@@ -117,13 +120,16 @@ export async function proveSlots(ctx, records) {
return refuse('no-proven-rollback');
}
-// QNBS-v3: a candidate without causal proof is protected and skipped, so one unprovable record cannot stall the rest of the batch or turn the schedule permanently red.
-export async function selectBatch(ctx, candidates, slots) {
- const batch = [];
- for (const record of candidates.slice(0, MAX_BATCH * 2)) {
- if (batch.length === MAX_BATCH) break;
- if (await proves(ctx, record)) batch.push(record);
+// QNBS-v3: every examined candidate gets causal proof, so the manifest never reports an unexamined record as a candidate; the remainder is explicitly unexamined, and an unprovable record is protected instead of stalling the batch.
+export async function examineCandidates(ctx, candidates, slots) {
+ const proven = [];
+ for (const record of candidates.slice(0, EXAMINE_LIMIT)) {
+ if (await proves(ctx, record)) proven.push(record);
else slots.unproved.push(record.id);
}
- return batch;
+ return {
+ proven,
+ batch: proven.slice(0, MAX_BATCH),
+ unexamined: candidates.slice(EXAMINE_LIMIT),
+ };
}
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index b87538c8f..f820c8bc0 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -11,18 +11,22 @@ import { createContext, inventory, listRecords, loadStatuses, read, write } from
import {
admission,
DESTRUCTIVE_WRITES_ADMITTED,
- MAX_BATCH,
planPruning,
publicationOrder,
ROOT,
SITE,
} from './policy.mjs';
-import { ensureProven, proveSlots, selectBatch } from './provenance.mjs';
+import { ensureProven, examineCandidates, proveSlots } from './provenance.mjs';
-const CANDIDATE_CEILING = 40;
const RATE_RESERVE = 100;
const API_FAILURES = new Set([403, 404, 409, 429, 500, 502, 503, 504]);
+// QNBS-v3: one candidate costs two full refreshes (list pages, three focused histories that may grow by a page, the main read), the POST and DELETE, and a worst-case re-proof of both slots and the candidate.
+export function candidateCeiling(ctx) {
+ const { list, status } = ctx.observed;
+ return 2 * (list + 3 * (status + 1) + 1) + 2 + 6;
+}
+
function newRun(authority, mode) {
const result = {
trigger: authority.eventName,
@@ -54,32 +58,45 @@ function resolveMode(admitted, dryRun, destructive) {
return anyOf(dryRun, admitted !== 'delete', !destructive) ? 'dry-run' : 'delete';
}
-function report(run, records, plan) {
+// QNBS-v3: only records that received causal proof are reported as candidates; structurally eligible but unexamined records are listed separately and never counted as safe.
+function report(run, records, prepared) {
+ const { plan, proven, unexamined } = prepared;
const { result } = run;
result.classified = records.length;
result.protected = plan.protectedRecords.length;
- result.candidate = plan.candidates.length;
+ result.candidate = proven.length;
result.current = plan.current;
result.rollback = plan.rollback;
result.manifest = [
...plan.protectedRecords.map((entry) => ({ ...entry, disposition: 'protected' })),
- ...plan.candidates.map((entry) => ({ id: entry.id, disposition: 'candidate', reasons: [] })),
+ ...unexamined.map((entry) => ({
+ id: entry.id,
+ disposition: 'unexamined',
+ reasons: ['unexamined-batch-limit'],
+ })),
+ ...proven.map((entry) => ({ id: entry.id, disposition: 'candidate', reasons: [] })),
];
for (const entry of plan.protectedRecords) for (const key of entry.reasons) run.reason(key);
+ if (unexamined.length) run.reason('unexamined', unexamined.length);
}
-function finishDryRun(run, plan, records) {
- run.result.skipped = plan.candidates.length;
+function finishDryRun(run, records) {
+ run.result.skipped = run.result.candidate;
if (run.result.skipped) run.reason('dry-run', run.result.skipped);
run.result.followUpRequired = records.length > 3;
run.finalized = true;
}
async function prepare(ctx, holds, records, first) {
- if (first.candidates.length === 0) return { plan: first, slots: null, batch: [] };
+ const none = { plan: first, slots: null, batch: [], proven: [], unexamined: [] };
+ if (first.candidates.length === 0) return none;
const slots = await proveSlots(ctx, records);
- const batch = await selectBatch(ctx, planPruning(records, holds, { slots }).candidates, slots);
- return { plan: planPruning(records, holds, { slots }), slots, batch };
+ const examined = await examineCandidates(
+ ctx,
+ planPruning(records, holds, { slots }).candidates,
+ slots,
+ );
+ return { plan: planPruning(records, holds, { slots }), slots, ...examined };
}
function reusableStatuses(before, item, focus) {
@@ -87,22 +104,22 @@ function reusableStatuses(before, item, focus) {
return listKey(item) === listKey(before) ? before.statuses : null;
}
-async function refresh(ctx, state, focusId) {
- const previous = new Map(state.records.map((record) => [record.id, record]));
- const focus = new Set([focusId, state.slots.current, state.slots.rollback]);
+async function refresh(session, focusId) {
+ const previous = new Map(session.records.map((record) => [record.id, record]));
+ const focus = new Set([focusId, session.slots.current, session.slots.rollback]);
const fresh = [];
- for (const item of await listRecords(ctx)) {
+ for (const item of await listRecords(session.ctx)) {
const kept = reusableStatuses(previous.get(item.id), item, focus);
- fresh.push({ ...item, statuses: kept ?? (await loadStatuses(ctx, item)) });
+ fresh.push({ ...item, statuses: kept ?? (await loadStatuses(session.ctx, item)) });
}
return fresh;
}
-function assertStable(state, fresh, next) {
+function assertStable(session, fresh, next) {
const order = publicationOrder(fresh)
- .slice(0, state.slots.order.length)
+ .slice(0, session.slots.order.length)
.map((entry) => entry.record.id);
- if (next.blocked || order.join() !== state.slots.order.join()) refuse('current-moved');
+ if (next.blocked || order.join() !== session.slots.order.join()) refuse('current-moved');
}
async function assertMainUnchanged(ctx) {
@@ -111,16 +128,17 @@ async function assertMainUnchanged(ctx) {
}
// QNBS-v3: holds are immutable at the executing main SHA; every write re-reads the list plus the candidate and both publication slots, and unchanged other records are trusted only while their list key is identical.
-async function revalidate(ctx, holds, state, candidate, expected) {
- const fresh = await refresh(ctx, state, candidate.id);
- const next = planPruning(fresh, holds, { slots: state.slots });
- assertStable(state, fresh, next);
- state.records = fresh;
+async function revalidate(session, candidate, expected) {
+ const { ctx, holds, slots } = session;
+ const fresh = await refresh(session, candidate.id);
+ const next = planPruning(fresh, holds, { slots });
+ assertStable(session, fresh, next);
+ session.records = fresh;
const record = fresh.find((item) => item.id === candidate.id);
const candidateStillEligible = next.candidates.some((item) => item.id === candidate.id);
if (!record || !candidateStillEligible) return null;
if (fingerprint(record) !== expected) return null;
- for (const id of [state.slots.current, state.slots.rollback])
+ for (const id of [slots.current, slots.rollback])
await ensureProven(ctx, fresh.find((item) => item.id === id) ?? refuse('current-moved'));
await ensureProven(ctx, record);
await assertMainUnchanged(ctx);
@@ -146,35 +164,38 @@ function skip(run, reason) {
}
// QNBS-v3: a failed or ambiguous inactivation never reaches DELETE, and writes are never retried against an assumed inventory.
-async function retire(ctx, run, holds, state, candidate) {
- let fresh = await revalidate(ctx, holds, state, candidate, fingerprint(candidate));
+async function retire(session, candidate) {
+ const { ctx, run } = session;
+ let fresh = await revalidate(session, candidate, fingerprint(candidate));
if (!fresh) return skip(run, 'stale-candidate');
run.result.attempted += 1;
if (latestStatus(fresh).state !== 'inactive') {
fresh.statuses.push(await inactivate(ctx, fresh));
run.result.inactivated += 1;
- fresh = await revalidate(ctx, holds, state, candidate, fingerprint(fresh));
+ fresh = await revalidate(session, candidate, fingerprint(fresh));
if (!fresh) return skip(run, 'status-moved');
}
await write(ctx, `DELETE ${ROOT}/deployments/${candidate.id}`);
run.result.deleted += 1;
- state.records = state.records.filter((record) => record.id !== candidate.id);
+ session.records = session.records.filter((record) => record.id !== candidate.id);
}
function affordable(ctx) {
- const room = ctx.used + CANDIDATE_CEILING <= ctx.limit;
- const rate = ctx.remaining === null || ctx.remaining >= RATE_RESERVE + CANDIDATE_CEILING;
+ const ceiling = candidateCeiling(ctx);
+ const room = ctx.used + ceiling <= ctx.limit;
+ const rate = ctx.remaining === null || ctx.remaining >= RATE_RESERVE + ceiling;
return room && rate;
}
-async function mutate(ctx, run, holds, state) {
+async function mutate(session) {
+ const { ctx, run } = session;
for (const candidate of run.batch) {
if (!affordable(ctx)) {
run.reason('budget-limit', run.batch.length - run.considered);
return;
}
run.considered += 1;
- await retire(ctx, run, holds, state, candidate);
+ await retire(session, candidate);
}
}
@@ -184,17 +205,17 @@ async function execute(ctx, run, holds) {
run.result.queried = records.length;
const first = planPruning(records, holds);
if (first.blocked) {
- report(run, records, first);
+ report(run, records, { plan: first, proven: [], unexamined: [] });
refuse('unproved-current-or-unsettled');
}
- const { plan, slots, batch } = await prepare(ctx, holds, records, first);
- run.batch = batch;
- report(run, records, plan);
- if (run.result.mode !== 'delete') return finishDryRun(run, plan, records);
- await mutate(ctx, run, holds, { records, slots });
+ const prepared = await prepare(ctx, holds, records, first);
+ run.batch = prepared.batch;
+ report(run, records, prepared);
+ if (run.result.mode !== 'delete') return finishDryRun(run, records);
+ await mutate({ ctx, run, holds, records, slots: prepared.slots });
run.result.followUpRequired = records.length - run.result.deleted > 3;
- if (plan.candidates.length > MAX_BATCH)
- run.reason('batch-limit', plan.candidates.length - MAX_BATCH);
+ const backlog = prepared.proven.length - prepared.batch.length;
+ if (backlog > 0) run.reason('batch-limit', backlog);
}
function failureReason(error) {
diff --git a/scripts/prune-pages-deployments.mjs b/scripts/prune-pages-deployments.mjs
index c6c46f2e0..24a4ff3a1 100644
--- a/scripts/prune-pages-deployments.mjs
+++ b/scripts/prune-pages-deployments.mjs
@@ -14,7 +14,7 @@ export {
} from './pages-pruning/policy.mjs';
export { reconcilePages };
-const BACKLOG_REASONS = ['batch-limit', 'budget-limit'];
+const BACKLOG_REASONS = ['batch-limit', 'budget-limit', 'unexamined'];
export async function runAction({
github,
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index f10d78196..7938e6fb2 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -4,6 +4,8 @@ import { readdirSync, readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { REQUEST_LIMIT } from '../../../scripts/pages-pruning/github.mjs';
+import { EXAMINE_LIMIT, PUBLISH_EVENTS } from '../../../scripts/pages-pruning/provenance.mjs';
+import { candidateCeiling } from '../../../scripts/pages-pruning/reconcile.mjs';
import {
admission,
DESTRUCTIVE_WRITES_ADMITTED,
@@ -484,7 +486,9 @@ test('refuses when no rollback publication can be proven', async () => {
const runChanges = [
{ event: 'pull_request' },
- { event: 'workflow_dispatch' },
+ { event: 'schedule' },
+ { event: 'repository_dispatch' },
+ { event: 'release' },
{ head_branch: 'v1.30' },
{ head_repository: { full_name: 'fork/repo' } },
{ conclusion: 'failure' },
@@ -698,8 +702,8 @@ test('caps each invocation at ten records and reports remaining backlog', async
const statusReads = (github) => reads(github, /\/statuses$/).length;
for (const [count, bound] of [
[6, 60],
- [50, 200],
- [100, 260],
+ [50, 240],
+ [100, 300],
])
test(`request volume stays bounded for ${count} records`, async () => {
const github = client(records(count));
@@ -733,6 +737,92 @@ test('an inventory larger than the request budget fails closed without writes',
assert.deepEqual(writes(github), []);
});
+test('the per-candidate cost ceiling grows with the observed inventory depth', () => {
+ assert.equal(candidateCeiling({ observed: { list: 1, status: 1 } }), 24);
+ const deep = candidateCeiling({ observed: { list: 4, status: 5 } });
+ assert.equal(deep, 54);
+ assert.ok(deep > 40, 'a fixed 40-request allowance would under-reserve this inventory');
+});
+
+const deepHistory = (input) => {
+ const item = input.at(-1);
+ const older = Array.from({ length: 449 }, (_, index) =>
+ status(item.id, {
+ id: 7000 + index,
+ state: 'queued',
+ created_at: new Date(Date.UTC(2025, 0, 1) + index * 1000).toISOString(),
+ }),
+ );
+ item.statuses.push(...older);
+ return input;
+};
+
+test('a candidate pass is reserved at the observed inventory depth before any write', async () => {
+ const dry = await execute(client(deepHistory(records(14))), { dryRun: true });
+ const ceiling = candidateCeiling({ observed: { list: 1, status: 5 } });
+ assert.equal(ceiling, 48);
+ const short = client(deepHistory(records(14)));
+ const refused = await executeDestructive(short, { limit: dry.requests + ceiling - 1 });
+ assert.equal(refused.failed, 0);
+ assert.ok(refused.reasons['budget-limit'] > 0);
+ assert.deepEqual(writes(short), []);
+ const funded = client(deepHistory(records(14)));
+ const done = await executeDestructive(funded, { limit: dry.requests + ceiling });
+ assert.equal(done.failed, 0);
+ assert.equal(done.deleted, 1);
+});
+
+test('records beyond the examination limit are reported as unexamined, never as candidates', async () => {
+ const input = records(40);
+ input[0].statuses[0].creator = BOT;
+ const github = client(input);
+ const result = await execute(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.candidate, EXAMINE_LIMIT);
+ assert.equal(result.reasons.unexamined, 17);
+ assert.equal(disposition(result, 1).disposition, 'unexamined');
+ assert.equal(candidates(result).length, EXAMINE_LIMIT);
+ assert.ok(!candidates(result).includes(1));
+ // Slots take two proofs; each examined candidate takes one; nothing else is fetched.
+ assert.equal(reads(github, /\/actions\/runs\//).length, 2 + EXAMINE_LIMIT);
+});
+
+test('every candidate within the examination limit is proven even beyond the deletion batch', async () => {
+ const input = records(20);
+ forgedSuccess(input, 1, 1.5);
+ const result = await execute(client(input));
+ assert.equal(result.failed, 0);
+ assert.equal(result.reasons['unproved-publication'], 1);
+ assert.ok(!candidates(result).includes(1));
+ assert.equal(result.candidate, 16);
+});
+
+test('manual CI dispatch publications on main are proven like push publications', async () => {
+ const github = client(records(5), (route) =>
+ route.includes('/actions/runs/')
+ ? {
+ data: publication(Number(route.split('/').at(-1)) - 1000, { event: 'workflow_dispatch' }),
+ headers: {},
+ }
+ : undefined,
+ );
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 2);
+});
+
+test('the proven publication events are exactly the non-pull_request events ci.yml publishes from', () => {
+ const ci = workflowSource('ci.yml');
+ const triggers = ci.slice(ci.indexOf('\non:'), ci.indexOf('\npermissions:'));
+ const declared = [...triggers.matchAll(/^ {2}([a-z_]+):/gm)]
+ .map((match) => match[1])
+ .filter((name) => name !== 'pull_request')
+ .sort();
+ assert.deepEqual([...PUBLISH_EVENTS].sort(), declared);
+ assert.match(ci, /github\.event_name != 'pull_request'/);
+ assert.match(ci, /github\.ref == 'refs\/heads\/main'/);
+});
+
test('a low live rate-limit allowance stops before the next candidate', async () => {
const github = client(records(30), undefined, { 'x-ratelimit-remaining': '120' });
const result = await executeDestructive(github);
From c2529864923cd27bef0c4ad630e939f50516c9f3 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 02:42:14 +0200
Subject: [PATCH 05/13] ci(pages): reserve retried reads before the first write
and sync the CI inventory
Third correction on the Pages record reconciler.
Retries: read() may spend up to three requests, but the per-candidate
reservation counted each read once, so enough transient 5xx or 429 responses
after the inactive-status POST could exhaust the budget before DELETE. The POST
is now issued only when everything after it fits the remaining budget at three
attempts per read plus the DELETE, and the run otherwise stops without writing.
A test makes every post-POST read fail once and still ends with the delete.
Inventory: .github/CI-AUDIT.md still described the workflow as a weekly and
workflow_run pruner of every environment. The row now states the daily
schedule, the main-only dispatch, the absence of workflow_run and the
GitHub Pages record scope, and a test pins it.
---
.github/CI-AUDIT.md | 2 +-
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 7 ++-
scripts/pages-pruning/github.mjs | 3 +-
scripts/pages-pruning/reconcile.mjs | 27 +++++++++--
.../tooling/prunePagesDeployments.test.mjs | 48 ++++++++++++++++++-
5 files changed, 78 insertions(+), 9 deletions(-)
diff --git a/.github/CI-AUDIT.md b/.github/CI-AUDIT.md
index 9b0885b1c..a6b804623 100644
--- a/.github/CI-AUDIT.md
+++ b/.github/CI-AUDIT.md
@@ -12,7 +12,7 @@ This document summarizes the **current inventory** and **stabilization measures*
|------|---------|-----------|
| [`workflows/ci.yml`](workflows/ci.yml) | `push`/`pull_request` **main**, tags, `workflow_dispatch` | `security` → `quality` (matrix) → `build` \| `e2e` \| `storybook` \| `mutation` → `lighthouse` \| `vrt` → `deploy` (main) |
| [`workflows/tauri-build.yml`](workflows/tauri-build.yml) | `workflow_dispatch`, tags `v*` | Desktop bundles (Linux/Win/macOS) |
-| [`workflows/prune-deployments.yml`](workflows/prune-deployments.yml) | `workflow_run` (after CI/CD), weekly cron, `workflow_dispatch` | Prune ALL environments (Production/Preview/github-pages); keeps latest 3 per env |
+| [`workflows/prune-deployments.yml`](workflows/prune-deployments.yml) | daily cron 03:00 UTC and `workflow_dispatch`, both only on `main`; no `workflow_run` | Reconciles GitHub Pages deployment records only (newest three plus the proven current and rollback publication and the evidence holds); read-only while the destructive gate is closed, see [`docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md`](../docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md) |
| [`workflows/storybook-debug.yml`](workflows/storybook-debug.yml) | `workflow_dispatch` only | Cloud-first Storybook debug: configurable workers/retries, PWDEBUG output |
| [`workflows/scorecard.yml`](workflows/scorecard.yml) | weekly cron, `workflow_dispatch` | OpenSSF Scorecard → GitHub Code Scanning SARIF (publish_results=false; api.scorecard.dev timed out on push runs) |
| [`workflows/codeql.yml`](workflows/codeql.yml) | `push`/`pull_request` main, weekly cron | CodeQL JS/TS SAST |
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index 9f3120e3d..36f7a29a9 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -107,7 +107,10 @@ deployment list pages, the three focused histories each allowed one more page, t
POST and DELETE, and a worst-case re-proof of both slots and the candidate. The reservation is
computed from the page depths observed in this run, from 24 requests for a small inventory to 54
for a 400-record list with five-page histories, and the run stops gracefully with `budget-limit`
-instead of leaving a half-finished candidate. An inventory that alone exceeds the budget fails
+instead of leaving a half-finished candidate. Reads are retried up to twice, so immediately before
+the first write of a candidate the run also reserves everything after the inactive-status POST at
+three attempts per read plus the DELETE (52 requests for a small inventory, 97 for the
+400-record case) and otherwise stops without writing. An inventory that alone exceeds the budget fails
closed with `request-budget` before any write. Measured by the test fixture (GET + POST + DELETE,
ten candidates):
@@ -117,7 +120,7 @@ ten candidates):
| 50 | ≈1,214 | 216 |
| 100 | ≈2,264 | 269 |
| 200 | ≈4,364 | 390 |
-| 300 | ≈6,464 | 431 (stops after 5 deletions) |
+| 300 | ≈6,464 | 390 (stops after 2 deletions) |
A read-only dry-run costs 18, 96, 147 and 349 requests for 6, 50, 100 and 300 records.
diff --git a/scripts/pages-pruning/github.mjs b/scripts/pages-pruning/github.mjs
index 37ee55116..747ab6a1d 100644
--- a/scripts/pages-pruning/github.mjs
+++ b/scripts/pages-pruning/github.mjs
@@ -7,6 +7,7 @@ const API_ORIGIN = 'https://api.github.com';
const PAGE_SIZE = 100;
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const BACKOFF_CAP_MS = 2000;
+export const READ_ATTEMPTS = 3;
export const defaultPause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -41,7 +42,7 @@ const retryDelay = (error, attempt) =>
function shouldRetry(error, attempt, delay) {
return [
- attempt < 2,
+ attempt < READ_ATTEMPTS - 1,
RETRYABLE.has(error.status),
Number.isFinite(delay),
delay >= 0,
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index f820c8bc0..842c2ce7b 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -7,7 +7,15 @@ import {
timestamp,
validId,
} from './evidence.mjs';
-import { createContext, inventory, listRecords, loadStatuses, read, write } from './github.mjs';
+import {
+ createContext,
+ inventory,
+ listRecords,
+ loadStatuses,
+ READ_ATTEMPTS,
+ read,
+ write,
+} from './github.mjs';
import {
admission,
DESTRUCTIVE_WRITES_ADMITTED,
@@ -27,6 +35,13 @@ export function candidateCeiling(ctx) {
return 2 * (list + 3 * (status + 1) + 1) + 2 + 6;
}
+// QNBS-v3: reads may be retried, so everything after the inactive-status POST is reserved at READ_ATTEMPTS per read plus the DELETE, and the POST is only issued when that worst case still fits the budget.
+export function finishCeiling(ctx) {
+ const { list, status } = ctx.observed;
+ const reads = list + 3 * (status + 1) + 1 + 6 + 3;
+ return READ_ATTEMPTS * reads + 1;
+}
+
function newRun(authority, mode) {
const result = {
trigger: authority.eventName,
@@ -168,8 +183,10 @@ async function retire(session, candidate) {
const { ctx, run } = session;
let fresh = await revalidate(session, candidate, fingerprint(candidate));
if (!fresh) return skip(run, 'stale-candidate');
+ const needsPost = latestStatus(fresh).state !== 'inactive';
+ if (needsPost && ctx.used + finishCeiling(ctx) > ctx.limit) return 'budget';
run.result.attempted += 1;
- if (latestStatus(fresh).state !== 'inactive') {
+ if (needsPost) {
fresh.statuses.push(await inactivate(ctx, fresh));
run.result.inactivated += 1;
fresh = await revalidate(session, candidate, fingerprint(fresh));
@@ -195,7 +212,11 @@ async function mutate(session) {
return;
}
run.considered += 1;
- await retire(session, candidate);
+ if ((await retire(session, candidate)) === 'budget') {
+ run.considered -= 1;
+ run.reason('budget-limit', run.batch.length - run.considered);
+ return;
+ }
}
}
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index 7938e6fb2..2e33710aa 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -5,7 +5,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { REQUEST_LIMIT } from '../../../scripts/pages-pruning/github.mjs';
import { EXAMINE_LIMIT, PUBLISH_EVENTS } from '../../../scripts/pages-pruning/provenance.mjs';
-import { candidateCeiling } from '../../../scripts/pages-pruning/reconcile.mjs';
+import { candidateCeiling, finishCeiling } from '../../../scripts/pages-pruning/reconcile.mjs';
import {
admission,
DESTRUCTIVE_WRITES_ADMITTED,
@@ -766,12 +766,56 @@ test('a candidate pass is reserved at the observed inventory depth before any wr
assert.equal(refused.failed, 0);
assert.ok(refused.reasons['budget-limit'] > 0);
assert.deepEqual(writes(short), []);
+ // First revalidation: list page, candidate and rollback histories, five current-record pages, main read.
+ const preWrite = 1 + 1 + 5 + 1 + 1;
const funded = client(deepHistory(records(14)));
- const done = await executeDestructive(funded, { limit: dry.requests + ceiling });
+ const done = await executeDestructive(funded, {
+ limit: dry.requests + preWrite + finishCeiling({ observed: { list: 1, status: 5 } }),
+ });
+ assert.equal(done.failed, 0);
+ assert.equal(done.deleted, 1);
+});
+
+test('the post-POST reservation covers every read at its full retry count', () => {
+ assert.equal(finishCeiling({ observed: { list: 1, status: 1 } }), 52);
+ assert.equal(finishCeiling({ observed: { list: 4, status: 5 } }), 97);
+});
+
+test('the POST is only issued when everything after it fits the budget even with retried reads', async () => {
+ const dry = await execute(client(records(8)), { dryRun: true });
+ // First revalidation: list page, three focused histories, main read.
+ const preWrite = 5;
+ const finish = finishCeiling({ observed: { list: 1, status: 1 } });
+ const short = client(records(8));
+ const refused = await executeDestructive(short, { limit: dry.requests + preWrite + finish - 1 });
+ assert.equal(refused.failed, 0);
+ assert.ok(refused.reasons['budget-limit'] > 0);
+ assert.deepEqual(writes(short), []);
+ let posted = false;
+ const seen = new Set();
+ const flaky = client(records(8), (route, parameters) => {
+ if (route.startsWith('POST ')) posted = true;
+ if (!posted || !route.startsWith('GET ')) return;
+ const key = `${route}${JSON.stringify(parameters)}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ throw apiError(503);
+ });
+ const done = await executeDestructive(flaky, { limit: dry.requests + preWrite + finish });
assert.equal(done.failed, 0);
assert.equal(done.deleted, 1);
});
+test('the CI inventory describes the reconciliation triggers and authority', () => {
+ const audit = readFileSync(new URL('../../../.github/CI-AUDIT.md', import.meta.url), 'utf8');
+ const row = audit.split('\n').find((line) => line.includes('workflows/prune-deployments.yml'));
+ assert.ok(row, 'CI-AUDIT.md lists the pruning workflow');
+ assert.match(row, /daily cron/);
+ assert.match(row, /no `workflow_run`/);
+ assert.match(row, /GitHub Pages deployment records only/);
+ assert.ok(!/ALL environments/.test(row));
+});
+
test('records beyond the examination limit are reported as unexamined, never as candidates', async () => {
const input = records(40);
input[0].statuses[0].creator = BOT;
From b152763a04e17846257960c8f5664db0a0b0deb2 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 03:21:17 +0200
Subject: [PATCH 06/13] ci(pages): list every eligible record in the manifest
of a blocked run
When an unsettled status blocks the run, structurally eligible records were
dropped from the manifest even though the counts included them, so a four
record inventory with an in-progress newest record reported classified 4 but
listed three entries. The remaining records are now listed as unexamined with
the reason blocked-before-proof, and a test pins it.
---
scripts/pages-pruning/reconcile.mjs | 12 +++++++++---
tests/unit/tooling/prunePagesDeployments.test.mjs | 13 +++++++++++++
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index 842c2ce7b..2665f89fb 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -74,7 +74,7 @@ function resolveMode(admitted, dryRun, destructive) {
}
// QNBS-v3: only records that received causal proof are reported as candidates; structurally eligible but unexamined records are listed separately and never counted as safe.
-function report(run, records, prepared) {
+function report(run, records, prepared, why = 'unexamined-batch-limit') {
const { plan, proven, unexamined } = prepared;
const { result } = run;
result.classified = records.length;
@@ -87,7 +87,7 @@ function report(run, records, prepared) {
...unexamined.map((entry) => ({
id: entry.id,
disposition: 'unexamined',
- reasons: ['unexamined-batch-limit'],
+ reasons: [why],
})),
...proven.map((entry) => ({ id: entry.id, disposition: 'candidate', reasons: [] })),
];
@@ -226,7 +226,13 @@ async function execute(ctx, run, holds) {
run.result.queried = records.length;
const first = planPruning(records, holds);
if (first.blocked) {
- report(run, records, { plan: first, proven: [], unexamined: [] });
+ // QNBS-v3: a blocked run still lists every eligible record, as unexamined, so the manifest never silently drops records the counts include.
+ report(
+ run,
+ records,
+ { plan: first, proven: [], unexamined: first.candidates },
+ 'blocked-before-proof',
+ );
refuse('unproved-current-or-unsettled');
}
const prepared = await prepare(ctx, holds, records, first);
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index 2e33710aa..0bc32ae5d 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -950,3 +950,16 @@ test('the local CLI refuses every mode except --dry-run before any network acces
assert.match(result.stderr, /Read-only Pages inventory failed/);
}
});
+
+test('a blocked run still lists every eligible record in the manifest as unexamined', async () => {
+ const input = records(4);
+ input[3].statuses.push(status(4, { id: 999, state: 'in_progress', created_at: date(99) }));
+ const result = await execute(client(input));
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['unproved-current-or-unsettled'], 1);
+ assert.equal(result.classified, 4);
+ assert.equal(result.manifest.length, 4);
+ assert.equal(disposition(result, 1).disposition, 'unexamined');
+ assert.deepEqual(disposition(result, 1).reasons, ['blocked-before-proof']);
+ assert.deepEqual(candidates(result), []);
+});
From d4e9d00c6234b43f9f11f799d0082ab296cd9acd Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 09:33:53 +0200
Subject: [PATCH 07/13] ci(pages): bind publication authors by account id,
bound refresh churn, scan past unprovable records, validate the inactive
status
Authors match by numeric account id and login together. A refresh reloads at most three unfocused records, so the per-candidate ceilings hold; more churn stops the run before any history is read. Examination continues past unprovable records until 20 are proven, within the budget left after one candidate's cost. The inactive-status POST is skipped only for a recognised Pages status with an identified author, and the new status takes the proven success status's job link.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 36 +++--
scripts/pages-pruning/evidence.mjs | 2 +
scripts/pages-pruning/policy.mjs | 6 +
scripts/pages-pruning/provenance.mjs | 32 +++--
scripts/pages-pruning/reconcile.mjs | 36 +++--
.../tooling/prunePagesDeployments.test.mjs | 127 ++++++++++++++++--
.../tooling/support/pagesPruningFixture.mjs | 10 +-
7 files changed, 197 insertions(+), 52 deletions(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index 36f7a29a9..d4895bc6f 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -44,7 +44,8 @@ on its own. A record is deletable only after all of these hold, read fresh from
test binds this set to the workflow file), completed successfully, and its job
`🚀 Deploy to GitHub Pages` and step `Deploy to GitHub Pages` succeeded;
- **causal link:** the deployment record and its newest success status were authored by the actor
- or triggering actor of that run, and neither author is `github-actions[bot]`.
+ or triggering actor of that run, matched by numeric account id and login together (an author
+ without a valid id never matches), and neither author is `github-actions[bot]`.
The causal link is what a `GITHUB_TOKEN` writer cannot imitate: any other workflow writes as
`github-actions[bot]`, while the Pages publisher records the principal that triggered the run.
@@ -58,10 +59,13 @@ That principal can delete records directly, so this adds no new authority. If th
established for a record (run deleted, fields missing, mismatch), the record is protected as
`unproved-publication`; it is never deleted and never blocks the rest of the batch.
-Proof costs two requests per record, so at most 20 structurally eligible records are examined per
-pass, newest first, and every one of them is proven even when only ten can be deleted. Eligible
-records beyond that are reported as `unexamined`: they are not counted or listed as candidates and
-are never deleted until a later pass examines them.
+Proof costs two requests per record. Eligible records are examined newest first until 20 of them
+are proven, even though only ten can be deleted. A record that cannot be proven is protected and
+does not count against those 20, so unprovable newest records cannot hide older provable ones;
+the scan ends early only when the budget left would no longer fund one more proof plus the cost of
+one candidate, which is kept for the mutation phase. Eligible records not examined are reported as
+`unexamined`: they are not counted or listed as candidates and are never deleted until a later pass
+examines them.
## Current and rollback slots
@@ -85,7 +89,13 @@ fresh read of the deployment list plus the full status history of the candidate
publication slots, an unchanged publication-order prefix, an unchanged candidate fingerprint and
the trusted-main SHA. Other records are re-read only when their list entry (including `updated_at`)
changed; GitHub bumped `updated_at` past the newest status on all six live records, but that is an
-optimization, not the authority for the candidate or the slots. A failed or ambiguous inactivation
+optimization, not the authority for the candidate or the slots. At most three such unfocused
+records may need a reload in one refresh; more churn than that stops the run
+(`inventory-churn`) before any history is read, so the cost ceilings below always hold. The
+inactive-status POST is skipped only when the latest status is already an inactive Pages status
+(environment `github-pages`, the Pages site URL) with an identified author; any other latest
+status, including an inactive one of unknown origin, gets this run's own inactive status first, so
+deletion authority never rests on unvalidated status metadata. A failed or ambiguous inactivation
never reaches deletion. `auto_inactive: false` avoids affecting other records. GET retries are
bounded at three attempts and two seconds maximum delay; writes are never retried. API failures
stop the batch, fail the job and report confirmed partial outcomes with sanitized reason codes. A
@@ -103,13 +113,13 @@ changed or duplicated parameter, or malformed link fails closed.
A run spends at most 450 requests (45% of GitHub's documented 1,000 requests per hour and
repository for `GITHUB_TOKEN`, shared with CI); the live `x-ratelimit-remaining` header can only
lower that. Before each candidate the run reserves its full cost: two complete refreshes (the
-deployment list pages, the three focused histories each allowed one more page, the main read), the
-POST and DELETE, and a worst-case re-proof of both slots and the candidate. The reservation is
-computed from the page depths observed in this run, from 24 requests for a small inventory to 54
-for a 400-record list with five-page histories, and the run stops gracefully with `budget-limit`
+deployment list pages, the three focused histories and up to three more reloads, each allowed one
+more page, the main read), the POST and DELETE, and a worst-case re-proof of both slots and the
+candidate. The reservation is computed from the page depths observed in this run, from 36 requests
+for a small inventory to 90 for a 400-record list with five-page histories, and the run stops gracefully with `budget-limit`
instead of leaving a half-finished candidate. Reads are retried up to twice, so immediately before
the first write of a candidate the run also reserves everything after the inactive-status POST at
-three attempts per read plus the DELETE (52 requests for a small inventory, 97 for the
+three attempts per read plus the DELETE (70 requests for a small inventory, 151 for the
400-record case) and otherwise stops without writing. An inventory that alone exceeds the budget fails
closed with `request-budget` before any write. Measured by the test fixture (GET + POST + DELETE,
ten candidates):
@@ -119,8 +129,8 @@ ten candidates):
| 6 | – | 54 |
| 50 | ≈1,214 | 216 |
| 100 | ≈2,264 | 269 |
-| 200 | ≈4,364 | 390 |
-| 300 | ≈6,464 | 390 (stops after 2 deletions) |
+| 200 | ≈4,364 | 382 (stops after 9 deletions) |
+| 300 | ≈6,464 | 374 (stops after 1 deletion) |
A read-only dry-run costs 18, 96, 147 and 349 requests for 6, 50, 100 and 300 records.
diff --git a/scripts/pages-pruning/evidence.mjs b/scripts/pages-pruning/evidence.mjs
index 5c81e8756..5218cc280 100644
--- a/scripts/pages-pruning/evidence.mjs
+++ b/scripts/pages-pruning/evidence.mjs
@@ -31,6 +31,7 @@ const statusKey = (status) => [
status.environment_url,
status.log_url,
status.creator?.login ?? null,
+ status.creator?.id ?? null,
];
const metaKey = (record) => [
@@ -43,6 +44,7 @@ const metaKey = (record) => [
record.transient_environment,
record.production_environment,
record.creator?.login ?? null,
+ record.creator?.id ?? null,
record.performed_via_github_app?.id ?? null,
];
diff --git a/scripts/pages-pruning/policy.mjs b/scripts/pages-pruning/policy.mjs
index 9ad074110..e815829d9 100644
--- a/scripts/pages-pruning/policy.mjs
+++ b/scripts/pages-pruning/policy.mjs
@@ -51,6 +51,12 @@ export function validateHolds(holds) {
export const creatorLogin = (actor) =>
typeof actor?.login === 'string' && actor.login !== '' ? actor.login : null;
+// QNBS-v3: an author is bound by account id and login together; a payload without a valid id, a reused login or a renamed account never matches another author.
+export function actorKey(actor) {
+ const login = creatorLogin(actor);
+ return login !== null && validId(actor.id) ? `${actor.id}:${login}` : null;
+}
+
const isActionsApp = (app) => app.id === ACTIONS_APP_ID && app.slug === 'github-actions';
const appIsActionsOrAbsent = (app) => app == null || isActionsApp(app);
diff --git a/scripts/pages-pruning/provenance.mjs b/scripts/pages-pruning/provenance.mjs
index d6f6d8d1c..53a8414a0 100644
--- a/scripts/pages-pruning/provenance.mjs
+++ b/scripts/pages-pruning/provenance.mjs
@@ -1,7 +1,8 @@
import { newestSuccess, proofKey, refuse } from './evidence.mjs';
-import { read } from './github.mjs';
+import { READ_ATTEMPTS, read } from './github.mjs';
import {
ACTIONS_BOT,
+ actorKey,
creatorLogin,
jobIdentity,
MAX_BATCH,
@@ -12,6 +13,8 @@ import {
export const SLOT_DEPTH = 4;
export const EXAMINE_LIMIT = 20;
+// A proof reads the run and the job, each at its full retry count.
+const PROOF_COST = 2 * READ_ATTEMPTS;
// QNBS-v3: ci.yml publishes Pages from every non-pull_request event on main, and its only such triggers are push and workflow_dispatch; a test keeps this set bound to the workflow file.
export const PUBLISH_EVENTS = new Set(['push', 'workflow_dispatch']);
const UNKNOWN = 'unknown-publication';
@@ -45,18 +48,16 @@ function jobFacts(job, run, identity) {
];
}
-// QNBS-v3: log_url is caller-written, so the record and its success status must both be authored by the principal that triggered the referenced run; a GITHUB_TOKEN writer is always github-actions[bot].
+// QNBS-v3: log_url is caller-written, so the record and its success status must both be authored by the principal that triggered the referenced run, matched by account id and login (a missing id never matches); a GITHUB_TOKEN writer is always github-actions[bot].
function actorFacts(record, success, run) {
- const actors = new Set([creatorLogin(run.actor), creatorLogin(run.triggering_actor)]);
+ const actors = new Set([actorKey(run.actor), actorKey(run.triggering_actor)]);
actors.delete(null);
- const deployer = creatorLogin(record.creator);
- const publisher = creatorLogin(success.creator);
return [
actors.size > 0,
- actors.has(deployer),
- actors.has(publisher),
- deployer !== ACTIONS_BOT,
- publisher !== ACTIONS_BOT,
+ actors.has(actorKey(record.creator)),
+ actors.has(actorKey(success.creator)),
+ creatorLogin(record.creator) !== ACTIONS_BOT,
+ creatorLogin(success.creator) !== ACTIONS_BOT,
];
}
@@ -120,16 +121,21 @@ export async function proveSlots(ctx, records) {
return refuse('no-proven-rollback');
}
-// QNBS-v3: every examined candidate gets causal proof, so the manifest never reports an unexamined record as a candidate; the remainder is explicitly unexamined, and an unprovable record is protected instead of stalling the batch.
-export async function examineCandidates(ctx, candidates, slots) {
+const hasRoomToProve = (ctx, floor) => ctx.used + floor + PROOF_COST <= ctx.limit;
+
+// QNBS-v3: every examined candidate gets causal proof, so the manifest never reports an unexamined record as a candidate; an unprovable record is protected and does not count against EXAMINE_LIMIT, so permanently unprovable newest records cannot hide older provable ones, and the scan ends early only when the budget left would no longer fund one more proof plus the floor kept for the mutation phase.
+export async function examineCandidates(ctx, candidates, slots, floor = 0) {
const proven = [];
- for (const record of candidates.slice(0, EXAMINE_LIMIT)) {
+ let examined = 0;
+ for (const record of candidates) {
+ if (proven.length >= EXAMINE_LIMIT || !hasRoomToProve(ctx, floor)) break;
+ examined += 1;
if (await proves(ctx, record)) proven.push(record);
else slots.unproved.push(record.id);
}
return {
proven,
batch: proven.slice(0, MAX_BATCH),
- unexamined: candidates.slice(EXAMINE_LIMIT),
+ unexamined: candidates.slice(examined),
};
}
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index 2665f89fb..ca3655250 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -3,6 +3,7 @@ import {
fingerprint,
latestStatus,
listKey,
+ newestSuccess,
refuse,
timestamp,
validId,
@@ -17,8 +18,10 @@ import {
write,
} from './github.mjs';
import {
+ actorKey,
admission,
DESTRUCTIVE_WRITES_ADMITTED,
+ isPagesStatus,
planPruning,
publicationOrder,
ROOT,
@@ -29,16 +32,21 @@ import { ensureProven, examineCandidates, proveSlots } from './provenance.mjs';
const RATE_RESERVE = 100;
const API_FAILURES = new Set([403, 404, 409, 429, 500, 502, 503, 504]);
-// QNBS-v3: one candidate costs two full refreshes (list pages, three focused histories that may grow by a page, the main read), the POST and DELETE, and a worst-case re-proof of both slots and the candidate.
+// QNBS-v3: a refresh reloads the three focused histories and at most EXTRA_RELOADS others whose list key changed, so its cost has a ceiling; more churn than that stops the run.
+const FOCUSED_RELOADS = 3;
+export const EXTRA_RELOADS = 3;
+const RELOADS = FOCUSED_RELOADS + EXTRA_RELOADS;
+
+// QNBS-v3: one candidate costs two full refreshes (list pages, every reloadable history that may grow by a page, the main read), the POST and DELETE, and a worst-case re-proof of both slots and the candidate.
export function candidateCeiling(ctx) {
const { list, status } = ctx.observed;
- return 2 * (list + 3 * (status + 1) + 1) + 2 + 6;
+ return 2 * (list + RELOADS * (status + 1) + 1) + 2 + 6;
}
// QNBS-v3: reads may be retried, so everything after the inactive-status POST is reserved at READ_ATTEMPTS per read plus the DELETE, and the POST is only issued when that worst case still fits the budget.
export function finishCeiling(ctx) {
const { list, status } = ctx.observed;
- const reads = list + 3 * (status + 1) + 1 + 6 + 3;
+ const reads = list + RELOADS * (status + 1) + 1 + 6 + 3;
return READ_ATTEMPTS * reads + 1;
}
@@ -110,6 +118,7 @@ async function prepare(ctx, holds, records, first) {
ctx,
planPruning(records, holds, { slots }).candidates,
slots,
+ candidateCeiling(ctx),
);
return { plan: planPruning(records, holds, { slots }), slots, ...examined };
}
@@ -119,14 +128,17 @@ function reusableStatuses(before, item, focus) {
return listKey(item) === listKey(before) ? before.statuses : null;
}
+// QNBS-v3: the list is read first and the number of unfocused records whose history must be reloaded is checked against EXTRA_RELOADS before any history is read, so a moving inventory stops the run inside the reads the ceilings reserved.
async function refresh(session, focusId) {
const previous = new Map(session.records.map((record) => [record.id, record]));
const focus = new Set([focusId, session.slots.current, session.slots.rollback]);
+ const items = await listRecords(session.ctx);
+ const kept = items.map((item) => reusableStatuses(previous.get(item.id), item, focus));
+ const churned = items.filter((item, index) => kept[index] === null && !focus.has(item.id));
+ if (churned.length > EXTRA_RELOADS) refuse('inventory-churn');
const fresh = [];
- for (const item of await listRecords(session.ctx)) {
- const kept = reusableStatuses(previous.get(item.id), item, focus);
- fresh.push({ ...item, statuses: kept ?? (await loadStatuses(session.ctx, item)) });
- }
+ for (const [index, item] of items.entries())
+ fresh.push({ ...item, statuses: kept[index] ?? (await loadStatuses(session.ctx, item)) });
return fresh;
}
@@ -165,7 +177,7 @@ async function inactivate(ctx, record) {
state: 'inactive',
environment: 'github-pages',
environment_url: SITE,
- log_url: latestStatus(record).log_url,
+ log_url: newestSuccess(record).log_url,
auto_inactive: false,
});
if (anyOf(!validId(data.id), data.state !== 'inactive', !timestamp(data.created_at)))
@@ -178,12 +190,18 @@ function skip(run, reason) {
run.reason(reason);
}
+// QNBS-v3: the POST is skipped only for an inactive Pages status with an identified author; any other latest status, including an inactive one of unknown origin, gets this run's own inactive status before the DELETE, so deletion authority never rests on unvalidated status metadata.
+const isSettledInactive = (status) =>
+ [status?.state === 'inactive', isPagesStatus(status), actorKey(status?.creator) !== null].every(
+ Boolean,
+ );
+
// QNBS-v3: a failed or ambiguous inactivation never reaches DELETE, and writes are never retried against an assumed inventory.
async function retire(session, candidate) {
const { ctx, run } = session;
let fresh = await revalidate(session, candidate, fingerprint(candidate));
if (!fresh) return skip(run, 'stale-candidate');
- const needsPost = latestStatus(fresh).state !== 'inactive';
+ const needsPost = !isSettledInactive(latestStatus(fresh));
if (needsPost && ctx.used + finishCeiling(ctx) > ctx.limit) return 'budget';
run.result.attempted += 1;
if (needsPost) {
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index 0bc32ae5d..2332d3959 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -5,7 +5,11 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { REQUEST_LIMIT } from '../../../scripts/pages-pruning/github.mjs';
import { EXAMINE_LIMIT, PUBLISH_EVENTS } from '../../../scripts/pages-pruning/provenance.mjs';
-import { candidateCeiling, finishCeiling } from '../../../scripts/pages-pruning/reconcile.mjs';
+import {
+ candidateCeiling,
+ EXTRA_RELOADS,
+ finishCeiling,
+} from '../../../scripts/pages-pruning/reconcile.mjs';
import {
admission,
DESTRUCTIVE_WRITES_ADMITTED,
@@ -24,6 +28,7 @@ import {
job as jobFixture,
jobUrl,
linkHeader,
+ PRINCIPAL,
publication,
REPO_ID,
ROOT,
@@ -34,7 +39,8 @@ import {
writes,
} from './support/pagesPruningFixture.mjs';
-const BOT = { login: 'github-actions[bot]' };
+const BOT = { login: 'github-actions[bot]', id: 41898282 };
+const CURSOR = { login: 'cursor[bot]', id: 9002 };
const candidates = (result) =>
result.manifest.filter((item) => item.disposition === 'candidate').map((item) => item.id);
const disposition = (result, id) => result.manifest.find((item) => item.id === id);
@@ -280,6 +286,34 @@ test('already inactive candidates require no POST', async () => {
assert.equal(writes(github).length, 1);
});
+const unrecognizedInactive = {
+ 'an author that is missing': { creator: null },
+ 'an author without an account id': { creator: { login: 'maintainer' } },
+ 'a site that is not the Pages site': { environment_url: 'https://elsewhere.example/' },
+ 'no site at all': { environment_url: undefined },
+};
+for (const [name, change] of Object.entries(unrecognizedInactive))
+ test(`an inactive latest status with ${name} is replaced by this run's own status before DELETE`, async () => {
+ const input = records(4);
+ input[0].statuses.push({
+ ...input[0].statuses[0],
+ id: 900,
+ created_at: date(900),
+ state: 'inactive',
+ ...change,
+ });
+ const github = client(input);
+ const result = await executeDestructive(github);
+ assert.equal(result.deleted, 1);
+ assert.equal(result.inactivated, 1);
+ assert.deepEqual(
+ writes(github).map((call) => call.route),
+ [`POST ${ROOT}/deployments/1/statuses`, `DELETE ${ROOT}/deployments/1`],
+ );
+ // The new status carries the proven success status's job link, never the unrecognised one's.
+ assert.equal(writes(github)[0].parameters.log_url, jobUrl(1));
+ });
+
for (const code of [403, 404, 409, 429, 500, 503])
test(`failed inactivation ${code} never reaches DELETE or retries writes`, async () => {
const github = client(records(4), (route) => {
@@ -494,7 +528,9 @@ const runChanges = [
{ conclusion: 'failure' },
{ conclusion: 'cancelled' },
{ path: '.github/workflows/other.yml' },
- { actor: { login: 'intruder' }, triggering_actor: { login: 'intruder' } },
+ { actor: { login: 'intruder', id: 7 }, triggering_actor: { login: 'intruder', id: 7 } },
+ { actor: { login: 'maintainer' }, triggering_actor: { login: 'maintainer' } },
+ { actor: { login: 'maintainer', id: 1 }, triggering_actor: { login: 'maintainer', id: 1 } },
{ actor: null, triggering_actor: null },
];
for (const change of runChanges)
@@ -537,6 +573,18 @@ const forgeries = {
'a status authored by another principal': (item) => {
item.statuses[0].creator = { login: 'intruder' };
},
+ 'a status author without an account id': (item) => {
+ item.statuses[0].creator = { login: PRINCIPAL.login };
+ },
+ 'a status author whose account id differs from the run actor': (item) => {
+ item.statuses[0].creator = { login: PRINCIPAL.login, id: PRINCIPAL.id + 1 };
+ },
+ 'a deployment author without an account id': (item) => {
+ item.creator = { login: PRINCIPAL.login };
+ },
+ 'a deployment author whose account id differs from the run actor': (item) => {
+ item.creator = { login: PRINCIPAL.login, id: PRINCIPAL.id + 1 };
+ },
'a status without an author': (item) => {
delete item.statuses[0].creator;
},
@@ -575,14 +623,14 @@ test('a candidate whose publication run no longer exists is protected', async ()
test('a bot-triggered record is retired when its run actor matches the record authors', async () => {
const input = records(5);
input[0].performed_via_github_app = null;
- input[0].creator = { login: 'cursor[bot]' };
- input[0].statuses[0].creator = { login: 'cursor[bot]' };
+ input[0].creator = CURSOR;
+ input[0].statuses[0].creator = CURSOR;
const github = client(input, (route) =>
route.endsWith('/actions/runs/1001')
? {
data: publication(1, {
- actor: { login: 'cursor[bot]' },
- triggering_actor: { login: 'cursor[bot]' },
+ actor: CURSOR,
+ triggering_actor: CURSOR,
}),
headers: {},
}
@@ -642,6 +690,31 @@ test('a record whose list key changed is re-read and a new publication head stop
assert.deepEqual(writes(github), []);
});
+test('more changed unfocused records than a refresh can afford stop the run before any write', async () => {
+ let lists = 0;
+ const github = client(records(10), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++lists === 2)
+ for (const item of state.slice(0, EXTRA_RELOADS + 1)) item.updated_at = date(500);
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['inventory-churn'], 1);
+ assert.deepEqual(writes(github), []);
+ // The refusal comes before any history of the changed records is read again.
+ assert.equal(statusReads(github), 10);
+});
+
+test('as many changed unfocused records as a refresh can afford are reloaded and the run goes on', async () => {
+ let lists = 0;
+ const github = client(records(10), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++lists === 2)
+ for (const item of state.slice(0, EXTRA_RELOADS)) item.updated_at = date(500);
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 7);
+});
+
for (const [name, extra] of [
['a successful', record(99)],
['an in-flight', record(99, { statuses: [status(99, { state: 'in_progress' })] })],
@@ -723,7 +796,8 @@ test('a request budget that cannot fund another candidate stops gracefully befor
const result = await executeDestructive(github, { limit: 160 });
assert.equal(result.failed, 0);
assert.equal(result.deleted, 0);
- assert.equal(result.reasons['budget-limit'], 10);
+ assert.ok(result.reasons['budget-limit'] > 0);
+ assert.ok(result.reasons.unexamined > 0);
assert.equal(result.followUpRequired, true);
assert.deepEqual(writes(github), []);
});
@@ -738,9 +812,9 @@ test('an inventory larger than the request budget fails closed without writes',
});
test('the per-candidate cost ceiling grows with the observed inventory depth', () => {
- assert.equal(candidateCeiling({ observed: { list: 1, status: 1 } }), 24);
+ assert.equal(candidateCeiling({ observed: { list: 1, status: 1 } }), 36);
const deep = candidateCeiling({ observed: { list: 4, status: 5 } });
- assert.equal(deep, 54);
+ assert.equal(deep, 90);
assert.ok(deep > 40, 'a fixed 40-request allowance would under-reserve this inventory');
});
@@ -760,7 +834,7 @@ const deepHistory = (input) => {
test('a candidate pass is reserved at the observed inventory depth before any write', async () => {
const dry = await execute(client(deepHistory(records(14))), { dryRun: true });
const ceiling = candidateCeiling({ observed: { list: 1, status: 5 } });
- assert.equal(ceiling, 48);
+ assert.equal(ceiling, 84);
const short = client(deepHistory(records(14)));
const refused = await executeDestructive(short, { limit: dry.requests + ceiling - 1 });
assert.equal(refused.failed, 0);
@@ -777,8 +851,8 @@ test('a candidate pass is reserved at the observed inventory depth before any wr
});
test('the post-POST reservation covers every read at its full retry count', () => {
- assert.equal(finishCeiling({ observed: { list: 1, status: 1 } }), 52);
- assert.equal(finishCeiling({ observed: { list: 4, status: 5 } }), 97);
+ assert.equal(finishCeiling({ observed: { list: 1, status: 1 } }), 70);
+ assert.equal(finishCeiling({ observed: { list: 4, status: 5 } }), 151);
});
test('the POST is only issued when everything after it fits the budget even with retried reads', async () => {
@@ -831,6 +905,33 @@ test('records beyond the examination limit are reported as unexamined, never as
assert.equal(reads(github, /\/actions\/runs\//).length, 2 + EXAMINE_LIMIT);
});
+test('unprovable newest records do not hide older provable candidates', async () => {
+ const input = records(40);
+ for (const id of Array.from({ length: 22 }, (_, index) => 16 + index)) {
+ input[id - 1].statuses[0].creator = BOT;
+ }
+ const github = client(input);
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.reasons['unproved-publication'], 22);
+ assert.equal(result.candidate, 15);
+ assert.equal(result.reasons.unexamined, undefined);
+ assert.equal(result.deleted, 10);
+ assert.ok(
+ github.state.some((item) => item.id === 20),
+ 'an unprovable record is never deleted',
+ );
+});
+
+test('the scan keeps the budget floor of one candidate for the mutation phase', async () => {
+ const github = client(records(100));
+ const dry = await execute(github, { dryRun: true, limit: 140 });
+ assert.equal(dry.failed, 0);
+ assert.equal(dry.candidate, 0);
+ assert.ok(dry.reasons.unexamined > 0);
+ assert.deepEqual(reads(github, /\/actions\/runs\//).length, 2);
+});
+
test('every candidate within the examination limit is proven even beyond the deletion batch', async () => {
const input = records(20);
forgedSuccess(input, 1, 1.5);
diff --git a/tests/unit/tooling/support/pagesPruningFixture.mjs b/tests/unit/tooling/support/pagesPruningFixture.mjs
index 56b59b06f..19408090e 100644
--- a/tests/unit/tooling/support/pagesPruningFixture.mjs
+++ b/tests/unit/tooling/support/pagesPruningFixture.mjs
@@ -5,6 +5,8 @@ export const ROOT = `/repos/${REPOSITORY}`;
export const SITE = 'https://qnbs.github.io/WorldScript-Studio/';
export const REPO_ID = 4242;
export const ACTOR = 'maintainer';
+// A principal is the account id and the login together; the publication proof matches both.
+export const PRINCIPAL = { login: ACTOR, id: 9001 };
export const date = (id) => new Date(Date.UTC(2026, 0, 1) + id * 1000).toISOString();
export const authority = {
repository: REPOSITORY,
@@ -25,7 +27,7 @@ export function status(id, overrides = {}) {
state: 'success',
environment: 'github-pages',
environment_url: SITE,
- creator: { login: ACTOR },
+ creator: PRINCIPAL,
log_url: jobUrl(id),
...overrides,
};
@@ -42,7 +44,7 @@ export function record(id, overrides = {}) {
task: 'deploy',
transient_environment: false,
production_environment: false,
- creator: { login: ACTOR },
+ creator: PRINCIPAL,
performed_via_github_app: { id: 15368, slug: 'github-actions' },
statuses: [status(id)],
...overrides,
@@ -62,8 +64,8 @@ export const publication = (id, overrides = {}) => ({
head_sha: record(id).sha,
status: 'completed',
conclusion: 'success',
- actor: { login: ACTOR },
- triggering_actor: { login: ACTOR },
+ actor: PRINCIPAL,
+ triggering_actor: PRINCIPAL,
...overrides,
});
From 026c155bac8073000fe5e4e3fca5c7d6dd8402b3 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 10:02:27 +0200
Subject: [PATCH 08/13] ci(pages): re-read evidence at write boundaries, check
the live rate allowance before the POST, pin the github.com token host
The proof cache is bypassed at both write boundaries of a candidate so deleted or unreadable run and job evidence protects the record. The inactive-status POST also needs the live x-ratelimit-remaining to cover the finish cost plus the CI reserve. The local dry-run asks gh for the github.com token explicitly. Ceilings, measured volumes and docs follow.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 29 +++++++---
scripts/pages-pruning/provenance.mjs | 6 ++
scripts/pages-pruning/reconcile.mjs | 27 +++++----
scripts/prune-pages-deployments.mjs | 5 +-
.../tooling/prunePagesDeployments.test.mjs | 57 +++++++++++++++----
5 files changed, 93 insertions(+), 31 deletions(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index d4895bc6f..d8f1b9638 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -17,6 +17,10 @@ node scripts/prune-pages-deployments.mjs --dry-run
node --test tests/unit/tooling/prunePagesDeployments.test.mjs
```
+The local command asks `gh` for the `github.com` token explicitly (`gh auth token --hostname
+github.com`), so `GH_HOST` or an enterprise default can never hand a credential for another host
+to `api.github.com`; it only issues GET requests.
+
## Retention and ownership
Keep the newest three Pages records, ordered by `created_at` descending then numeric ID
@@ -87,7 +91,10 @@ invalidates the hold/executable snapshot before each mutation.
At most ten records are attempted per invocation. Every inactivate and delete operation requires a
fresh read of the deployment list plus the full status history of the candidate and of both
publication slots, an unchanged publication-order prefix, an unchanged candidate fingerprint and
-the trusted-main SHA. Other records are re-read only when their list entry (including `updated_at`)
+the trusted-main SHA. The run and job evidence of the candidate and of both slots is read again
+at each of those two boundaries, bypassing the proof cache, so evidence that was deleted or became
+unreadable since classification stops the run before the write (`unknown-publication`). Other
+records are re-read only when their list entry (including `updated_at`)
changed; GitHub bumped `updated_at` past the newest status on all six live records, but that is an
optimization, not the authority for the candidate or the slots. At most three such unfocused
records may need a reload in one refresh; more churn than that stops the run
@@ -115,22 +122,26 @@ repository for `GITHUB_TOKEN`, shared with CI); the live `x-ratelimit-remaining`
lower that. Before each candidate the run reserves its full cost: two complete refreshes (the
deployment list pages, the three focused histories and up to three more reloads, each allowed one
more page, the main read), the POST and DELETE, and a worst-case re-proof of both slots and the
-candidate. The reservation is computed from the page depths observed in this run, from 36 requests
-for a small inventory to 90 for a 400-record list with five-page histories, and the run stops gracefully with `budget-limit`
+candidate (twice, once per write boundary). The reservation is computed from the page depths
+observed in this run, from 42 requests for a small inventory to 96 for a 400-record list with
+five-page histories, and the run stops gracefully with `budget-limit`
instead of leaving a half-finished candidate. Reads are retried up to twice, so immediately before
the first write of a candidate the run also reserves everything after the inactive-status POST at
three attempts per read plus the DELETE (70 requests for a small inventory, 151 for the
-400-record case) and otherwise stops without writing. An inventory that alone exceeds the budget fails
+400-record case) and otherwise stops without writing. Both reservations are also checked against
+the live `x-ratelimit-remaining` value last reported by GitHub, with a reserve of 100 requests kept
+for CI; the POST is not issued when the last reported allowance cannot cover the rest of the
+candidate (other consumers of the same token can still lower it afterwards). An inventory that alone exceeds the budget fails
closed with `request-budget` before any write. Measured by the test fixture (GET + POST + DELETE,
ten candidates):
| Records | Previous design (full re-inventory per write) | This design |
| ---: | ---: | ---: |
-| 6 | – | 54 |
-| 50 | ≈1,214 | 216 |
-| 100 | ≈2,264 | 269 |
-| 200 | ≈4,364 | 382 (stops after 9 deletions) |
-| 300 | ≈6,464 | 374 (stops after 1 deletion) |
+| 6 | – | 90 |
+| 50 | ≈1,214 | 336 |
+| 100 | ≈2,264 | 389 |
+| 200 | ≈4,364 | 392 (stops after 5 deletions) |
+| 300 | ≈6,464 | 392 (stops after 1 deletion) |
A read-only dry-run costs 18, 96, 147 and 349 requests for 6, 50, 100 and 300 records.
diff --git a/scripts/pages-pruning/provenance.mjs b/scripts/pages-pruning/provenance.mjs
index 53a8414a0..1ba680544 100644
--- a/scripts/pages-pruning/provenance.mjs
+++ b/scripts/pages-pruning/provenance.mjs
@@ -92,6 +92,12 @@ export async function ensureProven(ctx, record) {
ctx.proven.set(record.id, key);
}
+// QNBS-v3: at a write boundary the run and job are read again, so evidence that was deleted or became unreadable since classification protects the record instead of authorizing the write on a cached proof.
+export async function reprove(ctx, record) {
+ ctx.proven.delete(record.id);
+ await ensureProven(ctx, record);
+}
+
async function proves(ctx, record) {
try {
await ensureProven(ctx, record);
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index ca3655250..02b1ea327 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -27,7 +27,7 @@ import {
ROOT,
SITE,
} from './policy.mjs';
-import { ensureProven, examineCandidates, proveSlots } from './provenance.mjs';
+import { examineCandidates, proveSlots, reprove } from './provenance.mjs';
const RATE_RESERVE = 100;
const API_FAILURES = new Set([403, 404, 409, 429, 500, 502, 503, 504]);
@@ -37,16 +37,19 @@ const FOCUSED_RELOADS = 3;
export const EXTRA_RELOADS = 3;
const RELOADS = FOCUSED_RELOADS + EXTRA_RELOADS;
-// QNBS-v3: one candidate costs two full refreshes (list pages, every reloadable history that may grow by a page, the main read), the POST and DELETE, and a worst-case re-proof of both slots and the candidate.
+// Re-reading the run and the job of the candidate and of both slots at one write boundary.
+const REPROOF = 6;
+
+// QNBS-v3: one candidate costs two full refreshes (list pages, every reloadable history that may grow by a page, the main read, the re-proof of both slots and the candidate) and the POST and DELETE.
export function candidateCeiling(ctx) {
const { list, status } = ctx.observed;
- return 2 * (list + RELOADS * (status + 1) + 1) + 2 + 6;
+ return 2 * (list + RELOADS * (status + 1) + 1 + REPROOF) + 2;
}
// QNBS-v3: reads may be retried, so everything after the inactive-status POST is reserved at READ_ATTEMPTS per read plus the DELETE, and the POST is only issued when that worst case still fits the budget.
export function finishCeiling(ctx) {
const { list, status } = ctx.observed;
- const reads = list + RELOADS * (status + 1) + 1 + 6 + 3;
+ const reads = list + RELOADS * (status + 1) + 1 + REPROOF + 3;
return READ_ATTEMPTS * reads + 1;
}
@@ -166,8 +169,8 @@ async function revalidate(session, candidate, expected) {
if (!record || !candidateStillEligible) return null;
if (fingerprint(record) !== expected) return null;
for (const id of [slots.current, slots.rollback])
- await ensureProven(ctx, fresh.find((item) => item.id === id) ?? refuse('current-moved'));
- await ensureProven(ctx, record);
+ await reprove(ctx, fresh.find((item) => item.id === id) ?? refuse('current-moved'));
+ await reprove(ctx, record);
await assertMainUnchanged(ctx);
return record;
}
@@ -202,7 +205,7 @@ async function retire(session, candidate) {
let fresh = await revalidate(session, candidate, fingerprint(candidate));
if (!fresh) return skip(run, 'stale-candidate');
const needsPost = !isSettledInactive(latestStatus(fresh));
- if (needsPost && ctx.used + finishCeiling(ctx) > ctx.limit) return 'budget';
+ if (needsPost && !funds(ctx, finishCeiling(ctx))) return 'budget';
run.result.attempted += 1;
if (needsPost) {
fresh.statuses.push(await inactivate(ctx, fresh));
@@ -215,13 +218,15 @@ async function retire(session, candidate) {
session.records = session.records.filter((record) => record.id !== candidate.id);
}
-function affordable(ctx) {
- const ceiling = candidateCeiling(ctx);
- const room = ctx.used + ceiling <= ctx.limit;
- const rate = ctx.remaining === null || ctx.remaining >= RATE_RESERVE + ceiling;
+// QNBS-v3: a cost is funded only when it fits both the local request counter and the live rate allowance last reported by GitHub, with a reserve kept for CI.
+function funds(ctx, cost) {
+ const room = ctx.used + cost <= ctx.limit;
+ const rate = ctx.remaining === null || ctx.remaining >= RATE_RESERVE + cost;
return room && rate;
}
+const affordable = (ctx) => funds(ctx, candidateCeiling(ctx));
+
async function mutate(session) {
const { ctx, run } = session;
for (const candidate of run.batch) {
diff --git a/scripts/prune-pages-deployments.mjs b/scripts/prune-pages-deployments.mjs
index 24a4ff3a1..ac6afbaf4 100644
--- a/scripts/prune-pages-deployments.mjs
+++ b/scripts/prune-pages-deployments.mjs
@@ -73,9 +73,12 @@ function readOnlyClient(token) {
};
}
+// QNBS-v3: the token is requested for github.com explicitly, because GH_HOST or an enterprise default would return a credential for another host, and every request below goes to api.github.com.
+export const TOKEN_ARGS = ['auth', 'token', '--hostname', 'github.com'];
+
async function localDryRun() {
if (process.argv.length !== 3 || process.argv[2] !== '--dry-run') refuse('invalid-cli-mode');
- const token = execFileSync('gh', ['auth', 'token'], {
+ const token = execFileSync('gh', TOKEN_ARGS, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index 2332d3959..a3aa26bb5 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -15,6 +15,7 @@ import {
DESTRUCTIVE_WRITES_ADMITTED,
planPruning,
runAction,
+ TOKEN_ARGS,
validateHolds,
} from '../../../scripts/prune-pages-deployments.mjs';
import {
@@ -610,6 +611,38 @@ for (const [name, forge] of Object.entries(forgeries))
);
});
+test('evidence that disappears after classification protects the record at the write boundary', async () => {
+ let looked = 0;
+ const github = client(records(5), (route) => {
+ if (route.endsWith('/actions/runs/1002') && ++looked > 1) throw apiError(404);
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['unknown-publication'], 1);
+ assert.equal(result.deleted, 0);
+ assert.deepEqual(writes(github), []);
+ assert.equal(looked, 2, 'classification read the run once and the write boundary read it again');
+});
+
+for (const [remaining, outcome] of [
+ ['120', 'is not issued'],
+ ['5000', 'is issued'],
+])
+ test(`the inactive-status POST ${outcome} at ${remaining} live requests left`, async () => {
+ const github = client(records(4), (route) => {
+ if (route.endsWith('/branches/main'))
+ return {
+ data: { commit: { sha: authority.sha } },
+ headers: { 'x-ratelimit-remaining': remaining },
+ };
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, remaining === '5000' ? 1 : 0);
+ assert.equal(writes(github).length, remaining === '5000' ? 2 : 0);
+ if (remaining !== '5000') assert.ok(result.reasons['budget-limit'] > 0);
+ });
+
test('a candidate whose publication run no longer exists is protected', async () => {
const github = client(records(5), (route) => {
if (route.endsWith('/actions/runs/1001')) throw apiError(404);
@@ -774,9 +807,9 @@ test('caps each invocation at ten records and reports remaining backlog', async
const statusReads = (github) => reads(github, /\/statuses$/).length;
for (const [count, bound] of [
- [6, 60],
- [50, 240],
- [100, 300],
+ [6, 100],
+ [50, 370],
+ [100, 420],
])
test(`request volume stays bounded for ${count} records`, async () => {
const github = client(records(count));
@@ -812,9 +845,9 @@ test('an inventory larger than the request budget fails closed without writes',
});
test('the per-candidate cost ceiling grows with the observed inventory depth', () => {
- assert.equal(candidateCeiling({ observed: { list: 1, status: 1 } }), 36);
+ assert.equal(candidateCeiling({ observed: { list: 1, status: 1 } }), 42);
const deep = candidateCeiling({ observed: { list: 4, status: 5 } });
- assert.equal(deep, 90);
+ assert.equal(deep, 96);
assert.ok(deep > 40, 'a fixed 40-request allowance would under-reserve this inventory');
});
@@ -834,14 +867,14 @@ const deepHistory = (input) => {
test('a candidate pass is reserved at the observed inventory depth before any write', async () => {
const dry = await execute(client(deepHistory(records(14))), { dryRun: true });
const ceiling = candidateCeiling({ observed: { list: 1, status: 5 } });
- assert.equal(ceiling, 84);
+ assert.equal(ceiling, 90);
const short = client(deepHistory(records(14)));
const refused = await executeDestructive(short, { limit: dry.requests + ceiling - 1 });
assert.equal(refused.failed, 0);
assert.ok(refused.reasons['budget-limit'] > 0);
assert.deepEqual(writes(short), []);
- // First revalidation: list page, candidate and rollback histories, five current-record pages, main read.
- const preWrite = 1 + 1 + 5 + 1 + 1;
+ // First revalidation: list page, candidate and rollback histories, five current-record pages, main read, three re-proofs.
+ const preWrite = 1 + 1 + 5 + 1 + 1 + 6;
const funded = client(deepHistory(records(14)));
const done = await executeDestructive(funded, {
limit: dry.requests + preWrite + finishCeiling({ observed: { list: 1, status: 5 } }),
@@ -857,8 +890,8 @@ test('the post-POST reservation covers every read at its full retry count', () =
test('the POST is only issued when everything after it fits the budget even with retried reads', async () => {
const dry = await execute(client(records(8)), { dryRun: true });
- // First revalidation: list page, three focused histories, main read.
- const preWrite = 5;
+ // First revalidation: list page, three focused histories, main read, three re-proofs.
+ const preWrite = 5 + 6;
const finish = finishCeiling({ observed: { list: 1, status: 1 } });
const short = client(records(8));
const refused = await executeDestructive(short, { limit: dry.requests + preWrite + finish - 1 });
@@ -1037,6 +1070,10 @@ test('Actions warns when the backlog exceeds one bounded pass', async () => {
assert.equal(warnings.length, 1);
});
+test('the local CLI asks for the github.com token explicitly, whatever GH_HOST says', () => {
+ assert.deepEqual(TOKEN_ARGS, ['auth', 'token', '--hostname', 'github.com']);
+});
+
test('the local CLI refuses every mode except --dry-run before any network access', () => {
const script = fileURLToPath(
new URL('../../../scripts/prune-pages-deployments.mjs', import.meta.url),
From 97206e2c4ef87fa7e55feb4144cdf592f201398d Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 10:28:37 +0200
Subject: [PATCH 09/13] ci(pages): require the classifier's ownership and
Pages-status shape in the publication proof
A github-pages record of another ref, or whose success status names another site, can no longer pass the proof and take the rollback slot, which would have left the real previous publication deletable.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 4 ++-
scripts/pages-pruning/provenance.mjs | 6 +++++
.../tooling/prunePagesDeployments.test.mjs | 27 +++++++++++++++++++
3 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index d8f1b9638..7a7da3968 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -42,7 +42,9 @@ on main before it acquires writer authority.
on its own. A record is deletable only after all of these hold, read fresh from the API:
- deployment shape: `github-pages`, `main`, task `deploy`, full SHA, non-transient, non-production,
- no GitHub App other than Actions;
+ no GitHub App other than Actions, an identified author, and a newest success status for the
+ Pages site (the same shape the classifier requires, so a record it would call unknown ownership
+ can neither occupy the current or rollback slot nor be proven);
- the referenced run is the own-repository `CI / CD` run on `main` for the same SHA, started by a
`push` or a `workflow_dispatch` (the only non-pull-request events `ci.yml` can publish from; a
test binds this set to the workflow file), completed successfully, and its job
diff --git a/scripts/pages-pruning/provenance.mjs b/scripts/pages-pruning/provenance.mjs
index 1ba680544..951f08258 100644
--- a/scripts/pages-pruning/provenance.mjs
+++ b/scripts/pages-pruning/provenance.mjs
@@ -4,8 +4,10 @@ import {
ACTIONS_BOT,
actorKey,
creatorLogin,
+ isPagesStatus,
jobIdentity,
MAX_BATCH,
+ owned,
publicationOrder,
REPOSITORY,
ROOT,
@@ -61,6 +63,9 @@ function actorFacts(record, success, run) {
];
}
+// QNBS-v3: the proof covers the record shape the classifier requires, so a record it would call unknown ownership can neither occupy a publication slot nor be declared proven.
+const shapeFacts = (record, success) => [owned(record), isPagesStatus(success)];
+
async function fetchEvidence(ctx, identity) {
try {
const { data: run } = await read(ctx, `${ROOT}/actions/runs/${identity.run}`);
@@ -78,6 +83,7 @@ export async function provePublication(ctx, record) {
if (!identity) refuse(UNKNOWN);
const { run, job } = await fetchEvidence(ctx, identity);
const facts = [
+ ...shapeFacts(record, success),
...runFacts(run, record, identity),
...jobFacts(job, run, identity),
...actorFacts(record, success, run),
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index a3aa26bb5..3abe968bb 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -643,6 +643,33 @@ for (const [remaining, outcome] of [
if (remaining !== '5000') assert.ok(result.reasons['budget-limit'] > 0);
});
+const notAPagesPublication = {
+ 'a record of another ref': (item) => {
+ item.ref = 'feature';
+ },
+ 'a success status for another site': (item) => {
+ item.statuses[0].environment_url = 'https://elsewhere.example/';
+ },
+};
+for (const [name, spoil] of Object.entries(notAPagesPublication))
+ test(`${name} cannot take the rollback slot or protect the real previous publication from deletion`, async () => {
+ const input = gapped();
+ spoil(input[3]);
+ const github = client(input);
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.rollback, 3);
+ assert.equal(result.reasons['unproved-publication'], 1);
+ assert.ok(
+ github.state.some((item) => item.id === 3),
+ 'the real rollback is kept',
+ );
+ assert.ok(
+ github.state.some((item) => item.id === 4),
+ 'the spoiled record is never deleted',
+ );
+ });
+
test('a candidate whose publication run no longer exists is protected', async () => {
const github = client(records(5), (route) => {
if (route.endsWith('/actions/runs/1001')) throw apiError(404);
From cdfd0495ed732af5089a681d053f6bc066cfca72 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 10:55:55 +0200
Subject: [PATCH 10/13] ci(pages): gate scan and direct delete on the live
allowance, validate the posted inactive status, keep the manifest when a slot
proof fails
funds() moves to the GitHub client so the proof scan, the POST and a DELETE without POST share one local-plus-live budget check. The status created by this run must come back as a recognised inactive Pages status before the DELETE. A stopped slot proof still reports every record as protected or unexamined.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 15 ++--
scripts/pages-pruning/github.mjs | 8 ++
scripts/pages-pruning/provenance.mjs | 4 +-
scripts/pages-pruning/reconcile.mjs | 59 +++++++++------
.../tooling/prunePagesDeployments.test.mjs | 73 ++++++++++++++++++-
.../tooling/support/pagesPruningFixture.mjs | 4 +-
6 files changed, 132 insertions(+), 31 deletions(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index 7a7da3968..65c5e9576 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -79,7 +79,9 @@ Both publication slots are proven by API before any candidate is classified. The
must prove or the run stops (`unproved-current`). Up to four older successes are walked in order;
an unproved one is protected and does not consume the rollback slot, and the first proven one is
the rollback. If none proves, the run stops (`no-proven-rollback`). Shape-only classification
-exists for the pure plan and never authorizes a write.
+exists for the pure plan and never authorizes a write. When no record is eligible for deletion the
+slots are not proven at all: they can only retain, so an empty plan needs no evidence. When a proof
+stops the run, the manifest still lists every record as protected or unexamined.
## Trigger and mutation boundary
@@ -104,8 +106,10 @@ records may need a reload in one refresh; more churn than that stops the run
inactive-status POST is skipped only when the latest status is already an inactive Pages status
(environment `github-pages`, the Pages site URL) with an identified author; any other latest
status, including an inactive one of unknown origin, gets this run's own inactive status first, so
-deletion authority never rests on unvalidated status metadata. A failed or ambiguous inactivation
-never reaches deletion. `auto_inactive: false` avoids affecting other records. GET retries are
+deletion authority never rests on unvalidated status metadata. The status this run creates must
+come back from the API as a recognised inactive Pages status before the DELETE
+(`inactive-status-unrecognized` otherwise). A failed or ambiguous inactivation never reaches
+deletion. `auto_inactive: false` avoids affecting other records. GET retries are
bounded at three attempts and two seconds maximum delay; writes are never retried. API failures
stop the batch, fail the job and report confirmed partial outcomes with sanitized reason codes. A
later pass must re-observe uncertain writes.
@@ -132,8 +136,9 @@ the first write of a candidate the run also reserves everything after the inacti
three attempts per read plus the DELETE (70 requests for a small inventory, 151 for the
400-record case) and otherwise stops without writing. Both reservations are also checked against
the live `x-ratelimit-remaining` value last reported by GitHub, with a reserve of 100 requests kept
-for CI; the POST is not issued when the last reported allowance cannot cover the rest of the
-candidate (other consumers of the same token can still lower it afterwards). An inventory that alone exceeds the budget fails
+for CI. The same check gates the proof scan, the POST and a DELETE that needs no POST; none of them
+is issued when the last reported allowance cannot cover it (other consumers of the same token can
+still lower it afterwards). An inventory that alone exceeds the budget fails
closed with `request-budget` before any write. Measured by the test fixture (GET + POST + DELETE,
ten candidates):
diff --git a/scripts/pages-pruning/github.mjs b/scripts/pages-pruning/github.mjs
index 747ab6a1d..598edc27c 100644
--- a/scripts/pages-pruning/github.mjs
+++ b/scripts/pages-pruning/github.mjs
@@ -8,6 +8,14 @@ const PAGE_SIZE = 100;
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const BACKOFF_CAP_MS = 2000;
export const READ_ATTEMPTS = 3;
+const RATE_RESERVE = 100;
+
+// QNBS-v3: a cost is funded only when it fits both the local request counter and the live rate allowance last reported by GitHub, with a reserve kept for CI.
+export function funds(ctx, cost) {
+ const room = ctx.used + cost <= ctx.limit;
+ const rate = ctx.remaining === null || ctx.remaining >= RATE_RESERVE + cost;
+ return room && rate;
+}
export const defaultPause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/scripts/pages-pruning/provenance.mjs b/scripts/pages-pruning/provenance.mjs
index 951f08258..88dd35759 100644
--- a/scripts/pages-pruning/provenance.mjs
+++ b/scripts/pages-pruning/provenance.mjs
@@ -1,5 +1,5 @@
import { newestSuccess, proofKey, refuse } from './evidence.mjs';
-import { READ_ATTEMPTS, read } from './github.mjs';
+import { funds, READ_ATTEMPTS, read } from './github.mjs';
import {
ACTIONS_BOT,
actorKey,
@@ -133,7 +133,7 @@ export async function proveSlots(ctx, records) {
return refuse('no-proven-rollback');
}
-const hasRoomToProve = (ctx, floor) => ctx.used + floor + PROOF_COST <= ctx.limit;
+const hasRoomToProve = (ctx, floor) => funds(ctx, floor + PROOF_COST);
// QNBS-v3: every examined candidate gets causal proof, so the manifest never reports an unexamined record as a candidate; an unprovable record is protected and does not count against EXAMINE_LIMIT, so permanently unprovable newest records cannot hide older provable ones, and the scan ends early only when the budget left would no longer fund one more proof plus the floor kept for the mutation phase.
export async function examineCandidates(ctx, candidates, slots, floor = 0) {
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index 02b1ea327..df9487cac 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -10,6 +10,7 @@ import {
} from './evidence.mjs';
import {
createContext,
+ funds,
inventory,
listRecords,
loadStatuses,
@@ -29,7 +30,6 @@ import {
} from './policy.mjs';
import { examineCandidates, proveSlots, reprove } from './provenance.mjs';
-const RATE_RESERVE = 100;
const API_FAILURES = new Set([403, 404, 409, 429, 500, 502, 503, 504]);
// QNBS-v3: a refresh reloads the three focused histories and at most EXTRA_RELOADS others whose list key changed, so its cost has a ceiling; more churn than that stops the run.
@@ -39,6 +39,7 @@ const RELOADS = FOCUSED_RELOADS + EXTRA_RELOADS;
// Re-reading the run and the job of the candidate and of both slots at one write boundary.
const REPROOF = 6;
+const DELETE_COST = 1;
// QNBS-v3: one candidate costs two full refreshes (list pages, every reloadable history that may grow by a page, the main read, the re-proof of both slots and the candidate) and the POST and DELETE.
export function candidateCeiling(ctx) {
@@ -126,6 +127,25 @@ async function prepare(ctx, holds, records, first) {
return { plan: planPruning(records, holds, { slots }), slots, ...examined };
}
+// QNBS-v3: a run that stops before every eligible record is proven still lists them, as unexamined, so the manifest never silently drops records the counts include.
+function reportUnexamined(run, records, first) {
+ report(
+ run,
+ records,
+ { plan: first, proven: [], unexamined: first.candidates },
+ 'blocked-before-proof',
+ );
+}
+
+async function prepareOrReport(ctx, run, holds, records, first) {
+ try {
+ return await prepare(ctx, holds, records, first);
+ } catch (error) {
+ reportUnexamined(run, records, first);
+ throw error;
+ }
+}
+
function reusableStatuses(before, item, focus) {
if (!before || focus.has(item.id)) return null;
return listKey(item) === listKey(before) ? before.statuses : null;
@@ -199,32 +219,33 @@ const isSettledInactive = (status) =>
Boolean,
);
+// QNBS-v3: the status this run just created must come back from the API as a recognised inactive Pages status before the DELETE, so malformed or incompatible status data keeps the record.
+async function settle(session, candidate, fresh) {
+ const { ctx, run } = session;
+ fresh.statuses.push(await inactivate(ctx, fresh));
+ run.result.inactivated += 1;
+ const next = await revalidate(session, candidate, fingerprint(fresh));
+ if (!next) return { skip: 'status-moved' };
+ return isSettledInactive(latestStatus(next)) ? {} : { skip: 'inactive-status-unrecognized' };
+}
+
// QNBS-v3: a failed or ambiguous inactivation never reaches DELETE, and writes are never retried against an assumed inventory.
async function retire(session, candidate) {
const { ctx, run } = session;
- let fresh = await revalidate(session, candidate, fingerprint(candidate));
+ const fresh = await revalidate(session, candidate, fingerprint(candidate));
if (!fresh) return skip(run, 'stale-candidate');
const needsPost = !isSettledInactive(latestStatus(fresh));
- if (needsPost && !funds(ctx, finishCeiling(ctx))) return 'budget';
+ if (!funds(ctx, needsPost ? finishCeiling(ctx) : DELETE_COST)) return 'budget';
run.result.attempted += 1;
if (needsPost) {
- fresh.statuses.push(await inactivate(ctx, fresh));
- run.result.inactivated += 1;
- fresh = await revalidate(session, candidate, fingerprint(fresh));
- if (!fresh) return skip(run, 'status-moved');
+ const settled = await settle(session, candidate, fresh);
+ if (settled.skip) return skip(run, settled.skip);
}
await write(ctx, `DELETE ${ROOT}/deployments/${candidate.id}`);
run.result.deleted += 1;
session.records = session.records.filter((record) => record.id !== candidate.id);
}
-// QNBS-v3: a cost is funded only when it fits both the local request counter and the live rate allowance last reported by GitHub, with a reserve kept for CI.
-function funds(ctx, cost) {
- const room = ctx.used + cost <= ctx.limit;
- const rate = ctx.remaining === null || ctx.remaining >= RATE_RESERVE + cost;
- return room && rate;
-}
-
const affordable = (ctx) => funds(ctx, candidateCeiling(ctx));
async function mutate(session) {
@@ -249,16 +270,10 @@ async function execute(ctx, run, holds) {
run.result.queried = records.length;
const first = planPruning(records, holds);
if (first.blocked) {
- // QNBS-v3: a blocked run still lists every eligible record, as unexamined, so the manifest never silently drops records the counts include.
- report(
- run,
- records,
- { plan: first, proven: [], unexamined: first.candidates },
- 'blocked-before-proof',
- );
+ reportUnexamined(run, records, first);
refuse('unproved-current-or-unsettled');
}
- const prepared = await prepare(ctx, holds, records, first);
+ const prepared = await prepareOrReport(ctx, run, holds, records, first);
run.batch = prepared.batch;
report(run, records, prepared);
if (run.result.mode !== 'delete') return finishDryRun(run, records);
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index 3abe968bb..1fc1a767a 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -670,6 +670,77 @@ for (const [name, spoil] of Object.entries(notAPagesPublication))
);
});
+test('a status this run just created must come back as a recognised inactive Pages status', async () => {
+ const github = client(records(4), (route, parameters, state) => {
+ if (!route.startsWith('POST ')) return undefined;
+ const item = state.find((entry) => entry.id === 1);
+ const created = { ...parameters, id: 9999, created_at: date(900) };
+ item.statuses.push(created);
+ item.updated_at = created.created_at;
+ return { data: created, headers: {} };
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 0);
+ assert.equal(result.reasons['inactive-status-unrecognized'], 1);
+ assert.deepEqual(
+ writes(github).map((call) => call.route),
+ [`POST ${ROOT}/deployments/1/statuses`],
+ );
+});
+
+test('an already inactive candidate is not deleted when the live allowance is below the reserve', async () => {
+ const lowAllowance = (remaining) =>
+ client(
+ (() => {
+ const input = records(4);
+ input[0].statuses.push({
+ ...input[0].statuses[0],
+ id: 900,
+ created_at: date(900),
+ state: 'inactive',
+ });
+ return input;
+ })(),
+ (route) =>
+ route.endsWith('/branches/main')
+ ? {
+ data: { commit: { sha: authority.sha } },
+ headers: { 'x-ratelimit-remaining': remaining },
+ }
+ : undefined,
+ );
+ const low = lowAllowance('90');
+ const refused = await executeDestructive(low);
+ assert.equal(refused.failed, 0);
+ assert.equal(refused.deleted, 0);
+ assert.ok(refused.reasons['budget-limit'] > 0);
+ assert.deepEqual(writes(low), []);
+ const high = lowAllowance('5000');
+ assert.equal((await executeDestructive(high)).deleted, 1);
+});
+
+test('the proof scan stops when the live allowance cannot cover another proof and one candidate', async () => {
+ const github = client(records(20), undefined, { 'x-ratelimit-remaining': '120' });
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 0);
+ assert.equal(result.candidate, 0);
+ assert.ok(result.reasons.unexamined > 0);
+});
+
+test('a failed slot proof still reports the whole inventory as protected or unexamined', async () => {
+ const github = client(records(6), (route) => {
+ if (route.includes('/actions/runs/')) throw apiError(404);
+ });
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['unproved-current'], 1);
+ assert.equal(result.classified, 6);
+ assert.equal(result.manifest.length, 6);
+ assert.equal(disposition(result, 1).disposition, 'unexamined');
+ assert.deepEqual(writes(github), []);
+});
+
test('a candidate whose publication run no longer exists is protected', async () => {
const github = client(records(5), (route) => {
if (route.endsWith('/actions/runs/1001')) throw apiError(404);
@@ -1033,7 +1104,7 @@ test('a low live rate-limit allowance stops before the next candidate', async ()
const result = await executeDestructive(github);
assert.equal(result.failed, 0);
assert.equal(result.deleted, 0);
- assert.ok(result.reasons['budget-limit'] > 0);
+ assert.ok((result.reasons['budget-limit'] ?? 0) + (result.reasons.unexamined ?? 0) > 0);
assert.deepEqual(writes(github), []);
});
diff --git a/tests/unit/tooling/support/pagesPruningFixture.mjs b/tests/unit/tooling/support/pagesPruningFixture.mjs
index 19408090e..464dec421 100644
--- a/tests/unit/tooling/support/pagesPruningFixture.mjs
+++ b/tests/unit/tooling/support/pagesPruningFixture.mjs
@@ -7,6 +7,8 @@ export const REPO_ID = 4242;
export const ACTOR = 'maintainer';
// A principal is the account id and the login together; the publication proof matches both.
export const PRINCIPAL = { login: ACTOR, id: 9001 };
+// The principal the pruning workflow's own token writes as.
+const WORKFLOW_BOT = { login: 'github-actions[bot]', id: 41898282 };
export const date = (id) => new Date(Date.UTC(2026, 0, 1) + id * 1000).toISOString();
export const authority = {
repository: REPOSITORY,
@@ -107,7 +109,7 @@ function routeTable(state, next) {
async (m, p) => {
assert.equal(p.auto_inactive, false);
const item = await find(m[1]);
- const created = { ...p, id: next(), created_at: date(next()) };
+ const created = { ...p, id: next(), created_at: date(next()), creator: WORKFLOW_BOT };
item.statuses.push(created);
item.updated_at = created.created_at;
return created;
From 2b1e677467d291e746e1ea0672cec0a5a92fda8b Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 11:11:40 +0200
Subject: [PATCH 11/13] ci(pages): count rate headers of failed requests, bind
job evidence to the run SHA, accept the documented workflow path form, stop
gracefully on a pre-write budget refusal
Failed reads and writes update the live allowance the scan and the write gates use. The job must report the same head SHA as its run. The CI workflow path matches with or without the documented main suffix. A request-budget refusal before a candidate's first write ends the pass as a budget-limit stop. prepareOrReport takes one input object to stay inside the argument limit.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 10 ++-
scripts/pages-pruning/github.mjs | 12 +++-
scripts/pages-pruning/provenance.mjs | 6 +-
scripts/pages-pruning/reconcile.mjs | 18 +++++-
.../tooling/prunePagesDeployments.test.mjs | 64 +++++++++++++++++++
.../tooling/support/pagesPruningFixture.mjs | 1 +
6 files changed, 102 insertions(+), 9 deletions(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index 65c5e9576..180fa4dfb 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -48,7 +48,10 @@ on its own. A record is deletable only after all of these hold, read fresh from
- the referenced run is the own-repository `CI / CD` run on `main` for the same SHA, started by a
`push` or a `workflow_dispatch` (the only non-pull-request events `ci.yml` can publish from; a
test binds this set to the workflow file), completed successfully, and its job
- `🚀 Deploy to GitHub Pages` and step `Deploy to GitHub Pages` succeeded;
+ `🚀 Deploy to GitHub Pages` (same run, same head SHA) and step `Deploy to GitHub Pages`
+ succeeded. The workflow path is `.github/workflows/ci.yml`, with or without GitHub's documented
+ `@main` / `@refs/heads/main` suffix (live runs report it without one); any other file or ref
+ does not prove;
- **causal link:** the deployment record and its newest success status were authored by the actor
or triggering actor of that run, matched by numeric account id and login together (an author
without a valid id never matches), and neither author is `github-actions[bot]`.
@@ -138,7 +141,10 @@ three attempts per read plus the DELETE (70 requests for a small inventory, 151
the live `x-ratelimit-remaining` value last reported by GitHub, with a reserve of 100 requests kept
for CI. The same check gates the proof scan, the POST and a DELETE that needs no POST; none of them
is issued when the last reported allowance cannot cover it (other consumers of the same token can
-still lower it afterwards). An inventory that alone exceeds the budget fails
+still lower it afterwards). The rate headers of failed reads and writes count too, so a run of
+404 responses cannot spend the reserve unnoticed. A budget refusal before a candidate's first
+write (a retry storm in its first revalidation) is the same graceful `budget-limit` stop; after the
+POST it stays a failure. An inventory that alone exceeds the budget fails
closed with `request-budget` before any write. Measured by the test fixture (GET + POST + DELETE,
ten candidates):
diff --git a/scripts/pages-pruning/github.mjs b/scripts/pages-pruning/github.mjs
index 598edc27c..ea948259a 100644
--- a/scripts/pages-pruning/github.mjs
+++ b/scripts/pages-pruning/github.mjs
@@ -66,6 +66,7 @@ export async function read(ctx, route, parameters = {}) {
noteRate(ctx, response.headers);
return response;
} catch (error) {
+ noteRate(ctx, error.response?.headers);
const delay = retryDelay(error, attempt);
if (!shouldRetry(error, attempt, delay)) throw error;
await ctx.pause(delay);
@@ -76,9 +77,14 @@ export async function read(ctx, route, parameters = {}) {
// QNBS-v3: writes are never retried; an ambiguous outcome is re-observed by a later pass instead of repeated against an assumed inventory.
export async function write(ctx, route, parameters) {
spend(ctx);
- const response = await ctx.github.request(route, parameters);
- noteRate(ctx, response?.headers);
- return response;
+ try {
+ const response = await ctx.github.request(route, parameters);
+ noteRate(ctx, response?.headers);
+ return response;
+ } catch (error) {
+ noteRate(ctx, error.response?.headers);
+ throw error;
+ }
}
async function repositoryId(ctx) {
diff --git a/scripts/pages-pruning/provenance.mjs b/scripts/pages-pruning/provenance.mjs
index 88dd35759..cabcec356 100644
--- a/scripts/pages-pruning/provenance.mjs
+++ b/scripts/pages-pruning/provenance.mjs
@@ -21,13 +21,16 @@ const PROOF_COST = 2 * READ_ATTEMPTS;
export const PUBLISH_EVENTS = new Set(['push', 'workflow_dispatch']);
const UNKNOWN = 'unknown-publication';
+// GitHub documents the workflow path with an optional ref suffix; only the CI file on main is accepted, in either spelling.
+const CI_PATH = /^\.github\/workflows\/ci\.yml(?:@(?:refs\/heads\/)?main)?$/;
+
function runFacts(run, record, identity) {
return [
run.id === identity.run,
run.repository?.full_name === REPOSITORY,
run.head_repository?.full_name === REPOSITORY,
run.name === 'CI / CD',
- run.path === '.github/workflows/ci.yml',
+ CI_PATH.test(run.path ?? ''),
PUBLISH_EVENTS.has(run.event),
run.head_branch === 'main',
run.head_sha === record.sha,
@@ -43,6 +46,7 @@ function jobFacts(job, run, identity) {
return [
job.id === identity.job,
job.run_id === run.id,
+ job.head_sha === run.head_sha,
job.name === '🚀 Deploy to GitHub Pages',
job.status === 'completed',
job.conclusion === 'success',
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index df9487cac..a126e1766 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -137,7 +137,7 @@ function reportUnexamined(run, records, first) {
);
}
-async function prepareOrReport(ctx, run, holds, records, first) {
+async function prepareOrReport(ctx, run, { holds, records, first }) {
try {
return await prepare(ctx, holds, records, first);
} catch (error) {
@@ -248,6 +248,18 @@ async function retire(session, candidate) {
const affordable = (ctx) => funds(ctx, candidateCeiling(ctx));
+// QNBS-v3: a budget refusal before this candidate's first write (a retry storm in its first revalidation) is the same graceful stop as an unaffordable candidate, because nothing was written yet; after a write it stays a failure.
+async function retireWithinBudget(session, candidate) {
+ const { run } = session;
+ const attempted = run.result.attempted;
+ try {
+ return await retire(session, candidate);
+ } catch (error) {
+ if (error.reason === 'request-budget' && run.result.attempted === attempted) return 'budget';
+ throw error;
+ }
+}
+
async function mutate(session) {
const { ctx, run } = session;
for (const candidate of run.batch) {
@@ -256,7 +268,7 @@ async function mutate(session) {
return;
}
run.considered += 1;
- if ((await retire(session, candidate)) === 'budget') {
+ if ((await retireWithinBudget(session, candidate)) === 'budget') {
run.considered -= 1;
run.reason('budget-limit', run.batch.length - run.considered);
return;
@@ -273,7 +285,7 @@ async function execute(ctx, run, holds) {
reportUnexamined(run, records, first);
refuse('unproved-current-or-unsettled');
}
- const prepared = await prepareOrReport(ctx, run, holds, records, first);
+ const prepared = await prepareOrReport(ctx, run, { holds, records, first });
run.batch = prepared.batch;
report(run, records, prepared);
if (run.result.mode !== 'delete') return finishDryRun(run, records);
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index 1fc1a767a..df031e34e 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -529,6 +529,10 @@ const runChanges = [
{ conclusion: 'failure' },
{ conclusion: 'cancelled' },
{ path: '.github/workflows/other.yml' },
+ { path: '.github/workflows/ci.yml@refs/heads/feature' },
+ { path: '.github/workflows/ci.yml@other' },
+ { path: '.github/workflows/ci.yml.evil' },
+ { path: undefined },
{ actor: { login: 'intruder', id: 7 }, triggering_actor: { login: 'intruder', id: 7 } },
{ actor: { login: 'maintainer' }, triggering_actor: { login: 'maintainer' } },
{ actor: { login: 'maintainer', id: 1 }, triggering_actor: { login: 'maintainer', id: 1 } },
@@ -555,6 +559,8 @@ for (const change of [
{ name: 'Other job' },
{ steps: [] },
{ run_id: 1 },
+ { head_sha: 'f'.repeat(40) },
+ { head_sha: undefined },
])
test(`rejects job evidence ${JSON.stringify(change)}`, async () => {
const github = client(records(4), (route) =>
@@ -741,6 +747,64 @@ test('a failed slot proof still reports the whole inventory as protected or unex
assert.deepEqual(writes(github), []);
});
+for (const path of ['.github/workflows/ci.yml@refs/heads/main', '.github/workflows/ci.yml@main'])
+ test(`a ref-qualified workflow path ${path} still proves the publication`, async () => {
+ const github = client(records(4), (route) =>
+ route.includes('/actions/runs/')
+ ? {
+ data: { ...publication(Number(route.split('/').at(-1)) - 1000), path },
+ headers: {},
+ }
+ : undefined,
+ );
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 1);
+ });
+
+test('rate-limit headers of a failed read lower the live allowance used by the proof scan', async () => {
+ const github = client(records(20), (route) => {
+ if (!route.endsWith('/actions/runs/1017')) return undefined;
+ throw Object.assign(apiError(404), {
+ response: { headers: { 'x-ratelimit-remaining': '120' } },
+ });
+ });
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 0);
+ assert.equal(result.candidate, 0);
+ assert.equal(result.reasons['unproved-publication'], 1);
+ assert.equal(result.reasons.unexamined, 16);
+});
+
+const budgetRefusal = () => Object.assign(new Error('budget'), { reason: 'request-budget' });
+
+test('a budget refusal before the first write of a candidate is the graceful stop, not a failure', async () => {
+ const github = client(records(4), (route) => {
+ if (route.endsWith('/branches/main')) throw budgetRefusal();
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 0);
+ assert.ok(result.reasons['budget-limit'] > 0);
+ assert.equal(result.followUpRequired, true);
+ assert.deepEqual(writes(github), []);
+});
+
+test('the same budget refusal after the inactive-status POST stays a failure', async () => {
+ let mains = 0;
+ const github = client(records(4), (route) => {
+ if (route.endsWith('/branches/main') && ++mains === 2) throw budgetRefusal();
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['request-budget'], 1);
+ assert.equal(result.deleted, 0);
+ assert.deepEqual(
+ writes(github).map((call) => call.route),
+ [`POST ${ROOT}/deployments/1/statuses`],
+ );
+});
+
test('a candidate whose publication run no longer exists is protected', async () => {
const github = client(records(5), (route) => {
if (route.endsWith('/actions/runs/1001')) throw apiError(404);
diff --git a/tests/unit/tooling/support/pagesPruningFixture.mjs b/tests/unit/tooling/support/pagesPruningFixture.mjs
index 464dec421..bb32ca0bd 100644
--- a/tests/unit/tooling/support/pagesPruningFixture.mjs
+++ b/tests/unit/tooling/support/pagesPruningFixture.mjs
@@ -74,6 +74,7 @@ export const publication = (id, overrides = {}) => ({
export const job = (jobId) => ({
id: jobId,
run_id: jobId - 1000,
+ head_sha: record(jobId - 2000).sha,
name: '🚀 Deploy to GitHub Pages',
status: 'completed',
conclusion: 'success',
From dec972c20314d081b2158d782b645ffcffd1e7d8 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 11:30:20 +0200
Subject: [PATCH 12/13] ci(pages): gate every read on the live allowance and
the CI reserve
read() refuses with rate-reserve before a request that would leave fewer than 100 requests, retries and the whole inventory included, and with rate-unknown once a response succeeded without a rate header. A budget or reserve refusal before a candidate's first write ends the pass gracefully. The fixture answers with a rate header like the real API.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 7 ++-
scripts/pages-pruning/github.mjs | 11 ++++-
scripts/pages-pruning/reconcile.mjs | 3 +-
.../tooling/prunePagesDeployments.test.mjs | 48 +++++++++++++++++++
.../tooling/support/pagesPruningFixture.mjs | 11 ++++-
5 files changed, 75 insertions(+), 5 deletions(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index 180fa4dfb..034805be4 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -141,8 +141,11 @@ three attempts per read plus the DELETE (70 requests for a small inventory, 151
the live `x-ratelimit-remaining` value last reported by GitHub, with a reserve of 100 requests kept
for CI. The same check gates the proof scan, the POST and a DELETE that needs no POST; none of them
is issued when the last reported allowance cannot cover it (other consumers of the same token can
-still lower it afterwards). The rate headers of failed reads and writes count too, so a run of
-404 responses cannot spend the reserve unnoticed. A budget refusal before a candidate's first
+still lower it afterwards). Every read, retries and the whole inventory included, is also gated
+by the live allowance before it is sent: a read that would leave fewer than 100 requests stops the
+run with `rate-reserve`, and once a response has succeeded a missing rate header stops it with
+`rate-unknown`, so the local 450 counter is never the only brake. The rate headers of failed reads
+and writes count too, so a run of 404 responses cannot spend the reserve unnoticed. A budget refusal before a candidate's first
write (a retry storm in its first revalidation) is the same graceful `budget-limit` stop; after the
POST it stays a failure. An inventory that alone exceeds the budget fails
closed with `request-budget` before any write. Measured by the test fixture (GET + POST + DELETE,
diff --git a/scripts/pages-pruning/github.mjs b/scripts/pages-pruning/github.mjs
index ea948259a..14dd32594 100644
--- a/scripts/pages-pruning/github.mjs
+++ b/scripts/pages-pruning/github.mjs
@@ -26,6 +26,7 @@ export function createContext({ github, authority, pause = defaultPause, limit =
pause,
limit,
used: 0,
+ answered: false,
remaining: null,
repositoryId: null,
proven: new Map(),
@@ -58,11 +59,19 @@ function shouldRetry(error, attempt, delay) {
].every(Boolean);
}
+// QNBS-v3: every read, each retry included, passes the live allowance before it is sent; once a response has succeeded the allowance must be known, so a missing rate header stops the run instead of leaving the local counter as the only brake (failed attempts before any success are bounded by READ_ATTEMPTS), and a read never takes the allowance below the CI reserve.
+function spendRead(ctx) {
+ if (ctx.answered && ctx.remaining === null) refuse('rate-unknown');
+ if (ctx.remaining !== null && ctx.remaining < RATE_RESERVE + 1) refuse('rate-reserve');
+ spend(ctx);
+}
+
export async function read(ctx, route, parameters = {}) {
for (let attempt = 0; ; attempt++) {
- spend(ctx);
+ spendRead(ctx);
try {
const response = await ctx.github.request(`GET ${route}`, parameters);
+ ctx.answered = true;
noteRate(ctx, response.headers);
return response;
} catch (error) {
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
index a126e1766..117daf0e4 100644
--- a/scripts/pages-pruning/reconcile.mjs
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -255,7 +255,8 @@ async function retireWithinBudget(session, candidate) {
try {
return await retire(session, candidate);
} catch (error) {
- if (error.reason === 'request-budget' && run.result.attempted === attempted) return 'budget';
+ const spent = ['request-budget', 'rate-reserve'].includes(error.reason);
+ if (spent && run.result.attempted === attempted) return 'budget';
throw error;
}
}
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index df031e34e..2123286a2 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -805,6 +805,54 @@ test('the same budget refusal after the inactive-status POST stays a failure', a
);
});
+// The allowance falls by one per call, like the real counter.
+const countdown = (start) => (calls) => ({ 'x-ratelimit-remaining': String(start - calls) });
+
+test('inventory reads stop before the live allowance drops below the CI reserve', async () => {
+ const github = client(records(150), undefined, countdown(120));
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['rate-reserve'], 1);
+ // The 20th response reports 100 left, which is the reserve; no 21st request is sent.
+ assert.equal(github.calls.length, 20);
+ assert.equal(result.requests, 20);
+ assert.equal(result.candidate, 0);
+ assert.deepEqual(writes(github), []);
+});
+
+for (const [remaining, completes] of [
+ ['101', true],
+ ['100', false],
+])
+ test(`an allowance of ${remaining} ${completes ? 'lets the read-only pass finish' : 'is refused after the first request'}`, async () => {
+ const github = client(records(6), undefined, { 'x-ratelimit-remaining': remaining });
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, completes ? 0 : 1);
+ assert.equal(result.reasons['rate-reserve'], completes ? undefined : 1);
+ if (!completes) assert.equal(github.calls.length, 1);
+ assert.deepEqual(writes(github), []);
+ });
+
+test('a response without a rate header stops the run after the first request', async () => {
+ const github = client(records(4), undefined, { 'x-ratelimit-remaining': undefined });
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['rate-unknown'], 1);
+ assert.equal(github.calls.length, 1);
+});
+
+test('a throttled response lowers the allowance before any retry can be sent', async () => {
+ const github = client(records(4), () => {
+ throw Object.assign(apiError(429, '0'), {
+ response: { headers: { 'retry-after': '0', 'x-ratelimit-remaining': '50' } },
+ });
+ });
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['rate-reserve'], 1);
+ assert.equal(github.calls.length, 1);
+});
+
test('a candidate whose publication run no longer exists is protected', async () => {
const github = client(records(5), (route) => {
if (route.endsWith('/actions/runs/1001')) throw apiError(404);
diff --git a/tests/unit/tooling/support/pagesPruningFixture.mjs b/tests/unit/tooling/support/pagesPruningFixture.mjs
index bb32ca0bd..d2264112a 100644
--- a/tests/unit/tooling/support/pagesPruningFixture.mjs
+++ b/tests/unit/tooling/support/pagesPruningFixture.mjs
@@ -130,6 +130,12 @@ function routeTable(state, next) {
];
}
+// Like the real API, every fixture response reports the live allowance unless a test overrides it; `headers` may be a function of the call count.
+const withAllowance = (headers, count) => ({
+ 'x-ratelimit-remaining': '5000',
+ ...(typeof headers === 'function' ? headers(count) : headers),
+});
+
export function client(initial, intercept = () => undefined, headers = {}) {
const state = structuredClone(initial);
const calls = [];
@@ -147,7 +153,10 @@ export function client(initial, intercept = () => undefined, headers = {}) {
);
if (!hit) throw new Error(`Unexpected fixture route ${route}`);
const match = typeof hit[0] === 'string' ? null : hit[0].exec(route);
- return { data: structuredClone(await hit[1](match, parameters)), headers };
+ return {
+ data: structuredClone(await hit[1](match, parameters)),
+ headers: withAllowance(headers, calls.length),
+ };
},
};
}
From 064e665add56936976f0a99641e7bd09ecb60a1a Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:06:24 +0200
Subject: [PATCH 13/13] ci(pages): a response without a usable rate header
leaves the allowance unknown
A successful response replaces the live allowance, so one that omits or malforms the header no longer leaves a stale value behind and the next read fails closed. Override responses in the fixture carry the default allowance like the real API.
---
docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md | 5 +++--
scripts/pages-pruning/github.mjs | 10 ++++++++--
tests/unit/tooling/prunePagesDeployments.test.mjs | 10 ++++++++++
tests/unit/tooling/support/pagesPruningFixture.mjs | 3 ++-
4 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
index 034805be4..3b0207044 100644
--- a/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -143,8 +143,9 @@ for CI. The same check gates the proof scan, the POST and a DELETE that needs no
is issued when the last reported allowance cannot cover it (other consumers of the same token can
still lower it afterwards). Every read, retries and the whole inventory included, is also gated
by the live allowance before it is sent: a read that would leave fewer than 100 requests stops the
-run with `rate-reserve`, and once a response has succeeded a missing rate header stops it with
-`rate-unknown`, so the local 450 counter is never the only brake. The rate headers of failed reads
+run with `rate-reserve`, and once a response has succeeded any later successful response without
+a usable rate header stops it with `rate-unknown` (the allowance is replaced, never kept stale), so
+the local 450 counter is never the only brake. The rate headers of failed reads
and writes count too, so a run of 404 responses cannot spend the reserve unnoticed. A budget refusal before a candidate's first
write (a retry storm in its first revalidation) is the same graceful `budget-limit` stop; after the
POST it stays a failure. An inventory that alone exceeds the budget fails
diff --git a/scripts/pages-pruning/github.mjs b/scripts/pages-pruning/github.mjs
index 14dd32594..96de5061c 100644
--- a/scripts/pages-pruning/github.mjs
+++ b/scripts/pages-pruning/github.mjs
@@ -59,6 +59,12 @@ function shouldRetry(error, attempt, delay) {
].every(Boolean);
}
+// QNBS-v3: a successful response replaces the allowance, so one that omits or malforms its rate header leaves it unknown instead of stale and the next read fails closed.
+function replaceRate(ctx, headers) {
+ ctx.remaining = null;
+ noteRate(ctx, headers);
+}
+
// QNBS-v3: every read, each retry included, passes the live allowance before it is sent; once a response has succeeded the allowance must be known, so a missing rate header stops the run instead of leaving the local counter as the only brake (failed attempts before any success are bounded by READ_ATTEMPTS), and a read never takes the allowance below the CI reserve.
function spendRead(ctx) {
if (ctx.answered && ctx.remaining === null) refuse('rate-unknown');
@@ -72,7 +78,7 @@ export async function read(ctx, route, parameters = {}) {
try {
const response = await ctx.github.request(`GET ${route}`, parameters);
ctx.answered = true;
- noteRate(ctx, response.headers);
+ replaceRate(ctx, response.headers);
return response;
} catch (error) {
noteRate(ctx, error.response?.headers);
@@ -88,7 +94,7 @@ export async function write(ctx, route, parameters) {
spend(ctx);
try {
const response = await ctx.github.request(route, parameters);
- noteRate(ctx, response?.headers);
+ replaceRate(ctx, response?.headers);
return response;
} catch (error) {
noteRate(ctx, error.response?.headers);
diff --git a/tests/unit/tooling/prunePagesDeployments.test.mjs b/tests/unit/tooling/prunePagesDeployments.test.mjs
index 2123286a2..756d0f6ec 100644
--- a/tests/unit/tooling/prunePagesDeployments.test.mjs
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -841,6 +841,16 @@ test('a response without a rate header stops the run after the first request', a
assert.equal(github.calls.length, 1);
});
+test('a later response without a rate header does not leave a stale allowance behind', async () => {
+ const github = client(records(4), undefined, (calls) =>
+ calls === 1 ? {} : { 'x-ratelimit-remaining': undefined },
+ );
+ const result = await execute(github, { dryRun: true });
+ assert.equal(result.failed, 1);
+ assert.equal(result.reasons['rate-unknown'], 1);
+ assert.equal(github.calls.length, 2);
+});
+
test('a throttled response lowers the allowance before any retry can be sent', async () => {
const github = client(records(4), () => {
throw Object.assign(apiError(429, '0'), {
diff --git a/tests/unit/tooling/support/pagesPruningFixture.mjs b/tests/unit/tooling/support/pagesPruningFixture.mjs
index d2264112a..0aa518b6c 100644
--- a/tests/unit/tooling/support/pagesPruningFixture.mjs
+++ b/tests/unit/tooling/support/pagesPruningFixture.mjs
@@ -147,7 +147,8 @@ export function client(initial, intercept = () => undefined, headers = {}) {
request: async (route, parameters = {}) => {
calls.push({ route, parameters });
const override = await intercept(route, parameters, state, calls);
- if (override) return override;
+ if (override)
+ return { ...override, headers: withAllowance(override.headers ?? {}, calls.length) };
const hit = table.find(([pattern]) =>
typeof pattern === 'string' ? pattern === route : pattern.test(route),
);