Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d20cb19
test(ci): define Actions registry recurrence audit
seonghobae Aug 12, 2026
a57990b
test(ci): keep registry audit RED type-safe
seonghobae Aug 12, 2026
88c3f72
fix(ci): classify Actions workflow registry drift
seonghobae Aug 12, 2026
d4f700c
test(ci): reject undocumented workflow registry states
seonghobae Aug 12, 2026
914bbf3
fix(ci): fail closed on unknown workflow states
seonghobae Aug 12, 2026
6790ebb
test(ci): reject malformed workflow registry UTF-8
seonghobae Aug 12, 2026
aee3439
fix(ci): reject malformed workflow registry UTF-8
seonghobae Aug 12, 2026
f6a19a9
test(ci): fail closed on short registry pages
seonghobae Aug 12, 2026
c981650
fix(ci): reject incomplete registry pagination shape
seonghobae Aug 12, 2026
11319f5
test(ci): detect default-branch movement during registry audit
seonghobae Aug 12, 2026
371205b
fix(ci): fail closed on workflow audit branch movement
seonghobae Aug 12, 2026
7effc38
test(ci): preflight registry audit input size
seonghobae Aug 12, 2026
6ee2628
fix(ci): bound registry audit fixture reads
seonghobae Aug 12, 2026
eb467f9
test(ci): reject non-canonical workflow registry paths
seonghobae Aug 13, 2026
fbc30a5
fix(ci): keep non-canonical workflow paths unresolved
seonghobae Aug 14, 2026
504e094
test(ci): reject ambiguous reused workflow identities
seonghobae Aug 14, 2026
6b54294
fix(ci): fail closed on reused workflow identities
seonghobae Aug 14, 2026
a9f207c
test(ci): bind repair workflow ownership evidence
seonghobae Aug 14, 2026
81bccd0
test(ci): require exact repair owner evidence
seonghobae Aug 14, 2026
955e522
fix(ci): bind repair workflow exemptions to exact owners
seonghobae Aug 14, 2026
636677f
chore(ci): synchronize actions registry audit with protected main
seonghobae Aug 17, 2026
49405ed
chore(ci): synchronize workflow registry audit with current protected…
seonghobae Aug 17, 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
420 changes: 420 additions & 0 deletions scripts/audit-actions-workflow-registry.mjs

Large diffs are not rendered by default.

277 changes: 277 additions & 0 deletions src/actionsWorkflowRegistryAudit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { afterEach, describe, expect, it } from 'vitest';

const temporaryRoots: string[] = [];
const REPAIR_HEAD_SHA = '6b54294c360c77916f9956837a41cea3833adcbe';

interface WorkflowFixture {
readonly defaultBranchSha: string;
readonly observedAt: string;
readonly presentWorkflowPaths: readonly string[];
readonly ownedActiveRepairs?: readonly {
readonly path: string;
readonly prNumber: number;
readonly headSha: string;
}[];
readonly pages: readonly {
readonly page: number;
readonly perPage: number;
readonly totalCount: number;
readonly items: readonly {
readonly id: number;
readonly path: string;
readonly state: string;
}[];
}[];
}

function runAudit(fixture: WorkflowFixture) {
const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-audit-'));
temporaryRoots.push(root);
const inputPath = join(root, 'input.json');
writeFileSync(inputPath, JSON.stringify(fixture), 'utf8');

return spawnSync(
process.execPath,
[resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath],
{
cwd: resolve('.'),
encoding: 'utf8',
timeout: 10_000,
env: process.env,
},
);
}

function baseFixture(): WorkflowFixture {
return {
defaultBranchSha: 'a430b1c153702de3b6439def801732d7453b4940',
observedAt: '2026-08-12T11:42:20.000Z',
presentWorkflowPaths: [
'.github/workflows/ci.yml',
'.github/workflows/release.yml',
],
ownedActiveRepairs: [
{
path: '.github/workflows/current-once.yml',
prNumber: 279,
headSha: REPAIR_HEAD_SHA,
},
],
pages: [
{
page: 1,
perPage: 100,
totalCount: 5,
items: [
{ id: 1, path: '.github/workflows/ci.yml', state: 'active' },
{ id: 2, path: '.github/workflows/release.yml', state: 'active' },
{
id: 3,
path: '.github/workflows/historical-finalizer.yml',
state: 'active',
},
{
id: 4,
path: '.github/workflows/current-once.yml',
state: 'active',
},
{
id: 5,
path: 'dynamic/dependabot/dependabot-updates',
state: 'active',
},
],
},
],
};
}

afterEach(() => {
while (temporaryRoots.length > 0) {
rmSync(temporaryRoots.pop()!, { recursive: true, force: true });
}
});

