@juangcarmona/lighthouse-sdlc 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -12
- package/completion.md +6 -4
- package/execution-environments.md +43 -0
- package/package.json +2 -1
- package/schemas/execution-environment.schema.json +12 -0
- package/skills/implement/SKILL.md +9 -8
- package/src/actions-artifact.mjs +17 -0
- package/src/completion-actions.mjs +30 -10
- package/src/completion.mjs +19 -0
- package/src/delivery-events.mjs +42 -0
- package/src/execution-environment.mjs +78 -0
- package/src/implementation-runtime.mjs +56 -32
- package/src/implementation.mjs +31 -19
- package/src/install-workflows.mjs +10 -7
- package/src/integration-evidence.mjs +25 -1
- package/src/issue-actions.mjs +260 -38
- package/src/opencode-execute.mjs +20 -10
- package/src/process-publication.mjs +1 -1
- package/src/proposal-review.mjs +91 -0
- package/src/proposal.mjs +22 -8
- package/src/stage-dispatch.mjs +148 -0
- package/src/stage-transition.mjs +25 -0
- package/src/worker-maintenance.mjs +83 -0
- package/src/worker-recovery.mjs +106 -0
- package/src/worker-reports.mjs +150 -0
- package/workflows/lighthouse-sdlc-apply-review.lock.yml +1335 -0
- package/workflows/lighthouse-sdlc-apply-review.md +205 -0
- package/workflows/lighthouse-sdlc-implement.lock.yml +51 -90
- package/workflows/lighthouse-sdlc-implement.md +40 -11
- package/workflows/lighthouse-sdlc-integrate.lock.yml +36 -83
- package/workflows/lighthouse-sdlc-integrate.md +25 -2
- package/workflows/lighthouse-sdlc-propose.lock.yml +44 -83
- package/workflows/lighthouse-sdlc-propose.md +33 -4
- package/workflows/lighthouse-sdlc-refine.lock.yml +45 -83
- package/workflows/lighthouse-sdlc-refine.md +32 -2
- package/workflows/lighthouse-sdlc-review.lock.yml +36 -83
- package/workflows/lighthouse-sdlc-review.md +25 -2
- package/workflows/lighthouse-sdlc-router.yml +70 -62
- package/workflows/lighthouse-worker-maintenance.yml +126 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { chmod, mkdir, writeFile, readdir, lstat, copyFile } from 'node:fs/promises';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
3
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
4
4
|
import { changesDigest } from './implementation.mjs';
|
|
5
|
+
import { startEnvironment, environmentArgs, environmentReceipt, redactEnvironmentLog } from './execution-environment.mjs';
|
|
5
6
|
|
|
6
7
|
export async function writableTree(root) {
|
|
7
8
|
await chmod(root, 0o777);
|
|
@@ -12,6 +13,23 @@ export async function writableTree(root) {
|
|
|
12
13
|
else throw Error('Workspace must contain regular files only');
|
|
13
14
|
}
|
|
14
15
|
}
|
|
16
|
+
export async function prepareWorkspace(root, config) {
|
|
17
|
+
await writableTree(root);
|
|
18
|
+
// npm workspaces chmod linked executables; write permission alone is insufficient.
|
|
19
|
+
execFileSync('docker', ['run', '--rm', '--network', 'none', '--user', '0:0', '--cap-drop', 'ALL', '--cap-add', 'CHOWN', '--security-opt', 'no-new-privileges', '--read-only', '--mount', `type=bind,src=${root},dst=/workspace`, '--entrypoint', 'chown', config.image, '-R', `${config.uid}:${config.uid}`, '/workspace'], { stdio: 'inherit', timeout: 30000 });
|
|
20
|
+
}
|
|
21
|
+
export async function prepareImplementationWorkspace(workspace, config) {
|
|
22
|
+
await writableTree(workspace);
|
|
23
|
+
// Candidate-only Git metadata supports repository checks without copying checkout credentials or hooks.
|
|
24
|
+
await mkdir(join(workspace, '.git'));
|
|
25
|
+
const git = args => execFileSync('git', ['-C', workspace, '-c', 'core.hooksPath=/dev/null', ...args], {
|
|
26
|
+
env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_AUTHOR_NAME: 'Lighthouse validation', GIT_AUTHOR_EMAIL: 'validation@localhost', GIT_COMMITTER_NAME: 'Lighthouse validation', GIT_COMMITTER_EMAIL: 'validation@localhost' }, stdio: 'pipe',
|
|
27
|
+
});
|
|
28
|
+
git(['init', '--template=', '--initial-branch=validation']);
|
|
29
|
+
git(['add', '--force', '--all']);
|
|
30
|
+
git(['commit', '--allow-empty', '-m', 'Candidate snapshot; repository history is checked separately']);
|
|
31
|
+
await prepareWorkspace(workspace, config);
|
|
32
|
+
}
|
|
15
33
|
export async function copyEvidence(source, destination) {
|
|
16
34
|
let bytes = 0, count = 0;
|
|
17
35
|
async function visit(dir, relative = '') {
|
|
@@ -29,38 +47,44 @@ export async function copyEvidence(source, destination) {
|
|
|
29
47
|
try { await lstat(source); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
30
48
|
await visit(source);
|
|
31
49
|
}
|
|
32
|
-
export async function runImplementationChecks({ config, request, changes, workspace, output, name, redact, cancelled }) {
|
|
33
|
-
await
|
|
34
|
-
const receipt = { runId: request.runId, sourceRevision: request.sourceRevision, changesDigest: changesDigest(changes), checks: [] };
|
|
50
|
+
export async function runImplementationChecks({ config, request, changes, workspace, output, name, redact, cancelled, selected = null }) {
|
|
51
|
+
await prepareImplementationWorkspace(workspace, config);
|
|
52
|
+
const receipt = { runId: request.runId, sourceRevision: request.sourceRevision, changesDigest: changesDigest(changes), image: config.image, environment: environmentReceipt(selected), checks: [] };
|
|
35
53
|
const deadline = Date.now() + 600000;
|
|
36
54
|
await mkdir(output, { recursive: true });
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
const runtime = await startEnvironment({ selected, name: `${name}-validation`, uid: config.uid });
|
|
56
|
+
const originalRedact = redact;
|
|
57
|
+
redact = text => originalRedact(redactEnvironmentLog(runtime, text));
|
|
58
|
+
try {
|
|
59
|
+
for (let i = 0; i < request.implementation.policy.checks.length; i++) {
|
|
60
|
+
const command = request.implementation.policy.checks[i];
|
|
61
|
+
const remaining = deadline - Date.now();
|
|
62
|
+
if (cancelled() || remaining <= 0) { receipt.checks.push({ command, exitCode: 124 }); continue; }
|
|
63
|
+
console.log(`Host check ${i + 1}: ${command.join(' ')}`);
|
|
64
|
+
let log = '', pending = '';
|
|
65
|
+
const args = ['run', '--rm', '--init', '--name', `${name}-checks`, '--user', `${config.uid}:${config.uid}`, '--cpus', '2', '--memory', '4g', '--pids-limit', '256', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only', '--tmpfs', '/tmp:rw,exec,size=1g,mode=1777', '--env', 'HOME=/tmp', '--env', 'CI=true', '--env', 'PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright', ...environmentArgs(runtime), '--mount', `type=bind,src=${workspace},dst=/workspace`, '--workdir', '/workspace', '--entrypoint', command[0], config.image, ...command.slice(1)];
|
|
66
|
+
const exitCode = await new Promise(resolve => {
|
|
67
|
+
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
68
|
+
const timer = setTimeout(() => { child.kill('SIGTERM'); spawn('docker', ['rm', '-f', `${name}-checks`], { stdio: 'ignore' }); }, remaining);
|
|
69
|
+
const consume = chunk => {
|
|
70
|
+
pending += chunk;
|
|
71
|
+
let end;
|
|
72
|
+
while ((end = pending.indexOf('\n')) >= 0) {
|
|
73
|
+
const line = redact(pending.slice(0, end)); pending = pending.slice(end + 1);
|
|
74
|
+
if (log.length < 512000) { log += line + '\n'; console.log(`[Check] ${line.slice(0, 1000)}`); }
|
|
75
|
+
}
|
|
76
|
+
if (pending.length > 65536) pending = '[oversized check log line omitted]\n';
|
|
77
|
+
};
|
|
78
|
+
for (const stream of [child.stdout, child.stderr]) { stream.setEncoding('utf8'); stream.on('data', consume); }
|
|
79
|
+
child.on('error', () => { clearTimeout(timer); resolve(127); });
|
|
80
|
+
child.on('close', code => { clearTimeout(timer); resolve(code ?? 124); });
|
|
81
|
+
});
|
|
82
|
+
log += redact(pending).slice(0, 65536);
|
|
83
|
+
await writeFile(join(output, `check-${i + 1}.log`), log);
|
|
84
|
+
receipt.checks.push({ command, exitCode });
|
|
85
|
+
console.log(`Host check ${i + 1} exit: ${exitCode}`);
|
|
86
|
+
}
|
|
87
|
+
} finally { receipt.serviceCleanup = runtime.cleanup() !== false; }
|
|
88
|
+
if (!receipt.serviceCleanup && receipt.checks.length) { receipt.checks.at(-1).exitCode = 1; receipt.checks.at(-1).reason = 'Environment cleanup failed; operator cleanup required'; }
|
|
65
89
|
return receipt;
|
|
66
90
|
}
|
package/src/implementation.mjs
CHANGED
|
@@ -3,34 +3,38 @@ import { join, dirname } from 'node:path';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import { proposalRoot } from './proposal.mjs';
|
|
6
|
+
import { proposalRoot, changeName } from './proposal.mjs';
|
|
7
|
+
import { validateEnvironment } from './execution-environment.mjs';
|
|
7
8
|
|
|
8
9
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
9
10
|
const safePath = path => typeof path === 'string' && path.length < 300 && !path.startsWith('/') && !path.includes('\\') && !path.split('/').some(p => !p || p === '.' || p === '..' || p === '.git');
|
|
10
|
-
const protectedPath = path => /^(?:\.lighthouse|\.agents|\.product)(?:\/|$)/.test(path) || /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519)$/.test(path) || path.startsWith('.github/');
|
|
11
|
+
const protectedPath = path => /^(?:\.lighthouse|\.agents|\.product|\.git|\.opencode)(?:\/|$)/.test(path) || /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519)$/.test(path) || path.startsWith('.github/') || /(?:^|\/)(?:AGENTS|CLAUDE)\.md$/.test(path);
|
|
11
12
|
export const changesDigest = changes => hash(JSON.stringify(changes));
|
|
12
13
|
export function validateImplementationPolicy(policy, issue) {
|
|
13
|
-
if (!policy || Object.keys(policy).filter(k =>
|
|
14
|
-
if (!Array.isArray(policy.paths) || !policy.paths.length || policy.paths.length > 40 || policy.paths.some(p => !safePath(p.replace(/\/$/, '')) || protectedPath(p) || p.startsWith('openspec/') && p !== `${proposalRoot(issue)}/tasks.md` || p.startsWith('.github/') && !/^\.github\/workflows\/[\w-]+\.ya?ml$/.test(p))) throw Error('Implementation policy needs
|
|
14
|
+
if (!policy || Object.keys(policy).filter(k => !['seed', 'proposal'].includes(k)).sort().join(',') !== 'checks,paths,reviewRequired,scope' || typeof policy.reviewRequired !== 'boolean' || ('proposal' in policy && (!Number.isSafeInteger(policy.proposal) || policy.proposal < 1)) || typeof policy.scope !== 'string' || !policy.scope.trim() || policy.scope.length > 4000) throw Error('Invalid implementation policy');
|
|
15
|
+
if (!Array.isArray(policy.paths) || !policy.paths.length || policy.paths.length > 40 || policy.paths.some(p => p !== '*' && (!safePath(p.replace(/\/$/, '')) || protectedPath(p) || p.startsWith('openspec/') && p !== `${proposalRoot(issue)}/tasks.md` && p !== `${proposalRoot(issue)}/` || p.startsWith('.github/') && !/^\.github\/workflows\/[\w-]+\.ya?ml$/.test(p)))) throw Error('Implementation policy needs safe paths');
|
|
15
16
|
if (!Array.isArray(policy.checks) || !policy.checks.length || policy.checks.length > 12 || policy.checks.some(c => !Array.isArray(c) || !c.length || c.length > 30 || c.some(a => typeof a !== 'string' || !a || a.length > 1000 || /[\0\r\n]/.test(a)))) throw Error('Implementation policy needs bounded command argument arrays');
|
|
16
17
|
if ('seed' in policy && (!policy.seed || typeof policy.seed !== 'object' || Object.keys(policy.seed).sort().join(',') !== 'base,head' || !/^[a-f0-9]{40}$/.test(policy.seed.base ?? '') || !/^[a-f0-9]{40}$/.test(policy.seed.head ?? ''))) throw Error('Seed must identify immutable base and head commits');
|
|
17
18
|
return policy;
|
|
18
19
|
}
|
|
19
|
-
export const withinScope = (path, policy) => safePath(path) && !protectedPath(path) && policy.paths.some(p => p.endsWith('/') ? path.startsWith(p) : path === p);
|
|
20
|
+
export const withinScope = (path, policy) => safePath(path) && !protectedPath(path) && policy.paths.some(p => p === '*' || (p.endsWith('/') ? path.startsWith(p) : path === p));
|
|
20
21
|
|
|
21
22
|
export async function loadImplementation(api, issue, owner, revision, defaultBranch) {
|
|
22
|
-
const config = await api(`contents/.lighthouse/implement.json?ref=${revision}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
if (!
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const current = await api(`contents/${file.filename}?ref=${revision}`);
|
|
31
|
-
if (current.sha !== file.sha) throw Error('Approved proposal changed; review a new proposal before implementation');
|
|
23
|
+
const config = await api(`contents/.lighthouse/implement.json?ref=${revision}`, 'GET', undefined, true);
|
|
24
|
+
const configured = config ? JSON.parse(Buffer.from(config.content, 'base64').toString()) : {};
|
|
25
|
+
let policy = configured[issue] ?? configured.defaults;
|
|
26
|
+
if (!policy) {
|
|
27
|
+
const manifest = await api(`contents/package.json?ref=${revision}`, 'GET', undefined, true);
|
|
28
|
+
const pkg = manifest ? JSON.parse(Buffer.from(manifest.content, 'base64').toString()) : {};
|
|
29
|
+
if (!pkg.scripts?.verify) throw Error('Configure repository implementation checks in .lighthouse/implement.json defaults, or provide npm run verify');
|
|
30
|
+
policy = { scope: 'Implement only this issue and its acceptance criteria.', paths: ['*'], checks: [['npm', 'ci', '--ignore-scripts'], ['npm', 'run', 'verify']], reviewRequired: false };
|
|
32
31
|
}
|
|
33
|
-
|
|
32
|
+
const planCheck = ['npx', '--yes', '--package=@fission-ai/openspec@1.15.0', 'openspec', 'validate', changeName(issue), '--strict', '--no-interactive'];
|
|
33
|
+
policy = validateImplementationPolicy(policy, issue);
|
|
34
|
+
policy = { ...policy, checks: [...policy.checks, planCheck] };
|
|
35
|
+
const environmentFile = await api(`contents/.lighthouse/environment.json?ref=${revision}`, 'GET', undefined, true);
|
|
36
|
+
const environment = environmentFile ? validateEnvironment(JSON.parse(Buffer.from(environmentFile.content, 'base64').toString())) : null;
|
|
37
|
+
return { policy, proposal: { root: proposalRoot(issue) }, ...(environment ? { environment } : {}) };
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// A reviewed immutable seed recovers a failed attempt without updating its branch.
|
|
@@ -112,7 +116,7 @@ export function validateChanges(changes, policy) {
|
|
|
112
116
|
seen.add(file.path);
|
|
113
117
|
const buffer = Buffer.from(file.content ?? '', 'base64'); bytes += buffer.length;
|
|
114
118
|
if (buffer.length > 4 * 1024 * 1024 || bytes > 16 * 1024 * 1024) throw Error('Implementation exceeds byte bounds');
|
|
115
|
-
if (/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{15,}|-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----/.test(buffer.toString())) throw Error(
|
|
119
|
+
if (/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{15,}|-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----/.test(buffer.toString())) throw Error(`Credential-shaped content rejected in ${file.path}`);
|
|
116
120
|
}
|
|
117
121
|
return changes;
|
|
118
122
|
}
|
|
@@ -124,7 +128,11 @@ export async function materializeCandidate(before, changes, destination) {
|
|
|
124
128
|
export function validateCheckReceipt(receipt, request, changes) {
|
|
125
129
|
if (receipt?.runId !== request.runId || receipt.sourceRevision !== request.sourceRevision || receipt.changesDigest !== changesDigest(changes) || !Array.isArray(receipt.checks) || receipt.checks.length !== request.implementation.policy.checks.length) throw Error('Implementation check receipt identity mismatch');
|
|
126
130
|
for (let i = 0; i < receipt.checks.length; i++) if (JSON.stringify(receipt.checks[i].command) !== JSON.stringify(request.implementation.policy.checks[i]) || !Number.isInteger(receipt.checks[i].exitCode)) throw Error('Implementation check receipt command mismatch');
|
|
127
|
-
|
|
131
|
+
if (request.implementation.environment) {
|
|
132
|
+
const declaration = validateEnvironment(request.implementation.environment);
|
|
133
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(receipt.image ?? '') || receipt.environment?.image !== receipt.image || receipt.environment?.profile !== declaration.profile || !Array.isArray(receipt.environment.services) || receipt.environment.services.length !== declaration.services.length || receipt.environment.services.some(s => s.type !== 'postgresql' || !/^sha256:[a-f0-9]{64}$/.test(s.image ?? ''))) throw Error('Implementation environment receipt mismatch');
|
|
134
|
+
}
|
|
135
|
+
return receipt.serviceCleanup !== false && receipt.checks.every(c => c.exitCode === 0);
|
|
128
136
|
}
|
|
129
137
|
export async function createImplementationPullRequest(api, snapshot, changes, body, runUrl, draft, beforeWrite) {
|
|
130
138
|
validateChanges(changes, snapshot.implementation.policy);
|
|
@@ -137,7 +145,11 @@ export async function createImplementationPullRequest(api, snapshot, changes, bo
|
|
|
137
145
|
const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: entries });
|
|
138
146
|
const created = await api('git/commits', 'POST', { message: `feat: implement issue #${snapshot.issue}`, tree: tree.sha, parents: [snapshot.sourceRevision] });
|
|
139
147
|
await beforeWrite();
|
|
148
|
+
if (snapshot.implementationReview) {
|
|
149
|
+
await api(`git/refs/heads/${snapshot.implementationReview.branch}`, 'PATCH', { sha: created.sha, force: false });
|
|
150
|
+
return { number: snapshot.implementationReview.number, html_url: snapshot.implementationReview.url, head: { sha: created.sha, ref: snapshot.implementationReview.branch } };
|
|
151
|
+
}
|
|
140
152
|
const branch = `lighthouse/implement-${snapshot.issue}-${snapshot.runId.slice(0, 12)}`;
|
|
141
153
|
await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: created.sha });
|
|
142
|
-
return api('pulls', 'POST', { title:
|
|
154
|
+
return api('pulls', 'POST', { title: snapshot.title.slice(0, 180), head: branch, base: snapshot.defaultBranch, draft, body: `${body}\n\nCloses #${snapshot.issue}. Planning artifacts and implementation are reviewed together in this PR.\n\n[Checks, screenshots and retrospective](${runUrl}). Comment here to request changes, or approve when ready.\n\n<!-- lighthouse-implementation:${snapshot.runId} -->` });
|
|
143
155
|
}
|
|
@@ -3,18 +3,21 @@ import { resolve, join, dirname } from 'node:path';
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
|
|
5
5
|
const [target, revision, ...flags] = process.argv.slice(2);
|
|
6
|
-
if (!target || !/^[a-f0-9]{40}$/.test(revision ?? '') || flags.some(x => !['--apply', '--replace-reviewed', '--with-propose', '--with-implement', '--with-completion'].includes(x))) throw Error('Usage: install-workflows <repository> <immutable-lighthouse-commit> [--apply] [--replace-reviewed] [--with-propose] [--with-implement] [--with-completion]');
|
|
6
|
+
if (!target || !/^[a-f0-9]{40}$/.test(revision ?? '') || flags.some(x => !['--apply', '--replace-reviewed', '--with-propose', '--with-implement', '--with-completion', '--with-maintenance'].includes(x))) throw Error('Usage: install-workflows <repository> <immutable-lighthouse-commit> [--apply] [--replace-reviewed] [--with-propose] [--with-implement] [--with-completion] [--with-maintenance]');
|
|
7
7
|
const apply = flags.includes('--apply'), replace = flags.includes('--replace-reviewed');
|
|
8
8
|
const completion = flags.includes('--with-completion');
|
|
9
9
|
const implement = flags.includes('--with-implement') || completion;
|
|
10
10
|
const propose = flags.includes('--with-propose') || implement;
|
|
11
|
-
const files = ['lighthouse-sdlc-router.yml', 'lighthouse-sdlc-refine.md', 'shared/lighthouse-opencode.md', ...(propose ? ['lighthouse-sdlc-propose.md'] : []), ...(implement ? ['lighthouse-sdlc-implement.md'] : []), ...(completion ? ['lighthouse-sdlc-integrate.md', 'lighthouse-sdlc-review.md'] : [])];
|
|
11
|
+
const files = [...(flags.includes('--with-maintenance') ? ['lighthouse-worker-maintenance.yml'] : []), 'lighthouse-sdlc-router.yml', 'lighthouse-sdlc-refine.md', 'shared/lighthouse-opencode.md', ...(propose ? ['lighthouse-sdlc-propose.md', 'lighthouse-sdlc-apply-review.md'] : []), ...(implement ? ['lighthouse-sdlc-implement.md'] : []), ...(completion ? ['lighthouse-sdlc-integrate.md', 'lighthouse-sdlc-review.md'] : [])];
|
|
12
12
|
const root = resolve(target), planned = [];
|
|
13
13
|
for (const name of files) {
|
|
14
14
|
let content = (await readFile(new URL(`../workflows/${name}`, import.meta.url), 'utf8')).replaceAll('__LIGHTHOUSE_REF__', revision);
|
|
15
|
-
if (name === 'lighthouse-sdlc-router.yml'
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
if (name === 'lighthouse-sdlc-router.yml') {
|
|
16
|
+
const enabled = ['refine', ...(propose ? ['propose'] : []), ...(implement ? ['implement'] : []), ...(completion ? ['integrate', 'review'] : [])];
|
|
17
|
+
content = content.replace('LIGHTHOUSE_ENABLED_STAGES: refine,propose,implement,integrate,review', `LIGHTHOUSE_ENABLED_STAGES: ${enabled.join(',')}`).replace('options: [refine, propose, implement, integrate, review]', `options: [${enabled.join(', ')}]`);
|
|
18
|
+
if (!implement) content = content.replace('LIGHTHOUSE_AUTO_IMPLEMENT: true', 'LIGHTHOUSE_AUTO_IMPLEMENT: false');
|
|
19
|
+
if (!completion) content = content.replaceAll('LIGHTHOUSE_COMPLETION_ENABLED: true', 'LIGHTHOUSE_COMPLETION_ENABLED: false');
|
|
20
|
+
}
|
|
18
21
|
const path = join(root, '.github/workflows', name);
|
|
19
22
|
const exists = await access(path).then(() => true, () => false);
|
|
20
23
|
if (exists && await readFile(path, 'utf8') !== content && !replace) throw Error(`Existing workflow differs: ${path}; review the diff before --replace-reviewed`);
|
|
@@ -25,6 +28,6 @@ if (apply) {
|
|
|
25
28
|
const compiler = execFileSync('gh', ['aw', '--version'], { encoding: 'utf8' }).trim();
|
|
26
29
|
if (!/\bv?0\.93\.2\b/.test(compiler)) throw Error(`gh-aw v0.93.2 is required; found ${compiler}`);
|
|
27
30
|
for (const { path, content } of planned) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, content); }
|
|
28
|
-
for (const stage of completion ? ['refine', 'propose', 'implement', 'integrate', 'review'] : implement ? ['refine', 'propose', 'implement'] : propose ? ['refine', 'propose'] : ['refine']) execFileSync('gh', ['aw', 'compile', `lighthouse-sdlc-${stage}`], { cwd: root, stdio: 'inherit' });
|
|
31
|
+
for (const stage of completion ? ['refine', 'propose', 'implement', 'integrate', 'review', 'apply-review'] : implement ? ['refine', 'propose', 'implement', 'apply-review'] : propose ? ['refine', 'propose', 'apply-review'] : ['refine']) execFileSync('gh', ['aw', 'compile', `lighthouse-sdlc-${stage}`], { cwd: root, stdio: 'inherit' });
|
|
29
32
|
}
|
|
30
|
-
console.log(`Enabled workers: ${completion ? 'refine, propose, implement, integrate, review' : implement ? 'refine, propose, implement' : propose ? 'refine, propose' : 'refine'}. Enable LIGHTHOUSE_AUTOMATION_ENABLED only after provisioning and reviewing the runner. Unselected workflows are never installed or deleted implicitly.`);
|
|
33
|
+
console.log(`Enabled workers: ${completion ? 'refine, propose, implement, integrate, review, apply-review' : implement ? 'refine, propose, implement, apply-review' : propose ? 'refine, propose, apply-review' : 'refine'}. Enable LIGHTHOUSE_AUTOMATION_ENABLED only after provisioning and reviewing the runner. Unselected workflows are never installed or deleted implicitly.`);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { verifyDispatchOrigin, stagePath } from './stage-dispatch.mjs';
|
|
2
|
+
import { authorizeCompletion } from './completion.mjs';
|
|
1
3
|
import { createHash } from 'node:crypto';
|
|
2
4
|
import { fingerprint, sha } from './completion.mjs';
|
|
3
5
|
import { validateEvidenceRecord, validateEvidenceIndex } from './evidence.mjs';
|
|
@@ -48,7 +50,29 @@ export async function readIntegrationEvidence(api, completion, pinned = null) {
|
|
|
48
50
|
if (!/^[1-9][0-9]*$/.test(workflow?.runId ?? '') || !/^[1-9][0-9]*$/.test(workflow?.attempt ?? '')) throw Error('Invalid retained workflow identity');
|
|
49
51
|
if (!path.includes(`/runs/${workflow.runId}/attempt-${String(workflow.attempt).padStart(4, '0')}/`)) throw Error('Retained workflow identity differs from its path');
|
|
50
52
|
const run = await api(`actions/runs/${workflow.runId}`);
|
|
51
|
-
|
|
53
|
+
let originEvent, originName;
|
|
54
|
+
if (workflow.dispatchOrigin) {
|
|
55
|
+
const origin = workflow.dispatchOrigin;
|
|
56
|
+
const parent = await api(`actions/runs/${origin.receipt.routerRunId}/attempts/${origin.receipt.routerAttempt}`);
|
|
57
|
+
verifyDispatchOrigin(origin.request, origin.receipt, parent, run, binding.repository, owner, binding.issue);
|
|
58
|
+
if (origin.request.stage !== 'integrate' || origin.request.sourceRevision !== payload.binding.base || String(origin.receipt.workerRunId) !== String(run.id)) throw Error('Retained integration dispatch mismatch');
|
|
59
|
+
originEvent = origin.request.event; originName = origin.request.eventName;
|
|
60
|
+
}
|
|
61
|
+
let manual = run.event === 'workflow_dispatch' && run.actor?.login === owner && run.triggering_actor?.login === owner && run.head_sha === payload.binding.base;
|
|
62
|
+
if (originEvent) {
|
|
63
|
+
manual = false;
|
|
64
|
+
if (originName === 'workflow_dispatch' && !originEvent.inputs?.after_run) {
|
|
65
|
+
const authorization = authorizeCompletion(originEvent, { GITHUB_EVENT_NAME: originName, GITHUB_ACTOR: owner, GITHUB_TRIGGERING_ACTOR: owner, GITHUB_REPOSITORY_OWNER: owner }, 'integrate');
|
|
66
|
+
manual = authorization.merge && authorization.issue === binding.issue && authorization.pr === binding.pr && authorization.head === binding.head;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let approved = false;
|
|
70
|
+
const approval = receipt.authorization.approval;
|
|
71
|
+
if ((['pull_request_review', 'workflow_run'].includes(originName ?? run.event) || originEvent?.inputs?.after_run) && approval?.owner === owner && approval.head === binding.head && Number.isSafeInteger(approval.id)) {
|
|
72
|
+
const review = await api(`pulls/${binding.pr}/reviews/${approval.id}`);
|
|
73
|
+
approved = review.user?.login === owner && review.state === 'APPROVED' && review.commit_id === binding.head;
|
|
74
|
+
}
|
|
75
|
+
if (run.repository?.full_name !== binding.repository || !(manual || approved) || run.path !== (workflow.dispatchOrigin ? stagePath('integrate') : '.github/workflows/lighthouse-sdlc-router.yml') || String(run.run_attempt) !== String(workflow.attempt) || run.status !== 'completed' || run.conclusion !== 'success') throw Error('Retained integration workflow is not authenticated and successful');
|
|
52
76
|
return { evidenceId: `integration:${workflow.runId}:${workflow.attempt}`, commit, path, recordSha256: digest(raw), url: `https://github.com/${binding.repository}/blob/${commit}/${prefix}report.md`, receipt, workflow: { ...workflow, conclusion: run.conclusion, actor: run.actor.login }, report: files['report.md'] };
|
|
53
77
|
}
|
|
54
78
|
if (pinned) throw Error('Previously supplied integration evidence is unavailable or stale');
|