Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8ff27f2
test(automation): capture orphan workflow registry gap
seonghobae Aug 12, 2026
df74ebd
fix(automation): bind workflow registry inventory to protected tree
seonghobae Aug 12, 2026
f39da6f
test(automation): preserve unrelated GitHub config files
seonghobae Aug 12, 2026
1ecb238
fix(automation): ignore unrelated GitHub config files
seonghobae Aug 12, 2026
a313bd4
test(automation): require workflow inventory receipts
seonghobae Aug 12, 2026
0676f56
fix(automation): retain exact workflow inventory receipts
seonghobae Aug 12, 2026
6923d63
test(automation): close registry movement test
seonghobae Aug 13, 2026
ca00b45
test(automation): cover workflow registry failure boundaries
seonghobae Aug 13, 2026
e946734
fix(automation): harden workflow registry evidence parsing
seonghobae Aug 13, 2026
1b1a0cf
chore(commercial-readiness): validate workflow registry source
seonghobae Aug 13, 2026
922407d
Merge branch 'main' into fix/actions-workflow-registry-detector-v1
opencode-agent[bot] Aug 13, 2026
3f47fde
test(automation): require recurrent workflow registry evidence command
seonghobae Aug 13, 2026
3674556
feat(automation): expose workflow registry evidence command
seonghobae Aug 13, 2026
3b163e0
feat(automation): run recurrent workflow registry detector
seonghobae Aug 13, 2026
d5bb9f2
test(automation): scope exact-head assertion to PR evidence
seonghobae Aug 13, 2026
5365fb4
docs(automation): document registry command contract
seonghobae Aug 13, 2026
2aeaccb
test(automation): expose registry command test seam
seonghobae Aug 13, 2026
973a909
test(automation): prove registry command persistence
seonghobae Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/commercial-readiness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
6 changes: 3 additions & 3 deletions packages/commercial-readiness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
38 changes: 38 additions & 0 deletions packages/commercial-readiness/src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
validateCommercialReadinessPolicy,
validateGitHubSnapshot,
} from './schema.mjs';
import { collectWorkflowRegistrySnapshot } from './workflow-registry.mjs';

const COMMANDS = Object.freeze({
snapshot: {
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
180 changes: 179 additions & 1 deletion packages/commercial-readiness/src/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
},
},
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('rejects unknown commands, duplicate options, missing values, and positional arguments', () => {
for (const argv of [
['unknown'],
Expand All @@ -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-'));
Expand Down
13 changes: 11 additions & 2 deletions packages/commercial-readiness/src/exact-head-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
});
});
Loading
Loading