diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..259b9f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a GitHub Actions workflow-registry audit plus an explicit dry-run-first, + exact-protected-SHA operator cleanup command. The audit binds complete + paginated registry evidence to one unchanged protected-branch tree; cleanup + reports all `active_orphan` candidates but can disable only exact numeric IDs + separately selected with `--workflow-id` after ownership review, requires + `--apply`, an immutable expected commit SHA, and authenticated GitHub Actions + write authority, and verifies each disabled identity without name heuristics. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON diff --git a/docs/doctoring/workflow-registry-audit.md b/docs/doctoring/workflow-registry-audit.md new file mode 100644 index 00000000..7052eeac --- /dev/null +++ b/docs/doctoring/workflow-registry-audit.md @@ -0,0 +1,128 @@ +# GitHub Actions workflow registry audit and cleanup + +## Decision + +ScopeWeave treats the GitHub Actions registry and the protected branch tree as separate control-plane authorities. Deleting workflow YAML does not prove that GitHub disabled the workflow identity. Conversely, an active registry identity absent from the protected tree is **not by itself sufficient disable authority**: GitHub can register a workflow while its YAML exists only on a non-default branch or another still-owned review lane. + +The control therefore has two deliberately separate stages: + +1. `scripts/ci/workflow_registry_audit.mjs` is a read-only detector that reconciles the complete Actions registry against one immutable protected-branch tree. +2. `scripts/ci/workflow_registry_cleanup.mjs` is an explicit operator control. It is dry-run by default, reports every `active_orphan` as a candidate, and can disable only the exact numeric candidate IDs separately named by the operator with `--workflow-id` after ownership review. + +The cleanup command is not an autonomous scanner mutation path. Applying changes requires `--apply`, an exact 40-character `--expected-sha`, at least one explicitly reviewed `--workflow-id`, and an authenticated `GITHUB_TOKEN` with GitHub Actions write authority. Display names, filename keywords, and `active_orphan` classification alone never authorize a mutation. + +Both stages use GitHub REST API version `2026-03-10`. The detector issues only `GET`; the cleanup path uses GitHub's dedicated workflow-disable endpoint only after fail-closed audit, explicit target selection, and live identity checks. Neither path changes repository YAML, product source, branch protection, secrets, tenant data, or ScopeWeave runtime state. + +## Evidence model + +Each audit result retains repository, branch, exact protected SHA, observation time, registry `total_count`, per-page URL/status/item-count receipts, exact protected workflow paths, and exact workflow ID/path/state/classification. Reused paths are never collapsed, but a repeated numeric workflow ID fails closed because it would make the registry observation ambiguous. + +GitHub's documented `WorkflowState` values are recognized exactly: `active`, `deleted`, `disabled_fork`, `disabled_inactivity`, and `disabled_manually`. An unfamiliar future state is retained as `unresolved` regardless of whether its path exists in the protected tree. `unresolved` is evidence that the API contract changed or the observation needs operator investigation; it is never an inactive verdict and blocks cleanup application. + +Classifications are deliberately narrow: + +- `present_active`: repository-path identity exists in the protected tree and GitHub reports `active`; +- `present_inactive`: protected YAML exists but GitHub reports one of the documented inactive states; +- `active_orphan`: documented `active` repository-path identity is absent from the protected tree and must be ownership-reviewed before it can be selected for disable; +- `inactive_absent`: absent repository-path identity is already in a documented inactive state; +- `preserved_absent`: documented active absent identity explicitly exempted by the operator, for example while an active reviewed PR, non-default writer branch, or central governance contract owns its source; +- `github_dynamic`: GitHub-owned `dynamic/*` identity with a documented state, outside repository YAML lifecycle authority; +- `unresolved`: unknown workflow state; preserve exact ID/path/state, investigate the API/ownership change, and do not disable from this record. + +Names such as `one-shot`, `repair`, or `bootstrap` never cause an orphan verdict. A legitimate one-shot-like workflow present in the protected tree is `present_active`; a branch-only workflow absent from the protected tree still needs explicit ownership review before any disable target is selected. + +## Protected-tree absence proof + +The normal path inventory uses the Contents API at the exact protected commit. A Contents `404` is ambiguous because GitHub can also conceal inaccessible resources as not found, so it is **not** treated as an empty workflow directory by itself. + +If that exact Contents request returns 404, the detector resolves the same commit through the Git Commits API to its root tree SHA, reads the root tree, then reads the exact `.github` tree when present. It returns an empty protected-workflow list only after those immutable Git Data reads succeed and prove that `.github` or its `workflows` tree entry is genuinely absent. If the `workflows` tree exists, its non-recursive blob entries are converted back to canonical immediate `.github/workflows/*.yml|*.yaml` paths. A failed or ambiguous Git Data read, duplicate tree path, non-tree directory entry, truncated tree response, or non-canonical workflow blob fails closed. + +## Read-only audit operation + +Every known absent-but-supported repository workflow should be supplied as an explicit exception for that observation. A conservative audit can therefore preserve a reviewed not-yet-protected workflow or another explicitly owned exception without teaching the detector a name heuristic: + +```bash +node scripts/ci/workflow_registry_audit.mjs \ + --repo ContextualWisdomLab/scopeweave \ + --branch develop \ + --preserve-path .github/workflows/hourly-opencode-commercial-readiness.yml \ + --preserve-path .github/workflows/opencode-review.yml \ + --preserve-path .github/workflows/scorecard.yml +``` + +Preserve paths must be canonical immediate `.github/workflows/*.yml` or `*.yaml` file paths. Parent traversal, nested paths, alternate separators, and directory-only values are rejected so an exception cannot ambiguously cover another control-plane location. + +`GITHUB_TOKEN` is optional for public audit reads and is sent only in the Authorization header. Tokens and failed response bodies are never printed. Authorization failure, ambiguous missing resources, malformed JSON, incomplete pagination, duplicated workflow IDs, an off-origin pagination link, or protected-branch movement all fail closed without a partial success payload. A future workflow state is retained only as `unresolved`, never coerced into a disable-capable classification. Transient 500, 502, 503, and 504 responses use bounded 100 ms then 200 ms timer-backed retries before failing closed. + +## Authorized cleanup operation + +Use the cleanup command first without `--apply`. A dry run performs the same fresh audit and emits all exact `active_orphan` candidates, but its mutation `targets` list remains empty unless the operator also names reviewed candidate IDs: + +```bash +node scripts/ci/workflow_registry_cleanup.mjs \ + --repo ContextualWisdomLab/scopeweave \ + --branch develop \ + --expected-sha \ + --preserve-path .github/workflows/hourly-opencode-commercial-readiness.yml +``` + +After reviewing current PRs, non-default branches/active writers, dependency ownership, and the candidate's exact ID/path/state, the operator can preview a specific reviewed subset by adding one or more `--workflow-id` arguments while still omitting `--apply`. + +An apply operation is intentionally harder to invoke and never means “disable every orphan”: + +```bash +GITHUB_TOKEN= \ +node scripts/ci/workflow_registry_cleanup.mjs \ + --repo ContextualWisdomLab/scopeweave \ + --branch develop \ + --expected-sha \ + --preserve-path .github/workflows/hourly-opencode-commercial-readiness.yml \ + --workflow-id \ + --apply +``` + +The operator contract is fail-closed: + +- apply mode rejects missing authentication before audit or mutation network traffic; +- the supplied expected SHA must equal the audit's protected SHA; +- apply mode requires one or more positive safe-integer `--workflow-id` values and rejects duplicates; +- any `unresolved` identity blocks mutation; +- the dry-run candidate list contains only exact numeric IDs classified `active_orphan` with canonical repository workflow paths; +- every reviewed `--workflow-id` must still be present in that current candidate list or the operation fails; unselected candidates are never mutated; +- the whole reviewed target plan is preflighted before the first mutation so obvious path/ID/state drift cannot produce a partial cleanup; +- protected `develop` is re-read before mutation, before each target, and after the operation; +- every target's numeric ID, exact case-sensitive path, and state are re-read immediately before mutation; +- a concurrently completed `disabled_manually` transition is adopted rather than raced; +- only an explicitly selected, exact, still-`active` target receives `PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable`; +- the resulting state must be observed as `disabled_manually`; and +- a fresh post-operation audit on the same protected SHA must prove no selected workflow ID remains `active_orphan`. + +Transient 500, 502, 503, and 504 responses on the disable request use bounded retry. Permission/authentication, identity, branch-movement, unexpected-state, malformed-evidence, and postcondition failures do not retry as if they were transport noise. GitHub response bodies are never surfaced in errors. + +This control changes Actions registry state only. It does not restore or delete workflow YAML, create a temporary cleanup workflow, add a PAT to repository source, broaden `secrets: inherit`, change branch rules, or infer safety from display names. The operator must preserve or simply decline to select any absent identity whose source is still legitimately owned by a current PR, non-default writer branch, or external governance path. + +## Verification contract + +Audit regression coverage includes complete multi-page enumeration; truncation detection; duplicate workflow-ID rejection; real timer-backed bounded 5xx retry with an injectable test seam; fail-closed 403/404 handling; immutable Git-tree proof of a genuinely absent workflow directory; ambiguous 404 preservation; malformed JSON; branch movement; off-origin pagination; exact case-sensitive paths; path reuse without ID collapse; documented inactive workflow states; unknown present/absent/dynamic states retained as `unresolved`; GitHub dynamic workflows; explicit active-PR preservation; canonical preserve-path validation; and a legitimate present one-shot-like workflow. + +Cleanup regression coverage separately proves dry-run candidate reporting with no implicit targets; immutable expected-SHA binding; unresolved-state refusal; explicit reviewed active-orphan selection; rejection of present/unknown/duplicate reviewed IDs; apply-time SHA and target requirements; authentication refusal before high-level audit traffic; whole-plan/pre-target revalidation; path identity drift and protected-branch movement blocking mutation; exact numeric-ID disable routing; `disabled_manually` verification; bounded transient 5xx retry; and fail-closed permission errors without response-body leakage. + +Both modules are repository control-plane production code rather than shipped ScopeWeave application runtime. Both are included in the canonical `c8` owned-production coverage producer, and their focused test suites execute in normal unit and coverage cases so the operator surface cannot silently fall out of quality evidence. + +## Recovery and rollback + +Rolling back repository code removes the detector/operator commands, tests, coverage registrations, doctoring, and changelog entries together. It **does not re-enable workflow identities already disabled in GitHub**, because Actions registry state is an independent control-plane fact. Re-enabling an intentionally disabled workflow therefore requires a separate, freshly reviewed operator decision against the current protected tree and GitHub registry; source rollback must never silently manufacture that decision. + +If an apply operation stops after disabling only part of a reviewed target plan, retain the before/after evidence, rerun the complete audit on the current protected SHA, review current owners again, and classify the remaining exact identities again. Do not blindly replay the stale target list. Already `disabled_manually` entries are safe to adopt; any changed path, state, ownership exception, unresolved classification, or protected-branch movement requires a new decision and a newly explicit `--workflow-id` selection. + +## References + +GitHub. (2026a). *REST API endpoints for workflows*. GitHub Docs. Retrieved August 17, 2026, from https://docs.github.com/en/rest/actions/workflows?apiVersion=2026-03-10 + +GitHub. (2026b). *Using pagination in the REST API*. GitHub Docs. Retrieved August 17, 2026, from https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2026-03-10 + +GitHub. (2026c). *Actions: WorkflowState*. GitHub Docs. Retrieved August 17, 2026, from https://docs.github.com/en/graphql/reference/actions#workflowstate + +GitHub. (2026d). *Git database: Commits and trees*. GitHub Docs. Retrieved August 17, 2026, from https://docs.github.com/en/rest/git/commits?apiVersion=2026-03-10 and https://docs.github.com/en/rest/git/trees?apiVersion=2026-03-10 + +GitHub. (2026e). *Disabling and enabling a workflow*. GitHub Docs. Retrieved August 17, 2026, from https://docs.github.com/en/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows diff --git a/package.json b/package.json index 8cefdc74..76dd6d6a 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,15 @@ "node": "^22.13.0 || >=23.4.0" }, "scripts": { + "audit:workflow-registry": "node scripts/ci/workflow_registry_audit.mjs", + "cleanup:workflow-registry": "node scripts/ci/workflow_registry_cleanup.mjs", "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/workflow-registry-audit.test.mjs && node tests/unit/workflow-registry-request-timeout.test.mjs && node tests/unit/workflow-registry-cleanup.test.mjs && node tests/unit/workflow-registry-cleanup-postcondition.test.mjs && node tests/unit/workflow-registry-coverage.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=scripts/ci/workflow_registry_audit.mjs --include=scripts/ci/workflow_registry_cleanup.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/workflow-registry-audit.test.mjs && node tests/unit/workflow-registry-request-timeout.test.mjs && node tests/unit/workflow-registry-cleanup.test.mjs && node tests/unit/workflow-registry-cleanup-postcondition.test.mjs && node tests/unit/workflow-registry-coverage.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/scripts/ci/workflow_registry_audit.mjs b/scripts/ci/workflow_registry_audit.mjs new file mode 100644 index 00000000..0267029a --- /dev/null +++ b/scripts/ci/workflow_registry_audit.mjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node +/** + * Read-only audit of GitHub Actions workflow registry identities against an exact + * protected-branch tree. The detector performs GET requests only and emits + * evidence for a separately authorized disable operation. + * @module workflow_registry_audit + */ +import { pathToFileURL } from 'node:url'; + +export const GITHUB_API_VERSION = '2026-03-10'; +export const WORKFLOW_DIRECTORY = '.github/workflows'; +const TRANSIENT_STATUS = new Set([500, 502, 503, 504]); +const GITHUB_READ_REQUEST_TIMEOUT_MS = 10_000; +const KNOWN_WORKFLOW_STATES = new Set([ + 'active', + 'deleted', + 'disabled_fork', + 'disabled_inactivity', + 'disabled_manually', +]); +const CANONICAL_WORKFLOW_PATH = /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/; + +/** GitHub API failure that retains only the numeric status, never response text. */ +export class GitHubApiError extends Error { + /** + * @param {number|undefined} status - HTTP status if one was available. + */ + constructor(status) { + super(`GitHub API request failed with status ${Number.isFinite(status) ? status : 'unknown'}`); + this.name = 'GitHubApiError'; + this.status = Number.isFinite(status) ? status : undefined; + } +} + +/** Validate and return an exact `owner/repository` identifier. */ +export function validateRepository(value) { + const repository = String(value || ''); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new TypeError('repository must use exact owner/name form'); + } + return repository; +} + +/** Parse the GitHub pagination subset of an RFC 8288 Link header. */ +export function parseLinkHeader(value) { + const links = new Map(); + for (const segment of String(value || '').split(',')) { + const match = segment.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"$/); + if (match) links.set(match[2], match[1]); + } + return links; +} + +/** + * Delay a bounded retry using the real runtime timer by default. + * + * The timer implementation is injectable only so unit tests can prove the + * delay contract without sleeping. Production callers use `globalThis.setTimeout`. + */ +export function sleepMilliseconds(delayMs, setTimeoutImpl = globalThis.setTimeout) { + if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > 60_000) { + return Promise.reject(new RangeError('retry delay must be an integer from 0 through 60000 milliseconds')); + } + if (typeof setTimeoutImpl !== 'function') { + return Promise.reject(new TypeError('setTimeoutImpl must be a function')); + } + return new Promise((resolve) => setTimeoutImpl(resolve, delayMs)); +} + +/** Perform a bounded JSON GET with retry only for transient 5xx responses. */ +export async function requestJson({ fetchImpl, url, token = '', sleepImpl = sleepMilliseconds, maxAttempts = 3 }) { + if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); + if (typeof sleepImpl !== 'function') throw new TypeError('sleepImpl must be a function'); + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) { + throw new RangeError('maxAttempts must be an integer from 1 through 5'); + } + const headers = { accept: 'application/vnd.github+json', 'x-github-api-version': GITHUB_API_VERSION }; + if (token) headers.authorization = `Bearer ${token}`; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const response = await fetchImpl(url, { + method: 'GET', + headers, + redirect: 'error', + signal: AbortSignal.timeout(GITHUB_READ_REQUEST_TIMEOUT_MS), + }); + const status = Number(response?.status); + if (response?.ok) { + let data; + try { data = await response.json(); } catch { throw new Error(`GitHub API returned invalid JSON (status ${status})`); } + return { data, linkHeader: response.headers?.get?.('link') ?? null, status }; + } + if (!TRANSIENT_STATUS.has(status) || attempt === maxAttempts) { + throw new GitHubApiError(status); + } + await sleepImpl(100 * attempt); + } + throw new Error('unreachable GitHub API retry state'); +} + +/** Fetch and validate one protected branch commit identity. */ +export async function fetchBranchSha({ fetchImpl, apiBase, repository, branch, token = '', sleepImpl }) { + const url = `${apiBase}/repos/${repository}/branches/${encodeURIComponent(branch)}`; + const { data } = await requestJson({ fetchImpl, url, token, sleepImpl }); + const sha = data?.commit?.sha; + if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/i.test(sha)) { + throw new Error('GitHub branch response is missing a valid commit SHA'); + } + return sha.toLowerCase(); +} + +/** Enumerate every workflow identity and prove pagination completeness. */ +export async function listAllWorkflows({ fetchImpl, apiBase, repository, token = '', sleepImpl }) { + const expectedOrigin = new URL(apiBase).origin; + const endpointPath = `/repos/${repository}/actions/workflows`; + let nextUrl = `${apiBase}${endpointPath}?per_page=100`; + const workflows = []; + const receipts = []; + let totalCount = null; + let page = 0; + while (nextUrl) { + page += 1; + if (page > 100) throw new Error('workflow pagination exceeded the 100-page safety bound'); + const parsed = new URL(nextUrl); + if (parsed.origin !== expectedOrigin || parsed.pathname !== endpointPath) { + throw new Error('workflow pagination attempted to leave the trusted GitHub endpoint'); + } + const { data, linkHeader, status } = await requestJson({ fetchImpl, url: parsed.href, token, sleepImpl }); + if (!data || !Array.isArray(data.workflows) || !Number.isSafeInteger(data.total_count)) { + throw new Error('GitHub workflow response has an invalid shape'); + } + if (totalCount === null) totalCount = data.total_count; + if (totalCount !== data.total_count) throw new Error('GitHub workflow total_count changed during pagination'); + workflows.push(...data.workflows); + receipts.push({ page, status, item_count: data.workflows.length, url: parsed.href }); + nextUrl = parseLinkHeader(linkHeader).get('next') || ''; + } + if (workflows.length !== totalCount) { + throw new Error(`workflow pagination incomplete: observed ${workflows.length} of ${totalCount}`); + } + const workflowIds = new Set(); + for (const item of workflows) { + if (!Number.isSafeInteger(item?.id)) throw new Error('workflow registry contains an invalid workflow id'); + if (workflowIds.has(item.id)) throw new Error(`workflow registry contains duplicate workflow id ${item.id}`); + workflowIds.add(item.id); + } + return { workflows, receipts, totalCount }; +} + +/** + * Read and validate one non-recursive Git tree. + * + * @param {object} options - GitHub request dependencies and tree identity. + * @returns {Promise>} Exact non-recursive tree entries. + */ +async function readGitTree({ fetchImpl, apiBase, repository, treeSha, token = '', sleepImpl }) { + const url = `${apiBase}/repos/${repository}/git/trees/${treeSha}`; + const { data } = await requestJson({ fetchImpl, url, token, sleepImpl }); + if (!data || !Array.isArray(data.tree) || data.truncated === true) { + throw new Error('GitHub tree response is incomplete or invalid'); + } + return data.tree; +} + +/** Resolve a commit SHA to its exact root tree SHA. */ +async function resolveCommitTreeSha({ fetchImpl, apiBase, repository, commitSha, token = '', sleepImpl }) { + const url = `${apiBase}/repos/${repository}/git/commits/${commitSha}`; + const { data } = await requestJson({ fetchImpl, url, token, sleepImpl }); + const treeSha = data?.tree?.sha; + if (typeof treeSha !== 'string' || !/^[0-9a-f]{40}$/i.test(treeSha)) { + throw new Error('GitHub commit response is missing a valid tree SHA'); + } + return treeSha.toLowerCase(); +} + +/** Select at most one exact path entry from a Git tree. */ +function exactTreeEntry(entries, path) { + const matches = entries.filter((entry) => entry?.path === path); + if (matches.length > 1) throw new Error(`GitHub tree contains duplicate path ${path}`); + return matches[0] || null; +} + +/** + * Prove a genuinely absent workflow directory through immutable Git tree reads. + * + * This fallback is used only after the Contents API returns 404. Returning an + * empty list requires successful commit/root/.github tree lookups that prove the + * workflow directory entry does not exist. Any ambiguous Git Data failure stays + * fail-closed. + */ +async function listProtectedWorkflowPathsFromTree({ fetchImpl, apiBase, repository, sha, token = '', sleepImpl }) { + const rootTreeSha = await resolveCommitTreeSha({ fetchImpl, apiBase, repository, commitSha: sha, token, sleepImpl }); + const rootEntries = await readGitTree({ fetchImpl, apiBase, repository, treeSha: rootTreeSha, token, sleepImpl }); + const githubEntry = exactTreeEntry(rootEntries, '.github'); + if (!githubEntry) return []; + if (githubEntry.type !== 'tree' || typeof githubEntry.sha !== 'string') { + throw new Error('GitHub .github entry is not a tree'); + } + + const githubEntries = await readGitTree({ fetchImpl, apiBase, repository, treeSha: githubEntry.sha, token, sleepImpl }); + const workflowsEntry = exactTreeEntry(githubEntries, 'workflows'); + if (!workflowsEntry) return []; + if (workflowsEntry.type !== 'tree' || typeof workflowsEntry.sha !== 'string') { + throw new Error('GitHub workflows entry is not a tree'); + } + + const workflowEntries = await readGitTree({ fetchImpl, apiBase, repository, treeSha: workflowsEntry.sha, token, sleepImpl }); + const paths = workflowEntries + .filter((entry) => entry?.type === 'blob' && typeof entry.path === 'string') + .map((entry) => `${WORKFLOW_DIRECTORY}/${entry.path}`); + if (paths.some((path) => !CANONICAL_WORKFLOW_PATH.test(path))) { + throw new Error('GitHub workflow tree contains a non-canonical workflow file path'); + } + return paths; +} + +/** Read exact case-sensitive workflow file paths from one protected commit. */ +export async function listProtectedWorkflowPaths({ fetchImpl, apiBase, repository, sha, token = '', sleepImpl }) { + const url = `${apiBase}/repos/${repository}/contents/${WORKFLOW_DIRECTORY}?ref=${sha}`; + let data; + try { + ({ data } = await requestJson({ fetchImpl, url, token, sleepImpl })); + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 404) throw error; + return listProtectedWorkflowPathsFromTree({ fetchImpl, apiBase, repository, sha, token, sleepImpl }); + } + if (!Array.isArray(data)) throw new Error('GitHub workflow-directory response is not an array'); + const paths = data.filter((entry) => entry?.type === 'file').map((entry) => entry.path); + if (paths.some((path) => typeof path !== 'string' || !CANONICAL_WORKFLOW_PATH.test(path))) { + throw new Error('GitHub workflow-directory response contains a non-canonical workflow file path'); + } + return paths; +} + +/** Classify exact workflow identities without collapsing reused paths. */ +export function classifyWorkflows(workflows, protectedPaths, preservePaths = []) { + if (!Array.isArray(workflows) || !Array.isArray(protectedPaths)) throw new TypeError('workflows and protectedPaths must be arrays'); + const present = new Set(protectedPaths); + const preserved = new Set(preservePaths); + const pathCounts = new Map(); + for (const workflow of workflows) { + if (typeof workflow?.path === 'string') pathCounts.set(workflow.path, (pathCounts.get(workflow.path) || 0) + 1); + } + return workflows.map((workflow) => { + const { id, path, state } = workflow || {}; + if (!Number.isSafeInteger(id) || id <= 0 || typeof path !== 'string' || typeof state !== 'string') { + throw new Error('workflow registry contains an invalid identity'); + } + let classification; + if (!KNOWN_WORKFLOW_STATES.has(state)) classification = 'unresolved'; + else if (path.startsWith('dynamic/')) classification = 'github_dynamic'; + else if (present.has(path)) classification = state === 'active' ? 'present_active' : 'present_inactive'; + else if (state !== 'active') classification = 'inactive_absent'; + else if (preserved.has(path)) classification = 'preserved_absent'; + else classification = 'active_orphan'; + return { workflow_id: id, path, state, classification, duplicate_path_identity: (pathCounts.get(path) || 0) > 1 }; + }).sort((left, right) => left.workflow_id - right.workflow_id); +} + +/** Produce a pagination-complete audit bound to one unchanged protected SHA. */ +export async function auditWorkflowRegistry({ + fetchImpl = globalThis.fetch, + apiBase = 'https://api.github.com', + repository, + branch = 'develop', + token = '', + preservePaths = [], + sleepImpl, + now = () => new Date(), +}) { + const checkedRepository = validateRepository(repository); + if (typeof branch !== 'string' || branch.length < 1 || branch.length > 255) { + throw new TypeError('branch must be a non-empty string no longer than 255 characters'); + } + if (typeof now !== 'function') throw new TypeError('now must be a function'); + const startSha = await fetchBranchSha({ fetchImpl, apiBase, repository: checkedRepository, branch, token, sleepImpl }); + const registry = await listAllWorkflows({ fetchImpl, apiBase, repository: checkedRepository, token, sleepImpl }); + const protectedPaths = await listProtectedWorkflowPaths({ fetchImpl, apiBase, repository: checkedRepository, sha: startSha, token, sleepImpl }); + const endSha = await fetchBranchSha({ fetchImpl, apiBase, repository: checkedRepository, branch, token, sleepImpl }); + if (startSha !== endSha) throw new Error('protected branch moved during workflow registry audit'); + const classifications = classifyWorkflows(registry.workflows, protectedPaths, preservePaths); + return { + repository: checkedRepository, + branch, + default_branch_sha: startSha, + observed_at: now().toISOString(), + registry_total_count: registry.totalCount, + pagination_receipts: registry.receipts, + protected_workflow_paths: [...protectedPaths].sort(), + classifications, + active_orphan_count: classifications.filter((item) => item.classification === 'active_orphan').length, + unresolved_count: classifications.filter((item) => item.classification === 'unresolved').length, + }; +} + +/** Parse CLI arguments; no write or disable option exists. */ +export function parseArgs(argv, environment = process.env) { + let repository = environment.GITHUB_REPOSITORY || ''; + let branch = 'develop'; + const preservePaths = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--repo') repository = argv[++index] || ''; + else if (argument === '--branch') branch = argv[++index] || ''; + else if (argument === '--preserve-path') preservePaths.push(argv[++index] || ''); + else throw new Error(`unsupported argument: ${argument}`); + } + validateRepository(repository); + if (!branch) throw new Error('branch is required'); + for (const path of preservePaths) { + if (!CANONICAL_WORKFLOW_PATH.test(path)) { + throw new Error(`preserved path must be a canonical workflow file under ${WORKFLOW_DIRECTORY}`); + } + } + return { repository, branch, preservePaths }; +} + +/** Execute the read-only CLI and print one JSON evidence document. */ +export async function main() { + const options = parseArgs(process.argv.slice(2)); + const evidence = await auditWorkflowRegistry({ ...options, token: process.env.GITHUB_TOKEN || '' }); + process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`); +} + +const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (isMain) { + main().catch((error) => { + process.stderr.write(`workflow registry audit failed: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/ci/workflow_registry_cleanup.mjs b/scripts/ci/workflow_registry_cleanup.mjs new file mode 100644 index 00000000..8a23f73f --- /dev/null +++ b/scripts/ci/workflow_registry_cleanup.mjs @@ -0,0 +1,385 @@ +#!/usr/bin/env node +/** + * Fail-closed operator control for disabling GitHub Actions workflow identities + * that a fresh ScopeWeave registry audit proves are active and absent from one + * exact protected-branch tree. + * + * The command is dry-run by default. Mutation requires `--apply`, an exact + * `--expected-sha`, at least one explicitly reviewed `--workflow-id`, and + * `GITHUB_TOKEN`. Every target is re-read immediately before mutation, the + * protected branch is rechecked throughout the operation, and every disable is + * verified through GitHub's workflow metadata endpoint. + * + * @module workflow_registry_cleanup + */ +import { pathToFileURL } from 'node:url'; +import { + GITHUB_API_VERSION, + GitHubApiError, + auditWorkflowRegistry, + fetchBranchSha, + requestJson, + sleepMilliseconds, + validateRepository, +} from './workflow_registry_audit.mjs'; + +const TRANSIENT_STATUS = new Set([500, 502, 503, 504]); +const CANONICAL_WORKFLOW_PATH = /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/; +const WORKFLOW_DISABLE_REQUEST_TIMEOUT_MS = 10_000; + +/** Validate one immutable Git commit identity. */ +export function validateCommitSha(value) { + const sha = String(value || '').toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(sha)) { + throw new TypeError('expected-sha must be a 40-character commit SHA'); + } + return sha; +} + +/** Validate a workflow ID without allowing coercive numeric spellings. */ +function validateWorkflowId(value) { + const text = String(value ?? ''); + if (!/^[1-9][0-9]*$/.test(text)) throw new TypeError('workflow-id must be a positive safe integer'); + const workflowId = Number(text); + if (!Number.isSafeInteger(workflowId) || workflowId <= 0) { + throw new TypeError('workflow-id must be a positive safe integer'); + } + return workflowId; +} + +/** + * Convert read-only audit evidence into candidate identities and the exact + * reviewed subset eligible for an operator disable action. + */ +export function buildWorkflowCleanupPlan({ evidence, expectedSha, reviewedWorkflowIds = [] }) { + if (!evidence || typeof evidence !== 'object') throw new TypeError('audit evidence is required'); + const checkedSha = validateCommitSha(expectedSha); + if (String(evidence.default_branch_sha || '').toLowerCase() !== checkedSha) { + throw new Error('audit evidence does not match the expected protected branch SHA'); + } + if (!Number.isSafeInteger(evidence.unresolved_count) || evidence.unresolved_count !== 0) { + throw new Error('workflow cleanup refuses to mutate while unresolved workflow identities exist'); + } + if (!Array.isArray(evidence.classifications)) throw new Error('audit evidence classifications are missing'); + if (!Array.isArray(reviewedWorkflowIds)) throw new TypeError('reviewed workflow ids must be an array'); + + const candidates = []; + const candidateById = new Map(); + for (const item of evidence.classifications) { + if (item?.classification !== 'active_orphan') continue; + if (!Number.isSafeInteger(item.workflow_id) || item.workflow_id <= 0) { + throw new Error('active orphan has an invalid workflow id'); + } + if (candidateById.has(item.workflow_id)) { + throw new Error(`cleanup plan contains duplicate workflow id ${item.workflow_id}`); + } + if (item.state !== 'active') throw new Error(`active orphan ${item.workflow_id} is not active`); + if (typeof item.path !== 'string' || !CANONICAL_WORKFLOW_PATH.test(item.path)) { + throw new Error(`active orphan ${item.workflow_id} has a non-canonical workflow path`); + } + const candidate = { workflow_id: item.workflow_id, path: item.path }; + candidates.push(candidate); + candidateById.set(item.workflow_id, candidate); + } + candidates.sort((left, right) => left.workflow_id - right.workflow_id); + + const reviewedIds = []; + const seenReviewed = new Set(); + for (const rawWorkflowId of reviewedWorkflowIds) { + const workflowId = validateWorkflowId(rawWorkflowId); + if (seenReviewed.has(workflowId)) throw new Error(`duplicate reviewed workflow id ${workflowId}`); + seenReviewed.add(workflowId); + reviewedIds.push(workflowId); + } + const targets = reviewedIds.map((workflowId) => { + const candidate = candidateById.get(workflowId); + if (!candidate) throw new Error(`reviewed workflow id ${workflowId} is not a current active orphan`); + return candidate; + }).sort((left, right) => left.workflow_id - right.workflow_id); + + return { + repository: evidence.repository, + branch: evidence.branch, + default_branch_sha: checkedSha, + candidates, + targets, + }; +} + +/** Parse dry-run/apply CLI arguments without accepting implicit mutation. */ +export function parseCleanupArgs(argv, environment = process.env) { + let repository = environment.GITHUB_REPOSITORY || ''; + let branch = 'develop'; + let expectedSha = ''; + let apply = false; + const preservePaths = []; + const reviewedWorkflowIds = []; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--repo') { + const value = argv[index + 1]; + if (value?.startsWith('--') !== false) throw new Error('--repo requires a value'); + repository = value; + index += 1; + } else if (argument === '--branch') { + const value = argv[index + 1]; + if (value?.startsWith('--') !== false) throw new Error('--branch requires a value'); + branch = value; + index += 1; + } else if (argument === '--expected-sha') { + const value = argv[index + 1]; + if (value?.startsWith('--') !== false) throw new Error('--expected-sha requires a value'); + expectedSha = value; + index += 1; + } else if (argument === '--preserve-path') { + const value = argv[index + 1]; + if (value?.startsWith('--') !== false) throw new Error('--preserve-path requires a value'); + preservePaths.push(value); + index += 1; + } else if (argument === '--workflow-id') { + const value = argv[index + 1]; + if (value?.startsWith('--') !== false) throw new Error('--workflow-id requires a value'); + reviewedWorkflowIds.push(validateWorkflowId(value)); + index += 1; + } else if (argument === '--apply') apply = true; + else throw new Error(`unsupported argument: ${argument}`); + } + + validateRepository(repository); + if (typeof branch !== 'string' || branch.length < 1 || branch.length > 255) { + throw new TypeError('branch must be a non-empty string no longer than 255 characters'); + } + if (expectedSha) expectedSha = validateCommitSha(expectedSha); + if (new Set(reviewedWorkflowIds).size !== reviewedWorkflowIds.length) throw new Error('duplicate workflow-id'); + if (apply && !expectedSha) throw new Error('expected-sha is required with --apply'); + if (apply && reviewedWorkflowIds.length === 0) throw new Error('workflow-id is required with --apply'); + for (const path of preservePaths) { + if (!CANONICAL_WORKFLOW_PATH.test(path)) { + throw new Error('preserved path must be a canonical immediate workflow YAML path'); + } + } + return { repository, branch, expectedSha, preservePaths, reviewedWorkflowIds, apply }; +} + +/** Read one workflow identity without exposing response bodies in failures. */ +async function fetchWorkflowIdentity({ fetchImpl, apiBase, repository, workflowId, token, sleepImpl }) { + const url = `${apiBase}/repos/${repository}/actions/workflows/${workflowId}`; + const { data } = await requestJson({ fetchImpl, url, token, sleepImpl }); + if (!data || !Number.isSafeInteger(data.id) || typeof data.path !== 'string' || typeof data.state !== 'string') { + throw new Error(`workflow ${workflowId} metadata has an invalid shape`); + } + return { workflow_id: data.id, path: data.path, state: data.state }; +} + +/** Perform one bounded GitHub workflow-disable request. */ +async function requestWorkflowDisable({ + fetchImpl, + apiBase, + repository, + workflowId, + token, + sleepImpl = sleepMilliseconds, + maxAttempts = 3, +}) { + const url = `${apiBase}/repos/${repository}/actions/workflows/${workflowId}/disable`; + const headers = { + accept: 'application/vnd.github+json', + 'x-github-api-version': GITHUB_API_VERSION, + authorization: `Bearer ${token}`, + }; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const response = await fetchImpl(url, { + method: 'PUT', + headers, + redirect: 'error', + signal: AbortSignal.timeout(WORKFLOW_DISABLE_REQUEST_TIMEOUT_MS), + }); + const status = Number(response?.status); + if (status === 204) return; + if (!TRANSIENT_STATUS.has(status) || attempt === maxAttempts) throw new GitHubApiError(status); + await sleepImpl(100 * attempt); + } + throw new Error('unreachable workflow-disable retry state'); +} + +/** Prove the protected branch is still the immutable branch used by the plan. */ +async function assertExpectedBranchSha({ fetchImpl, apiBase, repository, branch, expectedSha, token, sleepImpl }) { + const liveSha = await fetchBranchSha({ fetchImpl, apiBase, repository, branch, token, sleepImpl }); + if (liveSha !== expectedSha) throw new Error('protected branch moved during workflow cleanup'); +} + +/** Verify GitHub has transitioned one workflow identity to manual-disabled state. */ +async function verifyDisabled({ fetchImpl, apiBase, repository, target, token, sleepImpl = sleepMilliseconds }) { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const current = await fetchWorkflowIdentity({ + fetchImpl, apiBase, repository, workflowId: target.workflow_id, token, sleepImpl, + }); + if (current.workflow_id !== target.workflow_id || current.path !== target.path) { + throw new Error(`workflow identity changed for ${target.workflow_id}`); + } + if (current.state === 'disabled_manually') return current; + if (current.state !== 'active') { + throw new Error(`workflow ${target.workflow_id} entered unexpected state ${current.state}`); + } + if (attempt < 3) await sleepImpl(100 * attempt); + } + throw new Error(`workflow ${target.workflow_id} remained active after disable request`); +} + +/** + * Apply a precomputed exact-SHA cleanup plan with per-target optimistic + * concurrency checks. Already-disabled targets are adopted rather than raced. + */ +export async function applyWorkflowCleanupPlan({ + fetchImpl = globalThis.fetch, + apiBase = 'https://api.github.com', + repository, + branch = 'develop', + expectedSha, + targets, + token, + sleepImpl = sleepMilliseconds, +}) { + const checkedRepository = validateRepository(repository); + const checkedSha = validateCommitSha(expectedSha); + if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); + if (typeof sleepImpl !== 'function') throw new TypeError('sleepImpl must be a function'); + if (typeof token !== 'string' || token.length === 0) throw new Error('GitHub token is required for workflow cleanup'); + if (!Array.isArray(targets) || targets.length === 0) throw new TypeError('cleanup targets must be a non-empty array'); + + const normalizedTargets = targets.map((target) => { + if (!Number.isSafeInteger(target?.workflow_id) || target.workflow_id <= 0) throw new Error('cleanup target has invalid workflow id'); + if (typeof target.path !== 'string' || !CANONICAL_WORKFLOW_PATH.test(target.path)) throw new Error('cleanup target has invalid workflow path'); + return { workflow_id: target.workflow_id, path: target.path }; + }); + if (new Set(normalizedTargets.map((target) => target.workflow_id)).size !== normalizedTargets.length) { + throw new Error('cleanup targets contain duplicate workflow ids'); + } + + await assertExpectedBranchSha({ + fetchImpl, apiBase, repository: checkedRepository, branch, expectedSha: checkedSha, token, sleepImpl, + }); + + // Preflight the whole explicitly reviewed plan before the first mutation so + // identity drift cannot leave a partially applied cleanup. + for (const target of normalizedTargets) { + const current = await fetchWorkflowIdentity({ + fetchImpl, apiBase, repository: checkedRepository, workflowId: target.workflow_id, token, sleepImpl, + }); + if (current.workflow_id !== target.workflow_id || current.path !== target.path) { + throw new Error(`workflow identity changed for ${target.workflow_id}`); + } + if (!['active', 'disabled_manually'].includes(current.state)) { + throw new Error(`workflow ${target.workflow_id} is no longer an operator-disable candidate`); + } + } + + const disabled = []; + const alreadyDisabled = []; + for (const target of normalizedTargets) { + await assertExpectedBranchSha({ + fetchImpl, apiBase, repository: checkedRepository, branch, expectedSha: checkedSha, token, sleepImpl, + }); + const current = await fetchWorkflowIdentity({ + fetchImpl, apiBase, repository: checkedRepository, workflowId: target.workflow_id, token, sleepImpl, + }); + if (current.workflow_id !== target.workflow_id || current.path !== target.path) { + throw new Error(`workflow identity changed for ${target.workflow_id}`); + } + if (current.state === 'disabled_manually') { + alreadyDisabled.push(current); + continue; + } + if (current.state !== 'active') throw new Error(`workflow ${target.workflow_id} is no longer active`); + + await requestWorkflowDisable({ + fetchImpl, + apiBase, + repository: checkedRepository, + workflowId: target.workflow_id, + token, + sleepImpl, + }); + const verified = await verifyDisabled({ + fetchImpl, apiBase, repository: checkedRepository, target, token, sleepImpl, + }); + disabled.push(verified); + } + + await assertExpectedBranchSha({ + fetchImpl, apiBase, repository: checkedRepository, branch, expectedSha: checkedSha, token, sleepImpl, + }); + return { repository: checkedRepository, branch, expected_sha: checkedSha, disabled, already_disabled: alreadyDisabled }; +} + +/** Plan or apply cleanup from one fresh, pagination-complete registry audit. */ +export async function cleanupWorkflowRegistry({ + fetchImpl = globalThis.fetch, + apiBase = 'https://api.github.com', + repository, + branch = 'develop', + expectedSha = '', + preservePaths = [], + reviewedWorkflowIds = [], + apply = false, + token = '', + sleepImpl = sleepMilliseconds, + now = () => new Date(), +}) { + if (apply && (typeof token !== 'string' || token.length === 0)) { + throw new Error('GitHub token is required for workflow cleanup'); + } + if (apply && !expectedSha) throw new Error('expected-sha is required for apply mode'); + if (apply && (!Array.isArray(reviewedWorkflowIds) || reviewedWorkflowIds.length === 0)) { + throw new Error('workflow-id is required for apply mode'); + } + + const evidence = await auditWorkflowRegistry({ + fetchImpl, apiBase, repository, branch, token, preservePaths, sleepImpl, now, + }); + const boundSha = expectedSha ? validateCommitSha(expectedSha) : evidence.default_branch_sha; + const plan = buildWorkflowCleanupPlan({ evidence, expectedSha: boundSha, reviewedWorkflowIds }); + if (!apply) return { mode: 'dry_run', plan, evidence }; + + const result = await applyWorkflowCleanupPlan({ + fetchImpl, + apiBase, + repository, + branch, + expectedSha: boundSha, + targets: plan.targets, + token, + sleepImpl, + }); + const after = await auditWorkflowRegistry({ + fetchImpl, apiBase, repository, branch, token, preservePaths, sleepImpl, now, + }); + if (after.default_branch_sha !== boundSha) throw new Error('protected branch moved before cleanup postcondition audit'); + if (!Number.isSafeInteger(after.unresolved_count) || after.unresolved_count !== 0) { + throw new Error('cleanup postcondition failed: unresolved workflow identities remain'); + } + const targetIds = new Set(plan.targets.map((target) => target.workflow_id)); + const stillActive = after.classifications.filter( + (item) => targetIds.has(item.workflow_id) && item.classification === 'active_orphan', + ); + if (stillActive.length > 0) throw new Error('cleanup postcondition failed: planned workflow remains active'); + return { mode: 'applied', plan, result, after }; +} + +/** Execute the operator CLI and emit non-secret JSON evidence. */ +export async function main() { + const options = parseCleanupArgs(process.argv.slice(2)); + const result = await cleanupWorkflowRegistry({ + ...options, + token: process.env.GITHUB_TOKEN || '', + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().catch((error) => { + process.stderr.write(`${error?.message || 'workflow registry cleanup failed'}\n`); + process.exitCode = 1; + }); +} diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..61de64fa 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,6 +34,16 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=scripts\/ci\/workflow_registry_audit\.mjs/, + 'the workflow registry detector is instrumented as owned production code', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/workflow-registry-audit\.test\.mjs/, + 'the workflow registry detector regression executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, diff --git a/tests/unit/workflow-registry-audit.test.mjs b/tests/unit/workflow-registry-audit.test.mjs new file mode 100644 index 00000000..dc18fe96 --- /dev/null +++ b/tests/unit/workflow-registry-audit.test.mjs @@ -0,0 +1,299 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + auditWorkflowRegistry, + classifyWorkflows, + listAllWorkflows, + listProtectedWorkflowPaths, + parseArgs, + parseLinkHeader, + requestJson, + sleepMilliseconds, + validateRepository, +} from '../../scripts/ci/workflow_registry_audit.mjs'; + +const SHA_A = 'a'.repeat(40); +const SHA_B = 'b'.repeat(40); +const ROOT_TREE_SHA = 'c'.repeat(40); +const GITHUB_TREE_SHA = 'd'.repeat(40); +const WORKFLOW_TREE_SHA = 'e'.repeat(40); +const API = 'https://api.github.test'; +const REPO = 'ContextualWisdomLab/scopeweave'; + +function response(status, body, headers = {}) { + const normalized = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])); + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (name) => normalized.get(String(name).toLowerCase()) ?? null }, + async json() { if (body instanceof Error) throw body; return body; }, + }; +} + +const branchBody = (sha) => ({ name: 'develop', commit: { sha } }); +const directoryBody = (paths) => paths.map((path, index) => ({ + name: path.split('/').at(-1), path, sha: String(index).padStart(40, '0'), type: 'file', +})); +const treeBody = (tree, truncated = false) => ({ sha: ROOT_TREE_SHA, tree, truncated }); +const workflow = (id, path, state = 'active') => ({ id, path, state, name: path }); + +test('repository and Link parsing reject ambiguity without normalizing paths', () => { + assert.equal(validateRepository(REPO), REPO); + assert.throws(() => validateRepository('scopeweave'), /owner\/name/); + assert.throws(() => validateRepository('owner/repo/extra'), /owner\/name/); + const links = parseLinkHeader('; rel="next", ; rel="last"'); + assert.equal(links.get('next'), 'https://api.github.test/page/2'); + assert.equal(links.get('last'), 'https://api.github.test/page/3'); + assert.equal(parseLinkHeader('garbage').size, 0); +}); + +test('production retry delay uses a real timer and exposes a deterministic test seam', async () => { + const scheduled = []; + let released = false; + const promise = sleepMilliseconds(125, (callback, delay) => { + scheduled.push(delay); + callback(); + return 1; + }).then(() => { released = true; }); + assert.deepEqual(scheduled, [125]); + await promise; + assert.equal(released, true); + await assert.rejects(() => sleepMilliseconds(-1), /delay/); +}); + +test('requestJson retries bounded transient 5xx responses but fails closed on permissions and malformed JSON', async () => { + let attempts = 0; + const delays = []; + const recovered = await requestJson({ + fetchImpl: async () => { attempts += 1; return attempts < 3 ? response(503, { private: 'must not surface' }) : response(200, { ok: true }); }, + url: `${API}/probe`, + sleepImpl: async (delay) => { delays.push(delay); }, + }); + assert.deepEqual(recovered.data, { ok: true }); + assert.equal(attempts, 3); + assert.deepEqual(delays, [100, 200]); + + for (const status of [403, 404]) { + await assert.rejects( + () => requestJson({ fetchImpl: async () => response(status, { secret: 'private provider text' }), url: `${API}/probe` }), + (error) => { + assert.match(error.message, new RegExp(`status ${status}`)); + assert.equal(error.status, status); + assert.doesNotMatch(error.message, /private provider text|secret/); + return true; + }, + ); + } + await assert.rejects( + () => requestJson({ fetchImpl: async () => response(200, new SyntaxError('raw parser detail')), url: `${API}/probe` }), + /invalid JSON/, + ); + await assert.rejects( + () => requestJson({ fetchImpl: async () => response(500, {}), url: `${API}/probe`, maxAttempts: 2 }), + /status 500/, + ); + await assert.rejects( + () => requestJson({ fetchImpl: async () => response(200, {}), url: `${API}/probe`, maxAttempts: 0 }), + /maxAttempts/, + ); +}); + +test('workflow pagination must be complete, unique by ID, and unable to follow an untrusted next endpoint', async () => { + const firstUrl = `${API}/repos/${REPO}/actions/workflows?per_page=100`; + const secondUrl = `${API}/repos/${REPO}/actions/workflows?page=2&per_page=100`; + const pages = new Map([ + [firstUrl, response(200, { total_count: 2, workflows: [workflow(1, '.github/workflows/a.yml')] }, { link: `<${secondUrl}>; rel="next"` })], + [secondUrl, response(200, { total_count: 2, workflows: [workflow(2, '.github/workflows/b.yml')] })], + ]); + const complete = await listAllWorkflows({ fetchImpl: async (url) => pages.get(url) || response(404, {}), apiBase: API, repository: REPO }); + assert.deepEqual(complete.workflows.map((item) => item.id), [1, 2]); + assert.equal(complete.totalCount, 2); + assert.deepEqual(complete.receipts.map((item) => item.item_count), [1, 1]); + + await assert.rejects( + () => listAllWorkflows({ fetchImpl: async () => response(200, { total_count: 2, workflows: [workflow(1, '.github/workflows/a.yml')] }), apiBase: API, repository: REPO }), + /pagination incomplete/, + ); + const duplicatePages = new Map([ + [firstUrl, response(200, { total_count: 2, workflows: [workflow(1, '.github/workflows/a.yml')] }, { link: `<${secondUrl}>; rel="next"` })], + [secondUrl, response(200, { total_count: 2, workflows: [workflow(1, '.github/workflows/a-renamed.yml')] })], + ]); + await assert.rejects( + () => listAllWorkflows({ fetchImpl: async (url) => duplicatePages.get(url) || response(404, {}), apiBase: API, repository: REPO }), + /duplicate workflow id/, + ); + await assert.rejects( + () => listAllWorkflows({ + fetchImpl: async () => response(200, { total_count: 1, workflows: [workflow(1, '.github/workflows/a.yml')] }, { link: '; rel="next"' }), + apiBase: API, + repository: REPO, + }), + /trusted GitHub endpoint/, + ); +}); + +test('a genuine missing workflow directory is proven through immutable Git trees', async () => { + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + const commitUrl = `${API}/repos/${REPO}/git/commits/${SHA_A}`; + const rootUrl = `${API}/repos/${REPO}/git/trees/${ROOT_TREE_SHA}`; + const githubUrl = `${API}/repos/${REPO}/git/trees/${GITHUB_TREE_SHA}`; + const fetchImpl = async (url) => { + if (url === contentsUrl) return response(404, { message: 'Not Found' }); + if (url === commitUrl) return response(200, { tree: { sha: ROOT_TREE_SHA } }); + if (url === rootUrl) return response(200, treeBody([{ path: '.github', type: 'tree', sha: GITHUB_TREE_SHA }])); + if (url === githubUrl) return response(200, treeBody([{ path: 'CODEOWNERS', type: 'blob', sha: 'f'.repeat(40) }])); + return response(404, {}); + }; + assert.deepEqual( + await listProtectedWorkflowPaths({ fetchImpl, apiBase: API, repository: REPO, sha: SHA_A }), + [], + ); +}); + +test('tree fallback returns exact workflow files only after all required tree reads succeed', async () => { + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + const commitUrl = `${API}/repos/${REPO}/git/commits/${SHA_A}`; + const rootUrl = `${API}/repos/${REPO}/git/trees/${ROOT_TREE_SHA}`; + const githubUrl = `${API}/repos/${REPO}/git/trees/${GITHUB_TREE_SHA}`; + const workflowsUrl = `${API}/repos/${REPO}/git/trees/${WORKFLOW_TREE_SHA}`; + const fetchImpl = async (url) => { + if (url === contentsUrl) return response(404, { message: 'Not Found' }); + if (url === commitUrl) return response(200, { tree: { sha: ROOT_TREE_SHA } }); + if (url === rootUrl) return response(200, treeBody([{ path: '.github', type: 'tree', sha: GITHUB_TREE_SHA }])); + if (url === githubUrl) return response(200, treeBody([{ path: 'workflows', type: 'tree', sha: WORKFLOW_TREE_SHA }])); + if (url === workflowsUrl) return response(200, treeBody([ + { path: 'server-tests.yml', type: 'blob', sha: 'f'.repeat(40) }, + { path: 'nested', type: 'tree', sha: '1'.repeat(40) }, + ])); + return response(404, {}); + }; + assert.deepEqual( + await listProtectedWorkflowPaths({ fetchImpl, apiBase: API, repository: REPO, sha: SHA_A }), + ['.github/workflows/server-tests.yml'], + ); +}); + +test('ambiguous Contents 404 remains an error when immutable Git evidence is unavailable', async () => { + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + const commitUrl = `${API}/repos/${REPO}/git/commits/${SHA_A}`; + await assert.rejects( + () => listProtectedWorkflowPaths({ + fetchImpl: async (url) => { + if (url === contentsUrl) return response(404, { message: 'hidden contents' }); + if (url === commitUrl) return response(404, { message: 'hidden commit' }); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + (error) => { + assert.match(error.message, /status 404/); + assert.doesNotMatch(error.message, /hidden contents|hidden commit/); + return true; + }, + ); +}); + +test('classification preserves exact case, known states, dynamic identities, exceptions, reused paths, and unresolved states', () => { + const repeatedPath = '.github/workflows/reused.yml'; + const classified = classifyWorkflows([ + workflow(8, '.github/workflows/Case.yml'), + workflow(2, '.github/workflows/current.yml'), + workflow(3, 'dynamic/dependabot/update-graph'), + workflow(4, '.github/workflows/kept-by-active-pr.yml'), + workflow(5, '.github/workflows/old-disabled.yml', 'disabled_manually'), + workflow(6, repeatedPath), workflow(7, repeatedPath), + workflow(9, '.github/workflows/future-absent.yml', 'paused_by_future_api'), + workflow(10, '.github/workflows/future-present.yml', 'paused_by_future_api'), + workflow(11, 'dynamic/future-provider', 'paused_by_future_api'), + ], [ + '.github/workflows/current.yml', + '.github/workflows/case.yml', + '.github/workflows/future-present.yml', + ], ['.github/workflows/kept-by-active-pr.yml']); + const byId = new Map(classified.map((item) => [item.workflow_id, item])); + assert.equal(byId.get(8).classification, 'active_orphan', 'path case is exact'); + assert.equal(byId.get(2).classification, 'present_active'); + assert.equal(byId.get(3).classification, 'github_dynamic'); + assert.equal(byId.get(4).classification, 'preserved_absent'); + assert.equal(byId.get(5).classification, 'inactive_absent'); + assert.equal(byId.get(6).classification, 'active_orphan'); + assert.equal(byId.get(7).classification, 'active_orphan'); + assert.equal(byId.get(6).duplicate_path_identity, true); + assert.equal(byId.get(7).duplicate_path_identity, true); + assert.equal(byId.get(9).classification, 'unresolved'); + assert.equal(byId.get(10).classification, 'unresolved'); + assert.equal(byId.get(11).classification, 'unresolved'); +}); + +test('a present one-shot-like workflow is preserved by tree evidence without name heuristics', () => { + const path = '.github/workflows/one-shot-legitimate-production-check.yml'; + const [entry] = classifyWorkflows([workflow(42, path)], [path]); + assert.equal(entry.classification, 'present_active'); +}); + +test('full audit binds registry evidence to one unchanged protected SHA and emits pagination receipts', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowsUrl = `${API}/repos/${REPO}/actions/workflows?per_page=100`; + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + let branchReads = 0; + const fetchImpl = async (url, init) => { + assert.equal(init.method, 'GET'); + assert.equal(init.redirect, 'error'); + assert.equal(init.headers['x-github-api-version'], '2026-03-10'); + if (url === branchUrl) { branchReads += 1; return response(200, branchBody(SHA_A)); } + if (url === workflowsUrl) return response(200, { total_count: 5, workflows: [ + workflow(10, '.github/workflows/current.yml'), workflow(11, '.github/workflows/orphan.yml'), + workflow(12, '.github/workflows/pr-owned.yml'), workflow(13, 'dynamic/github-code-scanning/codeql'), + workflow(14, '.github/workflows/future.yml', 'future_state'), + ] }); + if (url === contentsUrl) return response(200, directoryBody(['.github/workflows/current.yml'])); + return response(404, {}); + }; + const evidence = await auditWorkflowRegistry({ + fetchImpl, apiBase: API, repository: REPO, branch: 'develop', + preservePaths: ['.github/workflows/pr-owned.yml'], now: () => new Date('2026-08-15T00:00:00.000Z'), + }); + assert.equal(branchReads, 2); + assert.equal(evidence.default_branch_sha, SHA_A); + assert.equal(evidence.observed_at, '2026-08-15T00:00:00.000Z'); + assert.equal(evidence.registry_total_count, 5); + assert.equal(evidence.pagination_receipts.length, 1); + assert.equal(evidence.active_orphan_count, 1); + assert.equal(evidence.unresolved_count, 1); + assert.deepEqual(evidence.protected_workflow_paths, ['.github/workflows/current.yml']); + assert.equal(evidence.classifications.find((item) => item.workflow_id === 11).classification, 'active_orphan'); + assert.equal(evidence.classifications.find((item) => item.workflow_id === 14).classification, 'unresolved'); +}); + +test('branch movement invalidates the entire mixed-time observation', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowsUrl = `${API}/repos/${REPO}/actions/workflows?per_page=100`; + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + let branchReads = 0; + const fetchImpl = async (url) => { + if (url === branchUrl) { branchReads += 1; return response(200, branchBody(branchReads === 1 ? SHA_A : SHA_B)); } + if (url === workflowsUrl) return response(200, { total_count: 0, workflows: [] }); + if (url === contentsUrl) return response(200, []); + return response(404, {}); + }; + await assert.rejects(() => auditWorkflowRegistry({ fetchImpl, apiBase: API, repository: REPO, branch: 'develop' }), /protected branch moved/); +}); + +test('CLI options require canonical workflow-file exceptions and reject write-like arguments', () => { + assert.deepEqual( + parseArgs(['--repo', REPO, '--branch', 'develop', '--preserve-path', '.github/workflows/hourly.yml'], {}), + { repository: REPO, branch: 'develop', preservePaths: ['.github/workflows/hourly.yml'] }, + ); + for (const invalidPath of [ + 'dynamic/agent', + '.github/workflows/../security.yml', + '.github/workflows/subdir/child.yml', + '.github/workflows/', + '.github\\workflows\\hourly.yml', + ]) { + assert.throws(() => parseArgs(['--repo', REPO, '--preserve-path', invalidPath], {}), /canonical workflow file/); + } + assert.throws(() => parseArgs(['--repo', REPO, '--write'], {}), /unsupported argument/); +}); diff --git a/tests/unit/workflow-registry-cleanup-postcondition.test.mjs b/tests/unit/workflow-registry-cleanup-postcondition.test.mjs new file mode 100644 index 00000000..a28cd671 --- /dev/null +++ b/tests/unit/workflow-registry-cleanup-postcondition.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cleanupWorkflowRegistry } from '../../scripts/ci/workflow_registry_cleanup.mjs'; + +const API = 'https://api.github.test'; +const REPO = 'ContextualWisdomLab/scopeweave'; +const SHA = 'a'.repeat(40); +const TARGET_PATH = '.github/workflows/legacy-repair.yml'; +const PRESENT_PATH = '.github/workflows/server-tests.yml'; + +function response(status, body = null, headers = {}) { + const normalized = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])); + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (name) => normalized.get(String(name).toLowerCase()) ?? null }, + async json() { return body; }, + }; +} + +test('cleanup rejects an applied result when the fresh postcondition audit becomes unresolved', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowsUrl = `${API}/repos/${REPO}/actions/workflows?per_page=100`; + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA}`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + const disableUrl = `${workflowUrl}/disable`; + let disabled = false; + + const fetchImpl = async (url, init = {}) => { + if (url === branchUrl) return response(200, { commit: { sha: SHA } }); + if (url === workflowsUrl) { + return response(200, { + total_count: 2, + workflows: [ + { id: 11, path: TARGET_PATH, state: disabled ? 'disabled_manually' : 'active' }, + { id: 12, path: PRESENT_PATH, state: disabled ? 'unknown_future_state' : 'active' }, + ], + }); + } + if (url === contentsUrl) { + return response(200, [{ type: 'file', path: PRESENT_PATH }]); + } + if (url === workflowUrl && (init.method ?? 'GET') === 'GET') { + return response(200, { id: 11, path: TARGET_PATH, state: disabled ? 'disabled_manually' : 'active' }); + } + if (url === disableUrl && init.method === 'PUT') { + disabled = true; + return response(204); + } + return response(404, { private: 'must not surface' }); + }; + + await assert.rejects( + () => cleanupWorkflowRegistry({ + fetchImpl, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA, + reviewedWorkflowIds: [11], + apply: true, + token: 'secret-token', + sleepImpl: async () => {}, + now: () => new Date('2026-08-18T01:00:00.000Z'), + }), + /cleanup postcondition failed: unresolved workflow identities remain/, + ); + assert.equal(disabled, true); +}); diff --git a/tests/unit/workflow-registry-cleanup.test.mjs b/tests/unit/workflow-registry-cleanup.test.mjs new file mode 100644 index 00000000..25180729 --- /dev/null +++ b/tests/unit/workflow-registry-cleanup.test.mjs @@ -0,0 +1,305 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyWorkflowCleanupPlan, + buildWorkflowCleanupPlan, + cleanupWorkflowRegistry, + parseCleanupArgs, +} from '../../scripts/ci/workflow_registry_cleanup.mjs'; + +const API = 'https://api.github.test'; +const REPO = 'ContextualWisdomLab/scopeweave'; +const SHA_A = 'a'.repeat(40); +const SHA_B = 'b'.repeat(40); + +function response(status, body = null, headers = {}) { + const normalized = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])); + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (name) => normalized.get(String(name).toLowerCase()) ?? null }, + async json() { return body; }, + }; +} + +function activeOrphan(id, path) { + return { + workflow_id: id, + path, + state: 'active', + classification: 'active_orphan', + duplicate_path_identity: false, + }; +} + +function evidence(overrides = {}) { + return { + repository: REPO, + branch: 'develop', + default_branch_sha: SHA_A, + unresolved_count: 0, + classifications: [ + activeOrphan(11, '.github/workflows/legacy-repair.yml'), + activeOrphan(13, '.github/workflows/retired-one-shot.yml'), + { + workflow_id: 12, + path: '.github/workflows/server-tests.yml', + state: 'active', + classification: 'present_active', + duplicate_path_identity: false, + }, + ], + ...overrides, + }; +} + +test('cleanup plan is exact-SHA bound and mutates only explicitly reviewed active-orphan IDs', () => { + const plan = buildWorkflowCleanupPlan({ evidence: evidence(), expectedSha: SHA_A, reviewedWorkflowIds: [11] }); + assert.equal(plan.default_branch_sha, SHA_A); + assert.deepEqual(plan.candidates, [ + { workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }, + { workflow_id: 13, path: '.github/workflows/retired-one-shot.yml' }, + ]); + assert.deepEqual(plan.targets, [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }]); + + const dryRunPlan = buildWorkflowCleanupPlan({ evidence: evidence(), expectedSha: SHA_A }); + assert.equal(dryRunPlan.candidates.length, 2); + assert.deepEqual(dryRunPlan.targets, []); + + assert.throws( + () => buildWorkflowCleanupPlan({ evidence: evidence(), expectedSha: SHA_B, reviewedWorkflowIds: [11] }), + /expected protected branch SHA/, + ); + assert.throws( + () => buildWorkflowCleanupPlan({ evidence: evidence({ unresolved_count: 1 }), expectedSha: SHA_A, reviewedWorkflowIds: [11] }), + /unresolved workflow identities/, + ); + assert.throws( + () => buildWorkflowCleanupPlan({ evidence: evidence(), expectedSha: SHA_A, reviewedWorkflowIds: [12] }), + /not a current active orphan/, + ); + assert.throws( + () => buildWorkflowCleanupPlan({ evidence: evidence(), expectedSha: SHA_A, reviewedWorkflowIds: [99] }), + /not a current active orphan/, + ); + assert.throws( + () => buildWorkflowCleanupPlan({ evidence: evidence(), expectedSha: SHA_A, reviewedWorkflowIds: [11, 11] }), + /duplicate reviewed workflow id/, + ); +}); + +test('cleanup CLI is dry-run by default and apply requires immutable SHA plus explicit reviewed workflow IDs', () => { + assert.deepEqual( + parseCleanupArgs(['--repo', REPO, '--branch', 'develop', '--expected-sha', SHA_A]), + { repository: REPO, branch: 'develop', expectedSha: SHA_A, preservePaths: [], reviewedWorkflowIds: [], apply: false }, + ); + assert.deepEqual( + parseCleanupArgs([ + '--repo', REPO, + '--expected-sha', SHA_A, + '--preserve-path', '.github/workflows/pr-owned.yml', + '--workflow-id', '11', + '--workflow-id', '13', + '--apply', + ]), + { + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + preservePaths: ['.github/workflows/pr-owned.yml'], + reviewedWorkflowIds: [11, 13], + apply: true, + }, + ); + assert.throws(() => parseCleanupArgs(['--repo', REPO, '--apply']), /expected-sha is required/); + assert.throws(() => parseCleanupArgs(['--repo', REPO, '--expected-sha', SHA_A, '--apply']), /workflow-id is required/); + assert.throws( + () => parseCleanupArgs(['--repo', REPO, '--expected-sha', SHA_A, '--workflow-id', '0', '--apply']), + /workflow-id must be a positive safe integer/, + ); + assert.throws( + () => parseCleanupArgs(['--repo', REPO, '--expected-sha', SHA_A, '--workflow-id', '11', '--workflow-id', '11', '--apply']), + /duplicate workflow-id/, + ); + assert.throws(() => parseCleanupArgs(['--repo', REPO, '--expected-sha', 'main', '--apply']), /40-character commit SHA/); + assert.throws(() => parseCleanupArgs(['--repo', REPO, '--expected-sha', SHA_A, '--unknown']), /unsupported argument/); + + for (const flag of ['--repo', '--branch', '--expected-sha', '--preserve-path', '--workflow-id']) { + const args = flag === '--repo' ? [flag, '--apply'] : ['--repo', REPO, flag, '--apply']; + assert.throws(() => parseCleanupArgs(args), new RegExp(`${flag} requires a value`)); + } +}); + +test('high-level apply refuses missing authentication before audit or mutation traffic', async () => { + let requests = 0; + await assert.rejects( + () => cleanupWorkflowRegistry({ + fetchImpl: async () => { requests += 1; return response(403, { private: 'must not surface' }); }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + reviewedWorkflowIds: [11], + apply: true, + token: '', + }), + /GitHub token is required/, + ); + assert.equal(requests, 0); +}); + +test('apply preflights exact workflow identity, disables only the planned ID, and verifies disabled state', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + const disableUrl = `${workflowUrl}/disable`; + let disabled = false; + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, method: init.method, authorization: init.headers.authorization, signal: init.signal }); + if (url === branchUrl) return response(200, { commit: { sha: SHA_A } }); + if (url === workflowUrl && init.method === 'GET') { + return response(200, { + id: 11, + path: '.github/workflows/legacy-repair.yml', + state: disabled ? 'disabled_manually' : 'active', + }); + } + if (url === disableUrl && init.method === 'PUT') { + disabled = true; + return response(204); + } + return response(404, { private: 'must not surface' }); + }; + + const result = await applyWorkflowCleanupPlan({ + fetchImpl, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }], + token: 'secret-token', + sleepImpl: async () => {}, + }); + + assert.deepEqual(result.disabled, [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml', state: 'disabled_manually' }]); + assert.equal(calls.filter((call) => call.method === 'PUT').length, 1); + const disableCall = calls.find((call) => call.method === 'PUT'); + assert.equal(disableCall.url, disableUrl); + assert.ok(disableCall.signal instanceof AbortSignal, 'workflow disable request must carry a bounded AbortSignal'); + assert.ok(calls.every((call) => call.authorization === 'Bearer secret-token')); +}); + +test('protected-branch movement or workflow identity drift prevents mutation', async () => { + let putCount = 0; + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (init.method === 'PUT') putCount += 1; + if (url.endsWith('/branches/develop')) return response(200, { commit: { sha: SHA_B } }); + return response(200, { id: 11, path: '.github/workflows/legacy-repair.yml', state: 'active' }); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }], + token: 'secret-token', + }), + /protected branch moved/, + ); + assert.equal(putCount, 0); + + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (init.method === 'PUT') putCount += 1; + if (url.endsWith('/branches/develop')) return response(200, { commit: { sha: SHA_A } }); + return response(200, { id: 11, path: '.github/workflows/reused.yml', state: 'active' }); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }], + token: 'secret-token', + }), + /workflow identity changed/, + ); + assert.equal(putCount, 0); +}); + +test('disable retries bounded transient 5xx failures and never retries permission failures', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + const disableUrl = `${workflowUrl}/disable`; + const delays = []; + let putAttempts = 0; + let disabled = false; + const fetchImpl = async (url, init) => { + if (url === branchUrl) return response(200, { commit: { sha: SHA_A } }); + if (url === workflowUrl) return response(200, { id: 11, path: '.github/workflows/legacy-repair.yml', state: disabled ? 'disabled_manually' : 'active' }); + if (url === disableUrl && init.method === 'PUT') { + putAttempts += 1; + if (putAttempts < 2) return response(503, { private: 'provider detail' }); + disabled = true; + return response(204); + } + return response(404, {}); + }; + const result = await applyWorkflowCleanupPlan({ + fetchImpl, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }], + token: 'secret-token', + sleepImpl: async (delay) => { delays.push(delay); }, + }); + assert.equal(result.disabled.length, 1); + assert.equal(putAttempts, 2); + assert.deepEqual(delays, [100]); + + putAttempts = 0; + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (url === branchUrl) return response(200, { commit: { sha: SHA_A } }); + if (url === workflowUrl && init.method === 'GET') return response(200, { id: 11, path: '.github/workflows/legacy-repair.yml', state: 'active' }); + if (url === disableUrl && init.method === 'PUT') { putAttempts += 1; return response(403, { private: 'secret response' }); } + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }], + token: 'secret-token', + sleepImpl: async (delay) => { delays.push(delay); }, + }), + (error) => { + assert.match(error.message, /status 403/); + assert.doesNotMatch(error.message, /secret response/); + return true; + }, + ); + assert.equal(putAttempts, 1); +}); + +test('apply requires explicit authentication before any network request', async () => { + let requests = 0; + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async () => { requests += 1; return response(500, {}); }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: '.github/workflows/legacy-repair.yml' }], + token: '', + }), + /GitHub token is required/, + ); + assert.equal(requests, 0); +}); diff --git a/tests/unit/workflow-registry-coverage.test.mjs b/tests/unit/workflow-registry-coverage.test.mjs new file mode 100644 index 00000000..d74cad98 --- /dev/null +++ b/tests/unit/workflow-registry-coverage.test.mjs @@ -0,0 +1,685 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { + GitHubApiError, + auditWorkflowRegistry, + classifyWorkflows, + fetchBranchSha, + listAllWorkflows, + listProtectedWorkflowPaths, + parseArgs, + requestJson, + sleepMilliseconds, +} from '../../scripts/ci/workflow_registry_audit.mjs'; +import { + applyWorkflowCleanupPlan, + buildWorkflowCleanupPlan, + cleanupWorkflowRegistry, + parseCleanupArgs, +} from '../../scripts/ci/workflow_registry_cleanup.mjs'; + +const API = 'https://api.github.test'; +const REPO = 'ContextualWisdomLab/scopeweave'; +const SHA_A = 'a'.repeat(40); +const SHA_B = 'b'.repeat(40); +const ROOT_TREE_SHA = 'c'.repeat(40); +const GITHUB_TREE_SHA = 'd'.repeat(40); +const WORKFLOW_TREE_SHA = 'e'.repeat(40); +const TARGET_PATH = '.github/workflows/legacy-repair.yml'; + +function response(status, body = null, headers = {}) { + const normalized = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])); + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (name) => normalized.get(String(name).toLowerCase()) ?? null }, + async json() { + if (body instanceof Error) throw body; + return body; + }, + }; +} + +function workflowIdentity(state = 'active', path = TARGET_PATH, id = 11) { + return { id, path, state }; +} + +function activeOrphan(id = 11, path = TARGET_PATH) { + return { + workflow_id: id, + path, + state: 'active', + classification: 'active_orphan', + duplicate_path_identity: false, + }; +} + +function cleanupEvidence(overrides = {}) { + return { + repository: REPO, + branch: 'develop', + default_branch_sha: SHA_A, + unresolved_count: 0, + classifications: [activeOrphan()], + ...overrides, + }; +} + +function branchBody(sha) { + return { commit: { sha } }; +} + +test('audit primitives reject malformed dependencies, ranges, and response identities', async () => { + const unknown = new GitHubApiError(undefined); + assert.equal(unknown.status, undefined); + assert.match(unknown.message, /unknown/); + + await assert.rejects(() => sleepMilliseconds(60_001), /retry delay/); + await assert.rejects(() => sleepMilliseconds(1.5), /retry delay/); + await assert.rejects(() => sleepMilliseconds(1, null), /setTimeoutImpl/); + + await assert.rejects( + () => requestJson({ fetchImpl: null, url: `${API}/probe` }), + /fetchImpl/, + ); + await assert.rejects( + () => requestJson({ fetchImpl: async () => response(200, {}), sleepImpl: null, url: `${API}/probe` }), + /sleepImpl/, + ); + await assert.rejects( + () => requestJson({ fetchImpl: async () => response(200, {}), url: `${API}/probe`, maxAttempts: 6 }), + /maxAttempts/, + ); + const noLink = await requestJson({ + fetchImpl: async () => ({ status: 200, ok: true, headers: null, async json() { return { ok: true }; } }), + url: `${API}/probe`, + }); + assert.equal(noLink.linkHeader, null); + await assert.rejects( + () => requestJson({ fetchImpl: async () => ({ status: undefined, ok: false }), url: `${API}/probe`, maxAttempts: 1 }), + (error) => error instanceof GitHubApiError && error.status === undefined, + ); + await assert.rejects( + () => fetchBranchSha({ fetchImpl: async () => response(200, { commit: { sha: 'not-a-sha' } }), apiBase: API, repository: REPO, branch: 'develop' }), + /valid commit SHA/, + ); +}); + +test('workflow pagination rejects changing totals, invalid shapes, invalid IDs, and runaway next links', async () => { + await assert.rejects( + () => listAllWorkflows({ fetchImpl: async () => response(200, { total_count: 0, workflows: null }), apiBase: API, repository: REPO }), + /invalid shape/, + ); + + let changingPage = 0; + await assert.rejects( + () => listAllWorkflows({ + fetchImpl: async (url) => { + changingPage += 1; + if (changingPage === 1) { + return response(200, { total_count: 2, workflows: [workflowIdentity('active', '.github/workflows/a.yml', 1)] }, { + link: `<${API}/repos/${REPO}/actions/workflows?page=2&per_page=100>; rel="next"`, + }); + } + return response(200, { total_count: 3, workflows: [workflowIdentity('active', '.github/workflows/b.yml', 2)] }); + }, + apiBase: API, + repository: REPO, + }), + /total_count changed/, + ); + + await assert.rejects( + () => listAllWorkflows({ + fetchImpl: async () => response(200, { total_count: 1, workflows: [{ id: '1', path: '.github/workflows/a.yml', state: 'active' }] }), + apiBase: API, + repository: REPO, + }), + /invalid workflow id/, + ); + + let pages = 0; + await assert.rejects( + () => listAllWorkflows({ + fetchImpl: async (url) => { + pages += 1; + const nextPage = pages + 1; + return response(200, { + total_count: 101, + workflows: [workflowIdentity('active', `.github/workflows/${pages}.yml`, pages)], + }, { + link: `<${API}/repos/${REPO}/actions/workflows?page=${nextPage}&per_page=100>; rel="next"`, + }); + }, + apiBase: API, + repository: REPO, + }), + /100-page safety bound/, + ); + assert.equal(pages, 100); +}); + +test('protected workflow path reads fail closed on ambiguous and malformed Contents responses', async () => { + await assert.rejects( + () => listProtectedWorkflowPaths({ fetchImpl: async () => response(403, { private: 'hidden' }), apiBase: API, repository: REPO, sha: SHA_A }), + /status 403/, + ); + await assert.rejects( + () => listProtectedWorkflowPaths({ fetchImpl: async () => response(200, { path: 'not-an-array' }), apiBase: API, repository: REPO, sha: SHA_A }), + /not an array/, + ); + await assert.rejects( + () => listProtectedWorkflowPaths({ + fetchImpl: async () => response(200, [{ type: 'file', path: '.github/workflows/nested/bad.yml' }]), + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + /non-canonical/, + ); + assert.deepEqual( + await listProtectedWorkflowPaths({ + fetchImpl: async () => response(200, [{ type: 'dir', path: '.github/workflows/not-a-file' }]), + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + [], + ); +}); + +test('immutable tree fallback rejects malformed, truncated, duplicate, and non-tree evidence', async () => { + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + const commitUrl = `${API}/repos/${REPO}/git/commits/${SHA_A}`; + const rootUrl = `${API}/repos/${REPO}/git/trees/${ROOT_TREE_SHA}`; + const githubUrl = `${API}/repos/${REPO}/git/trees/${GITHUB_TREE_SHA}`; + const workflowsUrl = `${API}/repos/${REPO}/git/trees/${WORKFLOW_TREE_SHA}`; + + await assert.rejects( + () => listProtectedWorkflowPaths({ + fetchImpl: async (url) => { + if (url === contentsUrl) return response(404, {}); + if (url === commitUrl) return response(200, { tree: { sha: 'bad' } }); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + /valid tree SHA/, + ); + + await assert.rejects( + () => listProtectedWorkflowPaths({ + fetchImpl: async (url) => { + if (url === contentsUrl) return response(404, {}); + if (url === commitUrl) return response(200, { tree: { sha: ROOT_TREE_SHA } }); + if (url === rootUrl) return response(200, { tree: [], truncated: true }); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + /incomplete or invalid/, + ); + + assert.deepEqual( + await listProtectedWorkflowPaths({ + fetchImpl: async (url) => { + if (url === contentsUrl) return response(404, {}); + if (url === commitUrl) return response(200, { tree: { sha: ROOT_TREE_SHA } }); + if (url === rootUrl) return response(200, { tree: [], truncated: false }); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + [], + ); + + for (const rootTree of [ + [ + { path: '.github', type: 'tree', sha: GITHUB_TREE_SHA }, + { path: '.github', type: 'tree', sha: GITHUB_TREE_SHA }, + ], + [{ path: '.github', type: 'blob', sha: GITHUB_TREE_SHA }], + ]) { + await assert.rejects( + () => listProtectedWorkflowPaths({ + fetchImpl: async (url) => { + if (url === contentsUrl) return response(404, {}); + if (url === commitUrl) return response(200, { tree: { sha: ROOT_TREE_SHA } }); + if (url === rootUrl) return response(200, { tree: rootTree, truncated: false }); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + /duplicate path|\.github entry is not a tree/, + ); + } + + await assert.rejects( + () => listProtectedWorkflowPaths({ + fetchImpl: async (url) => { + if (url === contentsUrl) return response(404, {}); + if (url === commitUrl) return response(200, { tree: { sha: ROOT_TREE_SHA } }); + if (url === rootUrl) return response(200, { tree: [{ path: '.github', type: 'tree', sha: GITHUB_TREE_SHA }], truncated: false }); + if (url === githubUrl) return response(200, { tree: [{ path: 'workflows', type: 'blob', sha: WORKFLOW_TREE_SHA }], truncated: false }); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + /workflows entry is not a tree/, + ); + + await assert.rejects( + () => listProtectedWorkflowPaths({ + fetchImpl: async (url) => { + if (url === contentsUrl) return response(404, {}); + if (url === commitUrl) return response(200, { tree: { sha: ROOT_TREE_SHA } }); + if (url === rootUrl) return response(200, { tree: [{ path: '.github', type: 'tree', sha: GITHUB_TREE_SHA }], truncated: false }); + if (url === githubUrl) return response(200, { tree: [{ path: 'workflows', type: 'tree', sha: WORKFLOW_TREE_SHA }], truncated: false }); + if (url === workflowsUrl) return response(200, { tree: [{ path: 'nested/bad.yml', type: 'blob', sha: 'f'.repeat(40) }], truncated: false }); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + sha: SHA_A, + }), + /non-canonical/, + ); +}); + +test('classification and audit argument validation cover inactive-present and malformed identities', async () => { + assert.throws(() => classifyWorkflows({}, []), /must be arrays/); + assert.throws(() => classifyWorkflows([{ id: 1, path: null, state: 'active' }], []), /invalid identity/); + assert.throws(() => classifyWorkflows([{ id: 0, path: '.github/workflows/a.yml', state: 'active' }], []), /invalid identity/); + const [inactivePresent] = classifyWorkflows( + [workflowIdentity('disabled_manually', '.github/workflows/present.yml', 7)], + ['.github/workflows/present.yml'], + ); + assert.equal(inactivePresent.classification, 'present_inactive'); + + await assert.rejects( + () => auditWorkflowRegistry({ repository: REPO, branch: '', fetchImpl: async () => response(500, {}) }), + /branch must be/, + ); + await assert.rejects( + () => auditWorkflowRegistry({ repository: REPO, branch: 'x'.repeat(256), fetchImpl: async () => response(500, {}) }), + /branch must be/, + ); + await assert.rejects( + () => auditWorkflowRegistry({ repository: REPO, now: null, fetchImpl: async () => response(500, {}) }), + /now must be/, + ); + + assert.deepEqual(parseArgs([], { GITHUB_REPOSITORY: REPO }), { repository: REPO, branch: 'develop', preservePaths: [] }); + assert.throws(() => parseArgs(['--repo'], {}), /owner\/name/); + assert.throws(() => parseArgs(['--branch'], { GITHUB_REPOSITORY: REPO }), /branch is required/); + assert.throws(() => parseArgs(['--preserve-path'], { GITHUB_REPOSITORY: REPO }), /canonical workflow file/); +}); + +test('cleanup planning and CLI parsing reject malformed candidate and operator evidence', () => { + assert.throws(() => buildWorkflowCleanupPlan({ evidence: null, expectedSha: SHA_A }), /audit evidence/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence({ unresolved_count: '0' }), expectedSha: SHA_A }), /unresolved workflow identities/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence({ classifications: null }), expectedSha: SHA_A }), /classifications/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence(), expectedSha: SHA_A, reviewedWorkflowIds: null }), /reviewed workflow ids/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence({ classifications: [activeOrphan(0)] }), expectedSha: SHA_A }), /invalid workflow id/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence({ classifications: [activeOrphan(), activeOrphan()] }), expectedSha: SHA_A }), /duplicate workflow id/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence({ classifications: [{ ...activeOrphan(), state: 'disabled_manually' }] }), expectedSha: SHA_A }), /is not active/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence({ classifications: [activeOrphan(11, 'dynamic/not-canonical')] }), expectedSha: SHA_A }), /non-canonical/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence(), expectedSha: SHA_A, reviewedWorkflowIds: ['01'] }), /positive safe integer/); + assert.throws(() => buildWorkflowCleanupPlan({ evidence: cleanupEvidence(), expectedSha: SHA_A, reviewedWorkflowIds: ['99999999999999999999'] }), /positive safe integer/); + + assert.deepEqual(parseCleanupArgs([], { GITHUB_REPOSITORY: REPO }), { + repository: REPO, + branch: 'develop', + expectedSha: '', + preservePaths: [], + reviewedWorkflowIds: [], + apply: false, + }); + assert.throws(() => parseCleanupArgs(['--branch'], { GITHUB_REPOSITORY: REPO }), /--branch requires a value/); + assert.throws(() => parseCleanupArgs(['--branch', 'x'.repeat(256)], { GITHUB_REPOSITORY: REPO }), /branch must be/); + assert.throws(() => parseCleanupArgs(['--preserve-path', 'dynamic/nope'], { GITHUB_REPOSITORY: REPO }), /canonical immediate workflow/); +}); + +test('apply cleanup validates dependencies and exact target identities before network traffic', async () => { + const base = { + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + }; + await assert.rejects(() => applyWorkflowCleanupPlan({ ...base, fetchImpl: null }), /fetchImpl/); + await assert.rejects(() => applyWorkflowCleanupPlan({ ...base, fetchImpl: async () => response(500, {}), sleepImpl: null }), /sleepImpl/); + await assert.rejects(() => applyWorkflowCleanupPlan({ ...base, fetchImpl: async () => response(500, {}), targets: [] }), /non-empty array/); + await assert.rejects(() => applyWorkflowCleanupPlan({ ...base, fetchImpl: async () => response(500, {}), targets: [{ workflow_id: 0, path: TARGET_PATH }] }), /invalid workflow id/); + await assert.rejects(() => applyWorkflowCleanupPlan({ ...base, fetchImpl: async () => response(500, {}), targets: [{ workflow_id: 11, path: 'dynamic/nope' }] }), /invalid workflow path/); + await assert.rejects(() => applyWorkflowCleanupPlan({ + ...base, + fetchImpl: async () => response(500, {}), + targets: [{ workflow_id: 11, path: TARGET_PATH }, { workflow_id: 11, path: TARGET_PATH }], + }), /duplicate workflow ids/); +}); + +test('cleanup preflight adopts concurrent manual disable but rejects unexpected states', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + const alreadyDisabled = await applyWorkflowCleanupPlan({ + fetchImpl: async (url) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === workflowUrl) return response(200, workflowIdentity('disabled_manually')); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + sleepImpl: async () => {}, + }); + assert.equal(alreadyDisabled.disabled.length, 0); + assert.equal(alreadyDisabled.already_disabled.length, 1); + + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === workflowUrl) return response(200, workflowIdentity('disabled_inactivity')); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + sleepImpl: async () => {}, + }), + /no longer an operator-disable candidate/, + ); +}); + +test('cleanup catches identity and state races after preflight without mutation', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + for (const secondIdentity of [ + workflowIdentity('active', '.github/workflows/reused.yml'), + workflowIdentity('disabled_inactivity'), + ]) { + let workflowReads = 0; + let puts = 0; + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === workflowUrl) { + workflowReads += 1; + return response(200, workflowReads === 1 ? workflowIdentity('active') : secondIdentity); + } + if (init?.method === 'PUT') puts += 1; + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + sleepImpl: async () => {}, + }), + /workflow identity changed|no longer active/, + ); + assert.equal(puts, 0); + } +}); + +test('disable verification tolerates bounded propagation but fails closed on drift, bad state, or no transition', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + const disableUrl = `${workflowUrl}/disable`; + let disabledRequested = false; + let postDisableReads = 0; + const delays = []; + const success = await applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === disableUrl && init.method === 'PUT') { + disabledRequested = true; + return response(204); + } + if (url === workflowUrl) { + if (!disabledRequested) return response(200, workflowIdentity('active')); + postDisableReads += 1; + return response(200, workflowIdentity(postDisableReads < 3 ? 'active' : 'disabled_manually')); + } + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + sleepImpl: async (delay) => { delays.push(delay); }, + }); + assert.equal(success.disabled.length, 1); + assert.deepEqual(delays, [100, 200]); + + for (const afterPut of [ + workflowIdentity('active', '.github/workflows/reused.yml'), + workflowIdentity('disabled_inactivity'), + ]) { + let put = false; + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === disableUrl && init.method === 'PUT') { put = true; return response(204); } + if (url === workflowUrl) return response(200, put ? afterPut : workflowIdentity('active')); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + sleepImpl: async () => {}, + }), + /workflow identity changed|entered unexpected state/, + ); + } + + let put = false; + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === disableUrl && init.method === 'PUT') { put = true; return response(204); } + if (url === workflowUrl) return response(200, workflowIdentity('active')); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + sleepImpl: async () => {}, + }), + /remained active/, + ); + assert.equal(put, true); +}); + +test('disable retries exhaust bounded transient failures before returning a sanitized API error', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + const disableUrl = `${workflowUrl}/disable`; + let puts = 0; + const delays = []; + await assert.rejects( + () => applyWorkflowCleanupPlan({ + fetchImpl: async (url, init) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === workflowUrl) return response(200, workflowIdentity('active')); + if (url === disableUrl && init.method === 'PUT') { puts += 1; return response(503, { private: 'hidden' }); } + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + targets: [{ workflow_id: 11, path: TARGET_PATH }], + token: 'secret-token', + sleepImpl: async (delay) => { delays.push(delay); }, + }), + (error) => { + assert.match(error.message, /status 503/); + assert.doesNotMatch(error.message, /hidden/); + return true; + }, + ); + assert.equal(puts, 3); + assert.deepEqual(delays, [100, 200]); +}); + +test('high-level cleanup exposes dry-run evidence and validates apply requirements before audit', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowsUrl = `${API}/repos/${REPO}/actions/workflows?per_page=100`; + const contentsUrl = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + const dryRun = await cleanupWorkflowRegistry({ + fetchImpl: async (url) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === workflowsUrl) return response(200, { total_count: 1, workflows: [workflowIdentity('active', TARGET_PATH, 11)] }); + if (url === contentsUrl) return response(200, []); + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + now: () => new Date('2026-08-18T00:00:00.000Z'), + }); + assert.equal(dryRun.mode, 'dry_run'); + assert.equal(dryRun.plan.candidates.length, 1); + + let requests = 0; + await assert.rejects( + () => cleanupWorkflowRegistry({ + fetchImpl: async () => { requests += 1; return response(500, {}); }, + apiBase: API, + repository: REPO, + apply: true, + token: 'secret-token', + reviewedWorkflowIds: [11], + }), + /expected-sha is required/, + ); + await assert.rejects( + () => cleanupWorkflowRegistry({ + fetchImpl: async () => { requests += 1; return response(500, {}); }, + apiBase: API, + repository: REPO, + expectedSha: SHA_A, + apply: true, + token: 'secret-token', + reviewedWorkflowIds: [], + }), + /workflow-id is required/, + ); + assert.equal(requests, 0); +}); + +test('postcondition rejects protected-SHA drift and a selected target that reappears active', async () => { + const branchUrl = `${API}/repos/${REPO}/branches/develop`; + const workflowsUrl = `${API}/repos/${REPO}/actions/workflows?per_page=100`; + const contentsA = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_A}`; + const contentsB = `${API}/repos/${REPO}/contents/.github/workflows?ref=${SHA_B}`; + const workflowUrl = `${API}/repos/${REPO}/actions/workflows/11`; + const disableUrl = `${workflowUrl}/disable`; + + let branchReads = 0; + let disabled = false; + await assert.rejects( + () => cleanupWorkflowRegistry({ + fetchImpl: async (url, init = {}) => { + if (url === branchUrl) { + branchReads += 1; + return response(200, branchBody(branchReads <= 5 ? SHA_A : SHA_B)); + } + if (url === workflowsUrl) return response(200, { total_count: 1, workflows: [workflowIdentity(disabled ? 'disabled_manually' : 'active')] }); + if (url === contentsA || url === contentsB) return response(200, []); + if (url === workflowUrl && (init.method ?? 'GET') === 'GET') return response(200, workflowIdentity(disabled ? 'disabled_manually' : 'active')); + if (url === disableUrl && init.method === 'PUT') { disabled = true; return response(204); } + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + reviewedWorkflowIds: [11], + apply: true, + token: 'secret-token', + sleepImpl: async () => {}, + }), + /protected branch moved before cleanup postcondition audit/, + ); + + let registryReads = 0; + disabled = false; + await assert.rejects( + () => cleanupWorkflowRegistry({ + fetchImpl: async (url, init = {}) => { + if (url === branchUrl) return response(200, branchBody(SHA_A)); + if (url === workflowsUrl) { + registryReads += 1; + return response(200, { + total_count: 1, + workflows: [workflowIdentity(registryReads === 1 ? 'active' : 'active')], + }); + } + if (url === contentsA) return response(200, []); + if (url === workflowUrl && (init.method ?? 'GET') === 'GET') return response(200, workflowIdentity(disabled ? 'disabled_manually' : 'active')); + if (url === disableUrl && init.method === 'PUT') { disabled = true; return response(204); } + return response(404, {}); + }, + apiBase: API, + repository: REPO, + branch: 'develop', + expectedSha: SHA_A, + reviewedWorkflowIds: [11], + apply: true, + token: 'secret-token', + sleepImpl: async () => {}, + }), + /planned workflow remains active/, + ); +}); + +test('CLI entry points execute their main and sanitized catch paths in child processes', () => { + for (const relative of [ + '../../scripts/ci/workflow_registry_audit.mjs', + '../../scripts/ci/workflow_registry_cleanup.mjs', + ]) { + const script = fileURLToPath(new URL(relative, import.meta.url)); + const child = spawnSync(process.execPath, [script], { + env: { ...process.env, GITHUB_REPOSITORY: 'invalid-repository', GITHUB_TOKEN: '' }, + encoding: 'utf8', + }); + assert.notEqual(child.status, 0); + assert.match(child.stderr, /owner\/name|workflow registry cleanup failed/); + } +}); diff --git a/tests/unit/workflow-registry-request-timeout.test.mjs b/tests/unit/workflow-registry-request-timeout.test.mjs new file mode 100644 index 00000000..99ab49a7 --- /dev/null +++ b/tests/unit/workflow-registry-request-timeout.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { requestJson } from '../../scripts/ci/workflow_registry_audit.mjs'; + +function response(status, body) { + return { + status, + ok: status >= 200 && status < 300, + headers: { get: () => null }, + async json() { return body; }, + }; +} + +test('workflow registry GET retries use a fresh bounded AbortSignal for every request attempt', async () => { + const signals = []; + let attempts = 0; + const recovered = await requestJson({ + url: 'https://api.github.test/probe', + fetchImpl: async (_url, init) => { + signals.push(init?.signal); + attempts += 1; + return attempts < 3 ? response(503, {}) : response(200, { ok: true }); + }, + sleepImpl: async () => {}, + }); + + assert.deepEqual(recovered.data, { ok: true }); + assert.equal(signals.length, 3); + assert.equal(new Set(signals).size, 3, 'each retry must receive a fresh timeout signal'); + for (const signal of signals) { + assert.ok(signal instanceof AbortSignal, 'every GitHub GET must have a bounded abort signal'); + assert.equal(signal.aborted, false, 'fresh request signals must not be pre-aborted'); + } +});