describe('Actions workflow registry audit', () => {
it('classifies source-backed, orphaned, explicitly owned repair, and GitHub dynamic identities without name heuristics', () => {
const result = runAudit(baseFixture());

expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
const evidence = JSON.parse(result.stdout) as {
defaultBranchSha: string;
observedAt: string;
complete: boolean;
paginationReceipts: Array<{
page: number;
itemCount: number;
totalCount: number;
}>;
workflows: Array<{
id: number;
path: string;
state: string;
classification: string;
repairOwner?: { prNumber: number; headSha: string };
}>;
};
expect(evidence.defaultBranchSha).toBe(
'a430b1c153702de3b6439def801732d7453b4940',
);
expect(evidence.observedAt).toBe('2026-08-12T11:42:20.000Z');
expect(evidence.complete).toBe(true);
expect(evidence.paginationReceipts).toEqual([
{ page: 1, itemCount: 5, totalCount: 5 },
]);
expect(evidence.workflows).toEqual([
{
id: 1,
path: '.github/workflows/ci.yml',
state: 'active',
classification: 'present',
},
{
id: 2,
path: '.github/workflows/release.yml',
state: 'active',
classification: 'present',
},
{
id: 3,
path: '.github/workflows/historical-finalizer.yml',
state: 'active',
classification: 'active_orphan',
},
{
id: 4,
path: '.github/workflows/current-once.yml',
state: 'active',
classification: 'owned_active_repair',
repairOwner: { prNumber: 279, headSha: REPAIR_HEAD_SHA },
},
{
id: 5,
path: 'dynamic/dependabot/dependabot-updates',
state: 'active',
classification: 'github_dynamic',
},
]);
});

it('fails closed when pagination does not account for the advertised registry total', () => {
const fixture = baseFixture();
const result = runAudit({
...fixture,
pages: [
{
...fixture.pages[0],
totalCount: 6,
},
],
});

expect(result.status).not.toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('workflow registry pagination is incomplete');
});

it('rejects an oversized fixture before whole-file materialization', () => {
const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-audit-'));
temporaryRoots.push(root);
const inputPath = join(root, 'oversized-input.json');
const preloadPath = join(root, 'guard-read-file-sync.cjs');
writeFileSync(inputPath, Buffer.alloc(1024 * 1024 + 1, 0x20));
writeFileSync(
preloadPath,
[
"const fs = require('node:fs');",
"const { syncBuiltinESMExports } = require('node:module');",
'const originalReadFileSync = fs.readFileSync;',
'fs.readFileSync = function guardedReadFileSync(path, ...args) {',
" if (String(path) === process.env.INKSPAN_GUARDED_INPUT) throw new Error('whole-file fixture materialization reached');",
' return originalReadFileSync.call(this, path, ...args);',
'};',
'syncBuiltinESMExports();',
].join('\n'),
'utf8',
);

const nodeOptions = [
process.env.NODE_OPTIONS,
`--require=${preloadPath}`,
]
.filter((value): value is string => Boolean(value))
.join(' ');
const result = spawnSync(
process.execPath,
[resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath],
{
cwd: resolve('.'),
encoding: 'utf8',
timeout: 10_000,
env: {
...process.env,
INKSPAN_GUARDED_INPUT: inputPath,
NODE_OPTIONS: nodeOptions,
},
},
);

expect(result.status).not.toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('input size is outside the supported bound');
expect(result.stderr).not.toContain('whole-file fixture materialization reached');
});

it('does not silently treat path case or percent-encoding drift as an orphan match', () => {
const fixture = baseFixture();
const result = runAudit({
...fixture,
pages: [
{
page: 1,
perPage: 100,
totalCount: 2,
items: [
{ id: 8, path: '.github/workflows/CI.yml', state: 'active' },
{
id: 9,
path: '.github/workflows%2Frelease.yml',
state: 'active',
},
],
},
],
});

expect(result.status).toBe(0);
const workflows = (
JSON.parse(result.stdout) as {
workflows: Array<{
id: number;
path: string;
state: string;
classification: string;
}>;
}
).workflows;
expect(workflows).toEqual([
{
id: 8,
path: '.github/workflows/CI.yml',
state: 'active',
classification: 'path_mismatch',
},
{
id: 9,
path: '.github/workflows%2Frelease.yml',
state: 'active',
classification: 'unresolved_path',
},
]);
});
});
52 changes: 52 additions & 0 deletions src/actionsWorkflowRegistryAuditBranchMovement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';

describe('Actions workflow registry audit branch binding', () => {
it('fails closed when the default branch moves during snapshot collection', () => {
const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-branch-'));
try {
const inputPath = join(root, 'input.json');
writeFileSync(
inputPath,
JSON.stringify({
defaultBranchSha: 'a430b1c153702de3b6439def801732d7453b4940',
defaultBranchShaAfter: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
observedAt: '2026-08-12T11:42:20.000Z',
presentWorkflowPaths: ['.github/workflows/ci.yml'],
pages: [
{
page: 1,
perPage: 100,
totalCount: 1,
items: [
{ id: 1, path: '.github/workflows/ci.yml', state: 'active' },
],
},
],
}),
'utf8',
);

const result = spawnSync(
process.execPath,
[resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath],
{
cwd: resolve('.'),
encoding: 'utf8',
timeout: 10_000,
env: process.env,
},
);

expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('default branch moved during observation');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading
Loading