diff --git a/.github/workflows/commercial-readiness.yml b/.github/workflows/commercial-readiness.yml index d413a9391..2f36a3b6f 100644 --- a/.github/workflows/commercial-readiness.yml +++ b/.github/workflows/commercial-readiness.yml @@ -56,6 +56,18 @@ jobs: --generated-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --output "$EVIDENCE_DIR/github-snapshot.json" + - name: Detect active orphan Actions workflow identities + if: github.event_name != 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node packages/commercial-readiness/src/cli.mjs workflow-registry \ + --repository "$GITHUB_REPOSITORY" \ + --commit "$GITHUB_SHA" \ + --generated-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --output "$EVIDENCE_DIR/workflow-registry.json" + - name: Audit capability maturity and canonical buyer gaps env: GITHUB_TOKEN: ${{ github.token }} @@ -83,6 +95,7 @@ jobs: --output "$EVIDENCE_DIR/pr-drain-dry-run.json" - name: Upload commercial readiness evidence + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: commercial-readiness-evidence-${{ github.run_id }} diff --git a/packages/commercial-readiness/package.json b/packages/commercial-readiness/package.json index 756d5efc6..db73c14f7 100644 --- a/packages/commercial-readiness/package.json +++ b/packages/commercial-readiness/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "node --check src/cli.mjs && node --check src/github-client.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs", - "lint": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs", + "build": "node --check src/cli.mjs && node --check src/github-client.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs && node --check src/workflow-registry.mjs", + "lint": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs && node --check src/workflow-registry.mjs", "test": "node --test src/*.test.mjs", - "typecheck": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs" + "typecheck": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs && node --check src/workflow-registry.mjs" } } diff --git a/packages/commercial-readiness/src/cli.mjs b/packages/commercial-readiness/src/cli.mjs index d8c09f04d..6ff263231 100644 --- a/packages/commercial-readiness/src/cli.mjs +++ b/packages/commercial-readiness/src/cli.mjs @@ -17,6 +17,7 @@ import { validateCommercialReadinessPolicy, validateGitHubSnapshot, } from './schema.mjs'; +import { collectWorkflowRegistrySnapshot } from './workflow-registry.mjs'; const COMMANDS = Object.freeze({ snapshot: { @@ -29,6 +30,10 @@ const COMMANDS = Object.freeze({ ]), booleans: new Set(), }, + 'workflow-registry': { + values: new Set(['repository', 'output', 'commit', 'generatedAt']), + booleans: new Set(), + }, audit: { values: new Set([ 'manifest', @@ -176,6 +181,36 @@ async function commandSnapshot(options) { ); } +/** + * Collects read-only Actions workflow-registry evidence for one exact repository + * commit and persists the complete bounded snapshot as JSON at the required output. + * + * `repository`, `output`, and `commit` are required command options. Collection is + * read-only; if the persisted snapshot reports any active orphan workflow identities, + * the command throws only after writing that evidence so operators retain the receipt. + */ +export async function commandWorkflowRegistry( + options, + client = githubClientFromEnvironment(), +) { + requireOptions(options, ['repository', 'output', 'commit']); + const snapshot = await collectWorkflowRegistrySnapshot( + client, + options.repository, + options.commit, + { generatedAt: options.generatedAt ?? new Date().toISOString() }, + ); + await writeJson(options.output, snapshot); + console.log( + `workflow registry: ${snapshot.workflow_count} identity record(s), ${snapshot.active_orphans.length} active orphan(s)`, + ); + if (snapshot.active_orphans.length > 0) { + throw new Error( + `Workflow registry contains ${snapshot.active_orphans.length} active orphan identity record(s)`, + ); + } +} + async function commandAudit(options) { requireOptions(options, [ 'manifest', @@ -289,6 +324,9 @@ async function commandDrain(options) { async function main(argv = process.argv.slice(2)) { const { command, options } = parseArguments(argv); if (command === 'snapshot') return await commandSnapshot(options); + if (command === 'workflow-registry') { + return await commandWorkflowRegistry(options); + } if (command === 'audit') return await commandAudit(options); if (command === 'publish') return await commandPublish(options); if (command === 'drain') return await commandDrain(options); diff --git a/packages/commercial-readiness/src/cli.test.mjs b/packages/commercial-readiness/src/cli.test.mjs index e12904ddc..2d85dace1 100644 --- a/packages/commercial-readiness/src/cli.test.mjs +++ b/packages/commercial-readiness/src/cli.test.mjs @@ -3,7 +3,64 @@ import { mkdtemp, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; -import { parseArguments, readJsonFile } from './cli.mjs'; +import { + commandWorkflowRegistry, + parseArguments, + readJsonFile, +} from './cli.mjs'; + +const workflowCommit = 'b'.repeat(40); +const workflowTree = 'c'.repeat(40); +const workflowPath = '.github/workflows/commercial-readiness.yml'; + +function createWorkflowRegistryClient({ + treePaths = [workflowPath], + workflows = [ + { + id: 101, + name: 'Commercial Readiness', + path: workflowPath, + state: 'active', + }, + ], + workflowResponse, +} = {}) { + return { + async requestJson(path) { + if (path === '/repos/ContextualWisdomLab/life-os') { + return { default_branch: 'main' }; + } + if (path === '/repos/ContextualWisdomLab/life-os/branches/main') { + return { commit: { sha: workflowCommit } }; + } + if ( + path === + `/repos/ContextualWisdomLab/life-os/git/commits/${workflowCommit}` + ) { + return { sha: workflowCommit, tree: { sha: workflowTree } }; + } + if ( + path === + `/repos/ContextualWisdomLab/life-os/git/trees/${workflowTree}?recursive=1` + ) { + return { + truncated: false, + tree: treePaths.map((entryPath) => ({ + path: entryPath, + type: 'blob', + })), + }; + } + if ( + path === + '/repos/ContextualWisdomLab/life-os/actions/workflows?per_page=100&page=1' + ) { + return workflowResponse ?? { total_count: workflows.length, workflows }; + } + throw new Error(`Unexpected GitHub test request: ${path}`); + }, + }; +} describe('parseArguments', () => { it('parses bounded command options without interpreting values as shell syntax', () => { @@ -31,6 +88,31 @@ describe('parseArguments', () => { ); }); + it('parses the read-only workflow registry evidence command', () => { + assert.deepEqual( + parseArguments([ + 'workflow-registry', + '--repository', + 'ContextualWisdomLab/life-os', + '--commit', + 'b'.repeat(40), + '--generated-at', + '2026-08-13T11:30:00.000Z', + '--output', + 'out/workflow-registry.json', + ]), + { + command: 'workflow-registry', + options: { + repository: 'ContextualWisdomLab/life-os', + commit: 'b'.repeat(40), + generatedAt: '2026-08-13T11:30:00.000Z', + output: 'out/workflow-registry.json', + }, + }, + ); + }); + it('rejects unknown commands, duplicate options, missing values, and positional arguments', () => { for (const argv of [ ['unknown'], @@ -47,6 +129,102 @@ describe('parseArguments', () => { }); }); +describe('commandWorkflowRegistry', () => { + it('persists realistic orphan-free workflow registry evidence', async () => { + const root = await mkdtemp(join(tmpdir(), 'life-os-workflow-registry-')); + const output = join(root, 'workflow-registry.json'); + + await commandWorkflowRegistry( + { + repository: 'ContextualWisdomLab/life-os', + commit: workflowCommit, + generatedAt: '2026-08-13T11:30:00.000Z', + output, + }, + createWorkflowRegistryClient(), + ); + + const evidence = await readJsonFile(output); + assert.equal(evidence.schema, 'life-os.workflow-registry-snapshot.v1'); + assert.equal(evidence.commit_sha, workflowCommit); + assert.equal(evidence.tree_sha, workflowTree); + assert.equal(evidence.workflow_count, 1); + assert.deepEqual(evidence.active_orphans, []); + assert.deepEqual(evidence.present, [ + { + id: 101, + name: 'Commercial Readiness', + path: workflowPath, + state: 'active', + }, + ]); + }); + + it('persists active-orphan evidence before failing the command', async () => { + const root = await mkdtemp(join(tmpdir(), 'life-os-workflow-registry-')); + const output = join(root, 'workflow-registry.json'); + const orphan = { + id: 202, + name: 'Legacy repair', + path: '.github/workflows/legacy-repair.yml', + state: 'active', + }; + + await assert.rejects( + () => + commandWorkflowRegistry( + { + repository: 'ContextualWisdomLab/life-os', + commit: workflowCommit, + generatedAt: '2026-08-13T11:30:00.000Z', + output, + }, + createWorkflowRegistryClient({ treePaths: [], workflows: [orphan] }), + ), + /contains 1 active orphan identity record/, + ); + + const evidence = await readJsonFile(output); + assert.deepEqual(evidence.active_orphans, [orphan]); + assert.equal(evidence.registry_receipt.total_count, 1); + }); + + it('fails closed on incomplete registry collection without publishing a snapshot', async () => { + const root = await mkdtemp(join(tmpdir(), 'life-os-workflow-registry-')); + const output = join(root, 'workflow-registry.json'); + + await assert.rejects( + () => + commandWorkflowRegistry( + { + repository: 'ContextualWisdomLab/life-os', + commit: workflowCommit, + generatedAt: '2026-08-13T11:30:00.000Z', + output, + }, + createWorkflowRegistryClient({ + workflowResponse: { + total_count: 2, + workflows: [ + { + id: 101, + name: 'Commercial Readiness', + path: workflowPath, + state: 'active', + }, + ], + }, + }), + ), + /pagination was truncated/, + ); + await assert.rejects( + () => readJsonFile(output), + (error) => error?.code === 'ENOENT', + ); + }); +}); + describe('readJsonFile', () => { it('reads bounded regular JSON files and rejects symlinks or oversized input', async () => { const root = await mkdtemp(join(tmpdir(), 'life-os-cli-')); diff --git a/packages/commercial-readiness/src/exact-head-workflow.test.mjs b/packages/commercial-readiness/src/exact-head-workflow.test.mjs index 28e1d21b2..9fcddf1f5 100644 --- a/packages/commercial-readiness/src/exact-head-workflow.test.mjs +++ b/packages/commercial-readiness/src/exact-head-workflow.test.mjs @@ -17,10 +17,19 @@ describe('commercial readiness exact-head contract', () => { const sourceExpression = '\\$\\{\\{ github\\.event\\.pull_request\\.head\\.sha \\|\\| github\\.sha \\}\\}'; assert.match(workflow, new RegExp(`ref: ${sourceExpression}`)); + + const snapshotStart = workflow.indexOf( + 'node packages/commercial-readiness/src/cli.mjs snapshot', + ); + assert.notEqual(snapshotStart, -1); + const snapshotEnd = workflow.indexOf('\n\n', snapshotStart); + assert.notEqual(snapshotEnd, -1); + const snapshotCommand = workflow.slice(snapshotStart, snapshotEnd); + assert.match( - workflow, + snapshotCommand, new RegExp(`--commit "${sourceExpression}"`), ); - assert.doesNotMatch(workflow, /--commit "\$GITHUB_SHA"/); + assert.doesNotMatch(snapshotCommand, /--commit "\$GITHUB_SHA"/); }); }); diff --git a/packages/commercial-readiness/src/workflow-registry.mjs b/packages/commercial-readiness/src/workflow-registry.mjs new file mode 100644 index 000000000..c5f6c1154 --- /dev/null +++ b/packages/commercial-readiness/src/workflow-registry.mjs @@ -0,0 +1,297 @@ +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const SHA_PATTERN = /^[0-9a-f]{40}$/iu; +const REPOSITORY_WORKFLOW_PATH_PATTERN = + /^\.github\/workflows\/[^/%\\\u0000-\u001f\u007f]+\.ya?ml$/u; +const CONTROL_OR_ESCAPE_PATTERN = /[\\%\u0000-\u001f\u007f]/u; +const PAGE_SIZE = 100; +const MAXIMUM_PAGES = 10; + +function invalid(message) { + throw new Error(message); +} + +function requireRepository(value) { + if (typeof value !== 'string' || !REPOSITORY_PATTERN.test(value)) { + return invalid('Workflow registry repository is invalid'); + } + const [owner, repository] = value.split('/'); + if (owner === '.' || owner === '..' || repository === '.' || repository === '..') { + return invalid('Workflow registry repository is invalid'); + } + return value; +} + +function requireSha(value) { + if (typeof value !== 'string' || !SHA_PATTERN.test(value)) { + return invalid('Workflow registry commit SHA is invalid'); + } + return value.toLowerCase(); +} + +function requireGeneratedAt(value) { + if (typeof value !== 'string') { + return invalid('Workflow registry timestamp is invalid'); + } + const date = new Date(value); + if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) { + return invalid('Workflow registry timestamp is invalid'); + } + return value; +} + +function requireWorkflowPath(value) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + CONTROL_OR_ESCAPE_PATTERN.test(value) || + value.split('/').some((segment) => segment === '..' || segment === '.') + ) { + return invalid('Workflow registry path is invalid'); + } + if (value.startsWith('.github/') && !REPOSITORY_WORKFLOW_PATH_PATTERN.test(value)) { + return invalid('Workflow registry path is invalid'); + } + return value; +} + +function requireWorkflowRecord(value) { + if ( + !value || + typeof value !== 'object' || + !Number.isSafeInteger(value.id) || + value.id <= 0 || + typeof value.name !== 'string' || + value.name.length === 0 || + value.name.length > 512 || + typeof value.state !== 'string' || + value.state.length === 0 || + value.state.length > 64 + ) { + return invalid('Workflow registry identity is invalid'); + } + return Object.freeze({ + id: value.id, + name: value.name, + path: requireWorkflowPath(value.path), + state: value.state, + }); +} + +function sortById(values) { + return values.sort((left, right) => left.id - right.id); +} + +/** + * Classifies a complete Actions workflow registry against one exact repository tree. + * + * Names are deliberately non-authoritative: only exact case-sensitive repository + * paths decide whether a repository workflow is still present. Dynamic GitHub-owned + * workflow identities are retained separately rather than guessed from their names. + */ +export function classifyWorkflowRegistry({ commitSha, treePaths, workflows }) { + const commit = requireSha(commitSha); + if (!Array.isArray(treePaths) || !Array.isArray(workflows)) { + return invalid('Workflow registry evidence is invalid'); + } + + const presentPaths = new Set(); + for (const value of treePaths) { + if (typeof value !== 'string') return invalid('Workflow registry path is invalid'); + if (!value.startsWith('.github/workflows/')) continue; + const path = requireWorkflowPath(value); + if (REPOSITORY_WORKFLOW_PATH_PATTERN.test(path)) presentPaths.add(path); + } + + const seenIds = new Map(); + const present = []; + const activeOrphans = []; + const disabledOrphans = []; + const dynamic = []; + + for (const raw of workflows) { + const record = requireWorkflowRecord(raw); + const previousPath = seenIds.get(record.id); + if (previousPath !== undefined) { + return invalid('Workflow registry identity is ambiguous'); + } + seenIds.set(record.id, record.path); + + if (!REPOSITORY_WORKFLOW_PATH_PATTERN.test(record.path)) { + dynamic.push(record); + } else if (presentPaths.has(record.path)) { + present.push(record); + } else if (record.state === 'active') { + activeOrphans.push(record); + } else { + disabledOrphans.push(record); + } + } + + return Object.freeze({ + schema: 'life-os.workflow-registry-snapshot.v1', + commit_sha: commit, + workflow_count: workflows.length, + present: Object.freeze(sortById(present)), + active_orphans: Object.freeze(sortById(activeOrphans)), + disabled_orphans: Object.freeze(sortById(disabledOrphans)), + dynamic: Object.freeze(sortById(dynamic)), + }); +} + +async function collectWorkflowRegistry(client, repository) { + const workflows = []; + let expectedTotal = null; + + for (let page = 1; page <= MAXIMUM_PAGES; page += 1) { + const payload = await client.requestJson( + `/repos/${repository}/actions/workflows?per_page=${PAGE_SIZE}&page=${page}`, + ); + if ( + !payload || + !Number.isSafeInteger(payload.total_count) || + payload.total_count < 0 || + !Array.isArray(payload.workflows) || + payload.workflows.length > PAGE_SIZE + ) { + return invalid('GitHub workflow registry response is invalid'); + } + if (expectedTotal === null) expectedTotal = payload.total_count; + if (payload.total_count !== expectedTotal) { + return invalid('GitHub workflow registry changed during pagination'); + } + + workflows.push(...payload.workflows); + if (workflows.length > expectedTotal) { + return invalid('GitHub workflow registry pagination is inconsistent'); + } + if (workflows.length === expectedTotal) { + return Object.freeze({ + workflows: Object.freeze([...workflows]), + pages: page, + total_count: expectedTotal, + }); + } + if (payload.workflows.length < PAGE_SIZE) { + return invalid('GitHub workflow registry pagination was truncated'); + } + } + + return invalid('GitHub workflow registry pagination exceeded the page limit'); +} + +/** + * Extracts validated repository-owned workflow YAML paths from one complete Git tree. + * + * Returns exact case-sensitive `.github/workflows/*.yml|yaml` blob paths. Malformed, + * truncated, or unsafe workflow-shaped tree evidence fails closed; unrelated tree + * entries are ignored. + */ +function workflowPathsFromTree(payload) { + if (!payload || payload.truncated !== false || !Array.isArray(payload.tree)) { + return invalid('GitHub workflow tree was truncated or invalid'); + } + const paths = []; + for (const entry of payload.tree) { + if (!entry || entry.type !== 'blob' || typeof entry.path !== 'string') continue; + if (entry.path.startsWith('.github/workflows/')) { + requireWorkflowPath(entry.path); + } + if (REPOSITORY_WORKFLOW_PATH_PATTERN.test(entry.path)) paths.push(entry.path); + } + return paths; +} + +/** + * Reads and validates the exact commit SHA currently named by a default branch. + * + * Returns a normalized 40-character hexadecimal SHA. Missing or malformed GitHub + * branch evidence fails closed through the shared SHA validator. + */ +async function readDefaultBranchHead(client, repository, defaultBranch) { + const payload = await client.requestJson( + `/repos/${repository}/branches/${encodeURIComponent(defaultBranch)}`, + ); + return requireSha(payload?.commit?.sha); +} + +/** + * Reads the Git tree SHA bound to an exact commit and verifies commit identity first. + * + * Returns a normalized 40-character hexadecimal tree SHA. Malformed responses or a + * response whose commit SHA differs from the requested immutable commit fail closed. + */ +async function readCommitTreeSha(client, repository, commitSha) { + const payload = await client.requestJson( + `/repos/${repository}/git/commits/${commitSha}`, + ); + if (requireSha(payload?.sha) !== commitSha) { + return invalid('GitHub workflow commit evidence is inconsistent'); + } + return requireSha(payload?.tree?.sha); +} + +/** + * Builds read-only, pagination-complete Actions registry evidence for one unchanged + * protected default-branch head and its exact Git tree. Any branch movement, + * incomplete tree, or incomplete registry response fails closed so an orphan + * workflow cannot disappear by omission. + */ +export async function collectWorkflowRegistrySnapshot( + client, + repositoryValue, + expectedCommitSha, + { generatedAt = new Date().toISOString() } = {}, +) { + if (!client || typeof client.requestJson !== 'function') { + return invalid('GitHub workflow registry client is invalid'); + } + const repository = requireRepository(repositoryValue); + const expected = requireSha(expectedCommitSha); + const evidenceTimestamp = requireGeneratedAt(generatedAt); + const metadata = await client.requestJson(`/repos/${repository}`); + const defaultBranch = metadata?.default_branch; + if ( + typeof defaultBranch !== 'string' || + defaultBranch.length === 0 || + defaultBranch.length > 255 || + defaultBranch === '.' || + defaultBranch === '..' || + defaultBranch.includes('/') || + CONTROL_OR_ESCAPE_PATTERN.test(defaultBranch) + ) { + return invalid('GitHub default branch is invalid'); + } + + const initialHead = await readDefaultBranchHead(client, repository, defaultBranch); + if (initialHead !== expected) { + return invalid('Protected default branch moved before workflow inventory'); + } + + const treeSha = await readCommitTreeSha(client, repository, expected); + const treePayload = await client.requestJson( + `/repos/${repository}/git/trees/${treeSha}?recursive=1`, + ); + const treePaths = workflowPathsFromTree(treePayload); + const registry = await collectWorkflowRegistry(client, repository); + + const finalHead = await readDefaultBranchHead(client, repository, defaultBranch); + if (finalHead !== expected) { + return invalid('Protected default branch moved during workflow inventory'); + } + + const classified = classifyWorkflowRegistry({ + commitSha: expected, + treePaths, + workflows: registry.workflows, + }); + return Object.freeze({ + ...classified, + tree_sha: treeSha, + generated_at: evidenceTimestamp, + registry_receipt: Object.freeze({ + pages: registry.pages, + total_count: registry.total_count, + }), + }); +} diff --git a/packages/commercial-readiness/src/workflow-registry.test.mjs b/packages/commercial-readiness/src/workflow-registry.test.mjs new file mode 100644 index 000000000..5ce905c99 --- /dev/null +++ b/packages/commercial-readiness/src/workflow-registry.test.mjs @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + classifyWorkflowRegistry, + collectWorkflowRegistrySnapshot, +} from './workflow-registry.mjs'; + +const SHA = 'f'.repeat(40); +const TREE_SHA = 'a'.repeat(40); +const GENERATED_AT = '2026-08-12T12:00:00.000Z'; +const REPOSITORY = 'ContextualWisdomLab/life-os'; + +function workflow(id, path, state = 'active', name = `workflow-${id}`) { + return { id, name, path, state }; +} + +function inventoryClient(overrides = {}) { + let branchReads = 0; + return { + async requestJson(path) { + if (overrides[path]) return overrides[path](branchReads++); + if (path === `/repos/${REPOSITORY}`) return { default_branch: 'main' }; + if (path === `/repos/${REPOSITORY}/branches/main`) return { commit: { sha: SHA } }; + if (path === `/repos/${REPOSITORY}/git/commits/${SHA}`) { + return { sha: SHA, tree: { sha: TREE_SHA } }; + } + if (path === `/repos/${REPOSITORY}/git/trees/${TREE_SHA}?recursive=1`) { + return { truncated: false, tree: [] }; + } + if (path.endsWith('per_page=100&page=1')) return { total_count: 0, workflows: [] }; + throw new Error(`unexpected ${path}`); + }, + }; +} + +test('classifies repository workflows by exact path without trusting names', () => { + const snapshot = classifyWorkflowRegistry({ + commitSha: SHA, + treePaths: [ + '.github/dependabot.yml', + '.github/workflows/ci.yml', + '.github/workflows/live-repair.yml', + ], + workflows: [ + workflow(1, '.github/workflows/ci.yml', 'active', 'Repair-looking production name'), + workflow(2, '.github/workflows/deleted-repair.yml', 'active', 'CI'), + workflow(3, '.github/workflows/old.yml', 'disabled_manually'), + workflow(4, 'dynamic/dependabot/dependabot-updates', 'active'), + workflow(5, '.github/workflows/CI.yml', 'active', 'case-confusion'), + ], + }); + + assert.equal(snapshot.schema, 'life-os.workflow-registry-snapshot.v1'); + assert.equal(snapshot.commit_sha, SHA); + assert.deepEqual(snapshot.present.map((entry) => entry.id), [1]); + assert.deepEqual(snapshot.active_orphans.map((entry) => entry.id), [2, 5]); + assert.deepEqual(snapshot.disabled_orphans.map((entry) => entry.id), [3]); + assert.deepEqual(snapshot.dynamic.map((entry) => entry.id), [4]); +}); + +test('rejects ambiguous workflow identities and unsafe repository paths', () => { + assert.throws( + () => + classifyWorkflowRegistry({ + commitSha: SHA, + treePaths: ['.github/workflows/ci.yml'], + workflows: [ + workflow(7, '.github/workflows/ci.yml'), + workflow(7, '.github/workflows/renamed.yml'), + ], + }), + /identity/i, + ); + + for (const path of [ + '.github/workflows/%2e%2e.yml', + '.github/workflows/../ci.yml', + '.github\\workflows\\ci.yml', + ]) { + assert.throws( + () => + classifyWorkflowRegistry({ + commitSha: SHA, + treePaths: [], + workflows: [workflow(9, path)], + }), + /path/i, + ); + } +}); + +test('rejects relative repository and default-branch API path segments', async () => { + const unexpectedClient = { + async requestJson(path) { + throw new Error(`unexpected request ${path}`); + }, + }; + for (const repository of [ + './life-os', + '../life-os', + 'ContextualWisdomLab/.', + 'ContextualWisdomLab/..', + ]) { + await assert.rejects( + collectWorkflowRegistrySnapshot(unexpectedClient, repository, SHA), + /repository.*invalid/i, + ); + } + + for (const defaultBranch of ['.', '..', 'feature/unsafe']) { + const client = { + async requestJson(path) { + if (path === `/repos/${REPOSITORY}`) return { default_branch: defaultBranch }; + throw new Error(`unexpected request ${path}`); + }, + }; + await assert.rejects( + collectWorkflowRegistrySnapshot(client, REPOSITORY, SHA), + /default branch.*invalid/i, + ); + } +}); + +test('paginates the complete registry and binds receipts to an unchanged default-branch tree', async () => { + const calls = []; + const client = { + async requestJson(path) { + calls.push(path); + if (path === `/repos/${REPOSITORY}`) return { default_branch: 'main' }; + if (path === `/repos/${REPOSITORY}/branches/main`) { + return { commit: { sha: SHA } }; + } + if (path === `/repos/${REPOSITORY}/git/commits/${SHA}`) { + return { sha: SHA, tree: { sha: TREE_SHA } }; + } + if (path === `/repos/${REPOSITORY}/git/trees/${TREE_SHA}?recursive=1`) { + return { + truncated: false, + tree: [ + { type: 'blob', path: '.github/dependabot.yml' }, + { type: 'blob', path: '.github/workflows/ci.yml' }, + ], + }; + } + if (path.endsWith('per_page=100&page=1')) { + return { + total_count: 101, + workflows: Array.from({ length: 100 }, (_, index) => + workflow(index + 1, `.github/workflows/deleted-${index + 1}.yml`), + ), + }; + } + if (path.endsWith('per_page=100&page=2')) { + return { total_count: 101, workflows: [workflow(101, '.github/workflows/ci.yml')] }; + } + throw new Error(`unexpected ${path}`); + }, + }; + + const result = await collectWorkflowRegistrySnapshot(client, REPOSITORY, SHA, { + generatedAt: GENERATED_AT, + }); + + assert.equal(result.commit_sha, SHA); + assert.equal(result.tree_sha, TREE_SHA); + assert.equal(result.generated_at, GENERATED_AT); + assert.deepEqual(result.registry_receipt, { pages: 2, total_count: 101 }); + assert.equal(result.workflow_count, 101); + assert.equal(result.active_orphans.length, 100); + assert.deepEqual(result.present.map((entry) => entry.id), [101]); + assert.equal(calls.filter((path) => path.includes('/actions/workflows?')).length, 2); + assert.equal(calls.at(-1), `/repos/${REPOSITORY}/branches/main`); +}); + +test('fails closed on incomplete or inconsistent workflow pagination', async () => { + const cases = [ + { + name: 'pagination truncation', + error: /pagination.*truncated/i, + pages: [ + { + total_count: 101, + workflows: Array.from({ length: 99 }, (_, index) => + workflow(index + 1, `.github/workflows/${index + 1}.yml`), + ), + }, + ], + }, + { + name: 'pagination inconsistency', + error: /pagination.*inconsistent/i, + pages: [ + { + total_count: 1, + workflows: [ + workflow(1, '.github/workflows/a.yml'), + workflow(2, '.github/workflows/b.yml'), + ], + }, + ], + }, + { + name: 'changing total_count', + error: /changed during pagination/i, + pages: [ + { + total_count: 101, + workflows: Array.from({ length: 100 }, (_, index) => + workflow(index + 1, `.github/workflows/${index + 1}.yml`), + ), + }, + { total_count: 102, workflows: [workflow(101, '.github/workflows/101.yml')] }, + ], + }, + { + name: 'malformed response', + error: /response.*invalid/i, + pages: [{ total_count: '1', workflows: [] }], + }, + ]; + + for (const scenario of cases) { + let page = 0; + const client = inventoryClient({ + [`/repos/${REPOSITORY}/actions/workflows?per_page=100&page=1`]: () => + scenario.pages[page++], + [`/repos/${REPOSITORY}/actions/workflows?per_page=100&page=2`]: () => + scenario.pages[page++], + }); + await assert.rejects( + collectWorkflowRegistrySnapshot(client, REPOSITORY, SHA), + scenario.error, + scenario.name, + ); + } + + const pageLimitClient = { + async requestJson(path) { + if (path === `/repos/${REPOSITORY}`) return { default_branch: 'main' }; + if (path === `/repos/${REPOSITORY}/branches/main`) return { commit: { sha: SHA } }; + if (path === `/repos/${REPOSITORY}/git/commits/${SHA}`) { + return { sha: SHA, tree: { sha: TREE_SHA } }; + } + if (path === `/repos/${REPOSITORY}/git/trees/${TREE_SHA}?recursive=1`) { + return { truncated: false, tree: [] }; + } + if (path.includes('/actions/workflows?')) { + return { + total_count: 1001, + workflows: Array.from({ length: 100 }, (_, index) => + workflow(index + 1, `.github/workflows/page-${path.at(-1)}-${index}.yml`), + ), + }; + } + throw new Error(`unexpected ${path}`); + }, + }; + await assert.rejects( + collectWorkflowRegistrySnapshot(pageLimitClient, REPOSITORY, SHA), + /exceeded the page limit/i, + ); +}); + +test('fails closed on tree, commit, branch, timestamp, and client evidence defects', async () => { + const treeTruncatedClient = inventoryClient({ + [`/repos/${REPOSITORY}/git/trees/${TREE_SHA}?recursive=1`]: () => ({ + truncated: true, + tree: [], + }), + }); + await assert.rejects( + collectWorkflowRegistrySnapshot(treeTruncatedClient, REPOSITORY, SHA), + /tree.*truncated/i, + ); + + const mismatchedCommitClient = inventoryClient({ + [`/repos/${REPOSITORY}/git/commits/${SHA}`]: () => ({ + sha: 'e'.repeat(40), + tree: { sha: TREE_SHA }, + }), + }); + await assert.rejects( + collectWorkflowRegistrySnapshot(mismatchedCommitClient, REPOSITORY, SHA), + /commit evidence.*inconsistent/i, + ); + + const movedBeforeClient = inventoryClient({ + [`/repos/${REPOSITORY}/branches/main`]: () => ({ commit: { sha: 'e'.repeat(40) } }), + }); + await assert.rejects( + collectWorkflowRegistrySnapshot(movedBeforeClient, REPOSITORY, SHA), + /moved before/i, + ); + + let branchReads = 0; + const movedDuringClient = { + async requestJson(path) { + if (path === `/repos/${REPOSITORY}`) return { default_branch: 'main' }; + if (path === `/repos/${REPOSITORY}/branches/main`) { + branchReads += 1; + return { commit: { sha: branchReads === 1 ? SHA : 'e'.repeat(40) } }; + } + if (path === `/repos/${REPOSITORY}/git/commits/${SHA}`) { + return { sha: SHA, tree: { sha: TREE_SHA } }; + } + if (path === `/repos/${REPOSITORY}/git/trees/${TREE_SHA}?recursive=1`) { + return { truncated: false, tree: [] }; + } + if (path.endsWith('per_page=100&page=1')) return { total_count: 0, workflows: [] }; + throw new Error(`unexpected ${path}`); + }, + }; + await assert.rejects( + collectWorkflowRegistrySnapshot(movedDuringClient, REPOSITORY, SHA), + /moved during/i, + ); + + await assert.rejects( + collectWorkflowRegistrySnapshot(inventoryClient(), REPOSITORY, SHA, { + generatedAt: 'not-an-iso-timestamp', + }), + /timestamp.*invalid/i, + ); + await assert.rejects( + collectWorkflowRegistrySnapshot({}, REPOSITORY, SHA), + /client.*invalid/i, + ); +});