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/.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..633b6d459 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).
+# 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
+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:
- deployments: write
+ contents: read
+ actions: read
+ pages: read
+ deployments: read
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 e74559caf..af4b8731d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
name one exact deployment ID revalidated against live data. AGENTS.md and the
deployment, merge and Cursor Cloud runbooks point to it. PR #1031.
+- **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. 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.
+
- **Security:** update quinn-proto to the OSV-fixed release 0.11.18 (RUSTSEC-2026-0338 to -0342). The crate is a transitive dependency through `tauri-plugin-http`, `reqwest` 0.12 and `quinn`; the lockfile-only update changes no manifest, feature, or toolchain pin and unblocks the required OSV scan. PR #1030.
- **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/README.md b/README.md
index 635e65592..e9c6d801e 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 (8547+ tests / 637 files) + Playwright | Unit/integration/E2E |
+| Testing | Vitest 5.x (8548+ tests / 638 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 8547+ total also includes components/ and packages/*/tests; tests/e2e is excluded)
+│ ├── unit/ # Vitest unit tests (the 8548+ 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:
-- **8547+ unit tests** across **637 test files**
+- **8548+ unit tests** across **638 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..3b0207044
--- /dev/null
+++ b/docs/PAGES-DEPLOYMENT-RECORD-PRUNING.md
@@ -0,0 +1,206 @@
+# 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.
+
+**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
+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
+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, 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
+ `🚀 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]`.
+
+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.
+
+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
+
+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. 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
+
+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 (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. 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
+(`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. 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.
+
+### 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 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 (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. 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 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). 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 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
+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 | – | 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.
+
+## 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. 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.
diff --git a/scripts/pages-pruning/evidence.mjs b/scripts/pages-pruning/evidence.mjs
new file mode 100644
index 000000000..5218cc280
--- /dev/null
+++ b/scripts/pages-pruning/evidence.mjs
@@ -0,0 +1,59 @@
+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,
+ status.creator?.id ?? 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.creator?.id ?? 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..96de5061c
--- /dev/null
+++ b/scripts/pages-pruning/github.mjs
@@ -0,0 +1,185 @@
+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 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));
+
+export function createContext({ github, authority, pause = defaultPause, limit = REQUEST_LIMIT }) {
+ return {
+ github,
+ authority,
+ pause,
+ limit,
+ used: 0,
+ answered: false,
+ remaining: null,
+ repositoryId: null,
+ proven: new Map(),
+ observed: { list: 1, status: 1 },
+ };
+}
+
+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 < READ_ATTEMPTS - 1,
+ RETRYABLE.has(error.status),
+ Number.isFinite(delay),
+ delay >= 0,
+ delay <= BACKOFF_CAP_MS,
+ ].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');
+ 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++) {
+ spendRead(ctx);
+ try {
+ const response = await ctx.github.request(`GET ${route}`, parameters);
+ ctx.answered = true;
+ replaceRate(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);
+ }
+ }
+}
+
+// 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);
+ try {
+ const response = await ctx.github.request(route, parameters);
+ replaceRate(ctx, response?.headers);
+ return response;
+ } catch (error) {
+ noteRate(ctx, error.response?.headers);
+ throw error;
+ }
+}
+
+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;
+
+// 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) {
+ 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);
+ }
+ 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`, 20, 'list');
+
+export function loadStatuses(ctx, record) {
+ if (!validId(record.id)) refuse('invalid-inventory');
+ return collect(ctx, `${ROOT}/deployments/${record.id}/statuses`, 5, 'status');
+}
+
+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..e815829d9
--- /dev/null
+++ b/scripts/pages-pruning/policy.mjs
@@ -0,0 +1,198 @@
+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;
+
+// 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);
+
+// 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..cabcec356
--- /dev/null
+++ b/scripts/pages-pruning/provenance.mjs
@@ -0,0 +1,157 @@
+import { newestSuccess, proofKey, refuse } from './evidence.mjs';
+import { funds, READ_ATTEMPTS, read } from './github.mjs';
+import {
+ ACTIONS_BOT,
+ actorKey,
+ creatorLogin,
+ isPagesStatus,
+ jobIdentity,
+ MAX_BATCH,
+ owned,
+ publicationOrder,
+ REPOSITORY,
+ ROOT,
+} from './policy.mjs';
+
+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';
+
+// 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',
+ CI_PATH.test(run.path ?? ''),
+ PUBLISH_EVENTS.has(run.event),
+ 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.head_sha === run.head_sha,
+ 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, 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([actorKey(run.actor), actorKey(run.triggering_actor)]);
+ actors.delete(null);
+ return [
+ actors.size > 0,
+ actors.has(actorKey(record.creator)),
+ actors.has(actorKey(success.creator)),
+ creatorLogin(record.creator) !== ACTIONS_BOT,
+ creatorLogin(success.creator) !== ACTIONS_BOT,
+ ];
+}
+
+// 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}`);
+ 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 = [
+ ...shapeFacts(record, success),
+ ...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);
+}
+
+// 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);
+ 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');
+}
+
+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) {
+ const proven = [];
+ 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(examined),
+ };
+}
diff --git a/scripts/pages-pruning/reconcile.mjs b/scripts/pages-pruning/reconcile.mjs
new file mode 100644
index 000000000..117daf0e4
--- /dev/null
+++ b/scripts/pages-pruning/reconcile.mjs
@@ -0,0 +1,330 @@
+import {
+ anyOf,
+ fingerprint,
+ latestStatus,
+ listKey,
+ newestSuccess,
+ refuse,
+ timestamp,
+ validId,
+} from './evidence.mjs';
+import {
+ createContext,
+ funds,
+ inventory,
+ listRecords,
+ loadStatuses,
+ READ_ATTEMPTS,
+ read,
+ write,
+} from './github.mjs';
+import {
+ actorKey,
+ admission,
+ DESTRUCTIVE_WRITES_ADMITTED,
+ isPagesStatus,
+ planPruning,
+ publicationOrder,
+ ROOT,
+ SITE,
+} from './policy.mjs';
+import { examineCandidates, proveSlots, reprove } from './provenance.mjs';
+
+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.
+const FOCUSED_RELOADS = 3;
+export const EXTRA_RELOADS = 3;
+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) {
+ const { list, status } = ctx.observed;
+ 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 + REPROOF + 3;
+ return READ_ATTEMPTS * reads + 1;
+}
+
+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';
+}
+
+// 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, why = 'unexamined-batch-limit') {
+ const { plan, proven, unexamined } = prepared;
+ const { result } = run;
+ result.classified = records.length;
+ result.protected = plan.protectedRecords.length;
+ result.candidate = proven.length;
+ result.current = plan.current;
+ result.rollback = plan.rollback;
+ result.manifest = [
+ ...plan.protectedRecords.map((entry) => ({ ...entry, disposition: 'protected' })),
+ ...unexamined.map((entry) => ({
+ id: entry.id,
+ disposition: 'unexamined',
+ reasons: [why],
+ })),
+ ...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, 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) {
+ const none = { plan: first, slots: null, batch: [], proven: [], unexamined: [] };
+ if (first.candidates.length === 0) return none;
+ const slots = await proveSlots(ctx, records);
+ const examined = await examineCandidates(
+ ctx,
+ planPruning(records, holds, { slots }).candidates,
+ slots,
+ candidateCeiling(ctx),
+ );
+ 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;
+}
+
+// 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 [index, item] of items.entries())
+ fresh.push({ ...item, statuses: kept[index] ?? (await loadStatuses(session.ctx, item)) });
+ return fresh;
+}
+
+function assertStable(session, fresh, next) {
+ const order = publicationOrder(fresh)
+ .slice(0, session.slots.order.length)
+ .map((entry) => entry.record.id);
+ if (next.blocked || order.join() !== session.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(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 [slots.current, slots.rollback])
+ await reprove(ctx, fresh.find((item) => item.id === id) ?? refuse('current-moved'));
+ await reprove(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: newestSuccess(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: 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: 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;
+ const fresh = await revalidate(session, candidate, fingerprint(candidate));
+ if (!fresh) return skip(run, 'stale-candidate');
+ const needsPost = !isSettledInactive(latestStatus(fresh));
+ if (!funds(ctx, needsPost ? finishCeiling(ctx) : DELETE_COST)) return 'budget';
+ run.result.attempted += 1;
+ if (needsPost) {
+ 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);
+}
+
+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) {
+ const spent = ['request-budget', 'rate-reserve'].includes(error.reason);
+ if (spent && run.result.attempted === attempted) return 'budget';
+ throw error;
+ }
+}
+
+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;
+ if ((await retireWithinBudget(session, candidate)) === 'budget') {
+ run.considered -= 1;
+ run.reason('budget-limit', run.batch.length - run.considered);
+ return;
+ }
+ }
+}
+
+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) {
+ reportUnexamined(run, records, first);
+ refuse('unproved-current-or-unsettled');
+ }
+ 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);
+ await mutate({ ctx, run, holds, records, slots: prepared.slots });
+ run.result.followUpRequired = records.length - run.result.deleted > 3;
+ const backlog = prepared.proven.length - prepared.batch.length;
+ if (backlog > 0) run.reason('batch-limit', backlog);
+}
+
+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
new file mode 100644
index 000000000..ac6afbaf4
--- /dev/null
+++ b/scripts/prune-pages-deployments.mjs
@@ -0,0 +1,107 @@
+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 {
+ admission,
+ DESTRUCTIVE_WRITES_ADMITTED,
+ planPruning,
+ REPOSITORY,
+ validateHolds,
+} from './pages-pruning/policy.mjs';
+export { reconcilePages };
+
+const BACKLOG_REASONS = ['batch-limit', 'budget-limit', 'unexamined'];
+
+export async function runAction({
+ github,
+ context,
+ core,
+ holds,
+ workflowRef,
+ reconcile = reconcilePages,
+}) {
+ const result = await reconcile({
+ 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 (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.
+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)}`);
+ 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) };
+ },
+ };
+}
+
+// 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', TOKEN_ARGS, {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ const holdsUrl = new URL('../.github/pages-pruning-holds.json', import.meta.url);
+ const result = await reconcilePages({
+ github: readOnlyClient(token),
+ holds: JSON.parse(readFileSync(holdsUrl, '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..756d0f6ec
--- /dev/null
+++ b/tests/unit/tooling/prunePagesDeployments.test.mjs
@@ -0,0 +1,1323 @@
+import assert from 'node:assert/strict';
+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 { EXAMINE_LIMIT, PUBLISH_EVENTS } from '../../../scripts/pages-pruning/provenance.mjs';
+import {
+ candidateCeiling,
+ EXTRA_RELOADS,
+ finishCeiling,
+} from '../../../scripts/pages-pruning/reconcile.mjs';
+import {
+ admission,
+ DESTRUCTIVE_WRITES_ADMITTED,
+ planPruning,
+ runAction,
+ TOKEN_ARGS,
+ validateHolds,
+} from '../../../scripts/prune-pages-deployments.mjs';
+import {
+ apiError,
+ authority,
+ client,
+ date,
+ execute,
+ executeDestructive,
+ holds,
+ job as jobFixture,
+ jobUrl,
+ linkHeader,
+ PRINCIPAL,
+ publication,
+ REPO_ID,
+ ROOT,
+ reads,
+ record,
+ records,
+ status,
+ writes,
+} from './support/pagesPruningFixture.mjs';
+
+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);
+const nonSuccess = (item, state = 'failure') => {
+ item.statuses = [status(item.id, { state })];
+ return item;
+};
+// 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`, () => {
+ 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: { id: 15368, slug: 'other' } },
+ { performed_via_github_app: { id: 1, slug: 'vercel' } },
+ { creator: null },
+ { creator: { login: '' } },
+ { creator: BOT },
+ { 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('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, { complete: 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 bad of [null, [], 'holds', { rollback: [], evidence: [], extra: [] }])
+ assert.throws(() => validateHolds(bad), /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);
+ });
+
+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));
+ 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' },
+ { eventName: 'constructor' },
+ { eventName: 'toString' },
+])
+ 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 executeDestructive(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('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 executeDestructive(github);
+ assert.equal(first.failed, 0);
+ assert.equal(first.deleted, 2);
+ assert.equal(first.inactivated, 2);
+ const second = await executeDestructive(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 executeDestructive(github);
+ assert.equal(result.deleted, 1);
+ 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) => {
+ if (route.startsWith('POST ')) throw apiError(code, '1');
+ });
+ const result = await executeDestructive(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 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(code);
+ });
+ 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 executeDestructive(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 executeDestructive(github)).deleted, 1);
+ const limited = client(records(4), () => {
+ throw apiError(429, '60');
+ });
+ assert.equal((await executeDestructive(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 executeDestructive(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: 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: structuredClone(first ? state.slice(0, 2) : state.slice(2)),
+ headers: first ? headers : {},
+ };
+ });
+
+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), []);
+});
+
+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: 'schedule' },
+ { event: 'repository_dispatch' },
+ { event: 'release' },
+ { head_branch: 'v1.30' },
+ { head_repository: { full_name: 'fork/repo' } },
+ { 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 } },
+ { 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/')
+ ? {
+ data: { ...publication(Number(route.split('/').at(-1)) - 1000), ...change },
+ headers: {},
+ }
+ : undefined,
+ );
+ 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 },
+ { head_sha: 'f'.repeat(40) },
+ { head_sha: undefined },
+])
+ 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 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;
+ },
+ '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('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);
+ });
+
+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 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), []);
+});
+
+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`],
+ );
+});
+
+// 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 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'), {
+ 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);
+ });
+ 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 = CURSOR;
+ input[0].statuses[0].creator = CURSOR;
+ const github = client(input, (route) =>
+ route.endsWith('/actions/runs/1001')
+ ? {
+ data: publication(1, {
+ actor: CURSOR,
+ triggering_actor: CURSOR,
+ }),
+ 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 executeDestructive(github)).reasons['main-moved'], 1);
+ assert.deepEqual(writes(github), []);
+});
+
+test('candidate SHA drift is skipped with zero writes', async () => {
+ let lists = 0;
+ const github = client(records(4), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++lists === 2) state[0].sha = 'b'.repeat(40);
+ });
+ const result = await executeDestructive(github);
+ assert.equal(result.skipped, 1);
+ assert.deepEqual(writes(github), []);
+});
+
+test('status promotion after inactivation prevents deletion', async () => {
+ let lists = 0;
+ const github = client(records(4), (route, _parameters, state) => {
+ if (route.includes('?environment=') && ++lists === 3)
+ state[0].statuses.push({ ...state[0].statuses[0], id: 99999, created_at: date(99999) });
+ });
+ 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), []);
+});
+
+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' })] })],
+])
+ 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 executeDestructive(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 executeDestructive(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);
+});
+
+const statusReads = (github) => reads(github, /\/statuses$/).length;
+for (const [count, bound] of [
+ [6, 100],
+ [50, 370],
+ [100, 420],
+])
+ 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.ok(result.reasons['budget-limit'] > 0);
+ assert.ok(result.reasons.unexamined > 0);
+ assert.equal(result.followUpRequired, true);
+ assert.deepEqual(writes(github), []);
+});
+
+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('the per-candidate cost ceiling grows with the observed inventory depth', () => {
+ assert.equal(candidateCeiling({ observed: { list: 1, status: 1 } }), 42);
+ const deep = candidateCeiling({ observed: { list: 4, status: 5 } });
+ assert.equal(deep, 96);
+ 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, 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, 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 } }),
+ });
+ 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 } }), 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 () => {
+ const dry = await execute(client(records(8)), { dryRun: true });
+ // 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 });
+ 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;
+ 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('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);
+ 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);
+ assert.equal(result.failed, 0);
+ assert.equal(result.deleted, 0);
+ assert.ok((result.reasons['budget-limit'] ?? 0) + (result.reasons.unexamined ?? 0) > 0);
+ assert.deepEqual(writes(github), []);
+});
+
+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 github = client(records(4), (route) => {
+ if (route.includes('/actions/runs/')) throw apiError(503);
+ });
+ await runAction({
+ github,
+ holds,
+ workflowRef: authority.workflowRef,
+ context: actionContext,
+ core: {
+ summary: summaryStub(blocks),
+ info: (value) => logs.push(value),
+ setFailed: (value) => failures.push(value),
+ },
+ });
+ assert.equal(blocks[0].failed, 1);
+ 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('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('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),
+ );
+ 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/);
+ }
+});
+
+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), []);
+});
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');
+});
diff --git a/tests/unit/tooling/support/pagesPruningFixture.mjs b/tests/unit/tooling/support/pagesPruningFixture.mjs
new file mode 100644
index 000000000..0aa518b6c
--- /dev/null
+++ b/tests/unit/tooling/support/pagesPruningFixture.mjs
@@ -0,0 +1,177 @@
+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';
+// 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,
+ 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: PRINCIPAL,
+ 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: PRINCIPAL,
+ 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: PRINCIPAL,
+ triggering_actor: PRINCIPAL,
+ ...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',
+ 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()), creator: WORKFLOW_BOT };
+ 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]))],
+ ];
+}
+
+// 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 = [];
+ 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, headers: withAllowance(override.headers ?? {}, calls.length) };
+ 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: withAllowance(headers, calls.length),
+ };
+ },
+ };
+}
+
+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"`,
+});