@juangcarmona/lighthouse-sdlc 0.17.1 → 0.18.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.
Files changed (61) hide show
  1. package/README.md +15 -3
  2. package/capabilities.json +2 -1
  3. package/epic-delivery.md +35 -0
  4. package/github-actions.md +19 -3
  5. package/host-contract.md +2 -0
  6. package/package.json +4 -1
  7. package/schemas/product-change.schema.json +23 -4
  8. package/schemas/stage-result.schema.json +23 -1
  9. package/skills/product-change/SKILL.md +3 -3
  10. package/skills/refine/SKILL.md +3 -2
  11. package/src/completion-actions.mjs +49 -12
  12. package/src/completion.mjs +1 -1
  13. package/src/contracts.mjs +10 -0
  14. package/src/decompose.mjs +5 -2
  15. package/src/epic-dispatch.mjs +59 -0
  16. package/src/epic-github.mjs +111 -0
  17. package/src/epic-journal.mjs +104 -0
  18. package/src/epic-merge.mjs +91 -0
  19. package/src/epic-native.mjs +465 -0
  20. package/src/epic.mjs +86 -0
  21. package/src/handoff.mjs +97 -0
  22. package/src/host-contract.mjs +22 -6
  23. package/src/implementation.mjs +33 -5
  24. package/src/install-workflows.mjs +1 -1
  25. package/src/issue-actions.mjs +360 -78
  26. package/src/opencode-execute.mjs +20 -0
  27. package/src/pr-state-actions.mjs +21 -7
  28. package/src/pr-state.mjs +18 -1
  29. package/src/product-change-actions.mjs +158 -46
  30. package/src/product-change.mjs +92 -11
  31. package/src/proposal-review.mjs +16 -6
  32. package/src/proposal.mjs +13 -1
  33. package/src/stacked.mjs +14 -3
  34. package/src/stage-dispatch.mjs +66 -19
  35. package/src/stage-transition.mjs +151 -2
  36. package/src/triage.mjs +4 -2
  37. package/src/unattended-actions.mjs +39 -12
  38. package/src/unattended.mjs +1 -1
  39. package/src/worker-maintenance.mjs +15 -3
  40. package/src/worker-recovery.mjs +204 -6
  41. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +97 -11
  42. package/workflows/lighthouse-sdlc-apply-product-change.md +75 -6
  43. package/workflows/lighthouse-sdlc-apply-review.lock.yml +33 -2
  44. package/workflows/lighthouse-sdlc-apply-review.md +29 -1
  45. package/workflows/lighthouse-sdlc-epic.yml +79 -0
  46. package/workflows/lighthouse-sdlc-implement.lock.yml +38 -2
  47. package/workflows/lighthouse-sdlc-implement.md +33 -2
  48. package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -1
  49. package/workflows/lighthouse-sdlc-integrate.md +3 -0
  50. package/workflows/lighthouse-sdlc-product-change.lock.yml +39 -4
  51. package/workflows/lighthouse-sdlc-product-change.md +39 -6
  52. package/workflows/lighthouse-sdlc-propose.lock.yml +13 -1
  53. package/workflows/lighthouse-sdlc-propose.md +11 -1
  54. package/workflows/lighthouse-sdlc-refine.lock.yml +29 -2
  55. package/workflows/lighthouse-sdlc-refine.md +25 -2
  56. package/workflows/lighthouse-sdlc-review.lock.yml +4 -1
  57. package/workflows/lighthouse-sdlc-review.md +3 -0
  58. package/workflows/lighthouse-sdlc-router.yml +47 -3
  59. package/workflows/lighthouse-sdlc-triage.lock.yml +30 -3
  60. package/workflows/lighthouse-sdlc-triage.md +26 -3
  61. package/workflows/lighthouse-worker-maintenance.yml +70 -1
@@ -1,5 +1,6 @@
1
- import { digest } from './host-contract.mjs';
2
- import { issueComments, readDispatchReceipt, loadDispatch, workerFor, stagePath, recordDispatch } from './stage-dispatch.mjs';
1
+ import { digest, validateStageEnvelope } from './host-contract.mjs';
2
+ import { issueComments, readDispatchReceipt, loadDispatch, workerFor, stagePath, recordDispatch, claimDispatch, checkDispatchClaim, finishDispatch } from './stage-dispatch.mjs';
3
+ import { publishObservedStop, handoff, pendingQuestion, resultHandoffState, visibleNextAction, productApplyObservation } from './handoff.mjs';
3
4
  import { readActionsJson } from './actions-artifact.mjs';
4
5
 
5
6
  export const maintenancePath = '.github/workflows/lighthouse-worker-maintenance.yml';
@@ -21,6 +22,7 @@ export function recoveryDecision({ request, receipt, run, jobs, issue, comments,
21
22
  if (run.run_attempt !== 1) return result('uncertain', 'execution-rerun', 'Inspect all attempts; a rerun does not reclaim the original request.');
22
23
  if (String(receipt.workerRunId) !== String(run.id)) return result('uncertain', 'dispatch-unconfirmed', 'Reconcile the request with the observed execution.');
23
24
  if (receipt.status === 'waiting') return result('waiting-human', 'human-decision', 'Answer the recorded question; no automatic retry.');
25
+ if (receipt.status === 'blocked') return result('blocked', 'stage-blocked', 'Resolve the recorded blocker before requesting an authorized continuation.');
24
26
  if (receipt.status === 'completed' && run.conclusion === 'success') return result('succeeded', 'worker-completed', 'No recovery needed.');
25
27
  if (issue.state !== 'open' || issue.user?.login !== owner || issue.pull_request || issue.labels.some(l => ['future', 'needs-info', 'waiting-review', 'human-working', 'agent-working'].includes(l.name))) return result('blocked', 'issue-paused', 'Inspect the issue decision or reservation before requesting work.');
26
28
  if (request.sourceRevision !== revision || request.workflowRevision !== workflowRevision || request.taskDigest !== intent(issue, comments, owner)) return result('blocked', 'authority-changed', 'Request fresh work against the current source and owner intent.');
@@ -55,11 +57,192 @@ export async function observeDispatch(api, repository, owner, comment, issue, co
55
57
  const run = await api(`actions/runs/${receipt.workerRunId}`);
56
58
  const dispatch = await loadDispatch(api, repository, owner, issue.number, comment.id, run, readArtifact);
57
59
  const jobs = await api(`actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`);
58
- if (jobs.total_count > 100) throw Error('Execution job inventory exceeds recovery bounds');
60
+ if (!Array.isArray(jobs.jobs) || !Number.isSafeInteger(jobs.total_count) || jobs.total_count !== jobs.jobs.length || jobs.total_count > 100) throw Error('Execution job inventory is incomplete or exceeds recovery bounds');
59
61
  const repo = await api('');
60
62
  const revision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
61
63
  const workflowRevision = (await api(`commits/${encodeURIComponent(dispatch.request.ref)}`)).sha;
62
- return { ...recoveryDecision({ ...dispatch, run, jobs: jobs.jobs, issue, comments, owner, revision, workflowRevision, now }), dispatch };
64
+ const agent = jobs.jobs.find(job => job.name === 'agent');
65
+ const publishJob = jobs.jobs.find(job => job.name === 'Validate, publish and release');
66
+ const publish = publishJob?.steps?.find(step => step.name === 'Recheck authority and publish the validated result');
67
+ const prepare = jobs.jobs.find(job => job.name === 'Authorize and reserve issue');
68
+ const unpublished = publish?.conclusion === 'skipped' || publishJob?.conclusion === 'skipped';
69
+ const notStarted = !agent || agent.conclusion === 'skipped';
70
+ const activationFailed = jobs.jobs.some(job => ['pre_activation', 'activation'].includes(job.name) && ['failure', 'timed_out'].includes(job.conclusion));
71
+ const observation = ['completed', 'waiting', 'blocked'].includes(receipt.status) ? null
72
+ : workerFor(dispatch.request) === 'apply-product-change' ? productApplyObservation({ prepare: jobs.jobs.find(job => job.name === 'Recheck product approval')?.conclusion, apply: jobs.jobs.find(job => job.name === 'Apply with the pinned ProductShape provider')?.conclusion, publish: jobs.jobs.find(job => job.name === 'Recheck and publish provider output')?.conclusion })
73
+ : agent?.conclusion === 'success' && ['failure', 'timed_out', 'cancelled'].includes(publish?.conclusion) ? 'publication-unknown'
74
+ : ['failure', 'timed_out'].includes(prepare?.conclusion) && notStarted ? 'preparation-failure'
75
+ : prepare?.conclusion === 'success' && activationFailed && notStarted && unpublished ? 'activation-failure'
76
+ : unpublished && ['failure', 'timed_out'].includes(agent?.conclusion) ? 'failure'
77
+ : unpublished && agent?.conclusion === 'cancelled' ? 'cancelled'
78
+ : run.conclusion === 'cancelled' && notStarted && (unpublished || !publishJob) ? 'pre-agent-cancelled' : null;
79
+ const event = dispatch.request.event;
80
+ const answered = dispatch.request.eventName === 'issue_comment' && event?.action === 'created' && event.issue?.number === issue.number && !event.issue.pull_request && event.comment?.user?.login === owner && event.comment.user.type === 'User' && typeof event.comment.body === 'string' && !!event.comment.body.trim() && !dispatch.request.feedbackKey;
81
+ const stop = run.status === 'completed' && observation ? { run: String(run.id), attempt: String(run.run_attempt), observation, answerRetained: answered } : null;
82
+ return { ...recoveryDecision({ ...dispatch, run, jobs: jobs.jobs, issue, comments, owner, revision, workflowRevision, now }), dispatch, stop, run, jobs: jobs.jobs, revision, workflowRevision };
83
+ }
84
+
85
+ // Reporting is read-only so it can observe an active worker without acquiring its lease.
86
+ export async function observeRecovery(api, repository, owner, issueNumber, now = Date.now(), readArtifact = readActionsJson) {
87
+ const issue = await api(`issues/${issueNumber}`), comments = await issueComments(api, issueNumber);
88
+ const entries = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes('<!-- lighthouse-dispatch:')).map(comment => ({ comment, receipt: readDispatchReceipt(comment, repository, issueNumber) }));
89
+ const unattended = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes('<!-- lighthouse-unattended:')).at(-1);
90
+ if (unattended && (!entries.length || unattended.id > entries.at(-1).comment.id)) return [];
91
+ const latest = new Map();
92
+ for (const entry of entries) latest.set(entry.receipt.recovery?.rootId ?? entry.receipt.id, entry);
93
+ const observations = [];
94
+ for (const { comment } of latest.values()) {
95
+ const { dispatch, stop, run, jobs, revision, workflowRevision, ...observation } = await observeDispatch(api, repository, owner, comment, issue, comments, now, readArtifact);
96
+ if (observation.state === 'stalled' || observation.code === 'dispatch-unconfirmed') observations.push(observation);
97
+ }
98
+ return observations;
99
+ }
100
+
101
+ async function reservationEvent(api, issue) {
102
+ let latest;
103
+ for (let page = 1; page <= 10; page++) {
104
+ const events = await api(`issues/${issue}/events?per_page=100&page=${page}`);
105
+ if (!Array.isArray(events)) throw Error('Issue event inventory unavailable');
106
+ for (const event of events) if (['labeled', 'unlabeled'].includes(event.event) && event.label?.name === 'agent-working') latest = event;
107
+ if (events.length < 100) return latest;
108
+ }
109
+ throw Error('Issue event history exceeds recovery bounds');
110
+ }
111
+
112
+ // Release only a retained reservation from a completed, unpublished read-only worker.
113
+ export async function reconcileAbandonedReservation(api, repository, owner, issue, comments, observation, readArtifact = readActionsJson) {
114
+ const { dispatch, run, jobs, revision, workflowRevision } = observation;
115
+ if (!dispatch || !run || !['triage', 'refine'].includes(dispatch.request.stage) || workerFor(dispatch.request) !== dispatch.request.stage || run.status !== 'completed' || run.run_attempt !== 1 || !['failure', 'cancelled', 'timed_out'].includes(run.conclusion)) return false;
116
+ if (issue.state !== 'open' || issue.pull_request || issue.user?.login !== owner || !issue.labels.some(l => l.name === 'agent-working') || issue.labels.some(l => ['human-working', 'future'].includes(l.name)) || dispatch.request.sourceRevision !== revision || dispatch.request.workflowRevision !== workflowRevision || dispatch.request.taskDigest !== intent(issue, comments, owner)) return false;
117
+ if (comments.some(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes(`<!-- lighthouse-stop:${run.id}:${run.run_attempt} -->`))) return false;
118
+ const prepare = jobs.find(job => job.name === 'Authorize and reserve issue');
119
+ const agent = jobs.find(job => job.name === 'agent');
120
+ const publish = jobs.find(job => job.name === 'Validate, publish and release');
121
+ const step = publish?.steps?.find(item => item.name === 'Recheck authority and publish the validated result');
122
+ if (prepare?.conclusion !== 'success' || !['failure', 'timed_out', 'cancelled', 'skipped'].includes(agent?.conclusion) || !(publish?.conclusion === 'skipped' || step?.conclusion === 'skipped')) return false;
123
+ let snapshot;
124
+ try { snapshot = await readArtifact(api, repository, run.id, `lighthouse-input-${issue.number}-${run.run_attempt}`, 'request.json'); } catch { return false; }
125
+ if (snapshot?.runId !== digest(`${repository}:${issue.number}:${dispatch.request.stage}:${run.id}:${run.run_attempt}`) || snapshot.issue !== issue.number || snapshot.stage !== dispatch.request.stage || String(snapshot.workflowRunId) !== String(run.id) || snapshot.sourceRevision !== revision || snapshot.workflowRevision !== workflowRevision || snapshot.taskDigest !== dispatch.request.taskDigest || snapshot.dispatch?.commentId !== dispatch.commentId || snapshot.dispatch?.requestId !== dispatch.request.id || snapshot.dispatch?.digest !== dispatch.receipt.digest) return false;
126
+ const event = await reservationEvent(api, issue.number);
127
+ const start = Date.parse(run.run_started_at), end = Date.parse(run.updated_at), at = Date.parse(event?.created_at);
128
+ if (!Number.isSafeInteger(event?.id) || event.id < 1 || !Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(at) || at < start || at > end || event.event !== 'labeled' || event.actor?.login !== 'github-actions[bot]' || event.actor.type !== 'Bot') return false;
129
+ const claim = { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest };
130
+ await checkDispatchClaim(api, repository, issue.number, claim, run.id);
131
+ const fresh = await api(`issues/${issue.number}`), current = await issueComments(api, issue.number), repo = await api('');
132
+ if (fresh.state !== 'open' || fresh.pull_request || fresh.user?.login !== owner || !fresh.labels.some(l => l.name === 'agent-working') || fresh.labels.some(l => ['human-working', 'future'].includes(l.name)) || intent(fresh, current, owner) !== dispatch.request.taskDigest || (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha !== revision || (await api(`commits/${encodeURIComponent(dispatch.request.ref)}`)).sha !== workflowRevision) return false;
133
+ if (current.some(c => Number(c.id) > Number(dispatch.commentId) && c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes('<!-- lighthouse-dispatch:'))) return false;
134
+ if (current.some(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes(`<!-- lighthouse-stop:${run.id}:${run.run_attempt} -->`))) return false;
135
+ if ((await reservationEvent(api, issue.number))?.id !== event.id) return false;
136
+ await checkDispatchClaim(api, repository, issue.number, claim, run.id);
137
+ await api(`issues/${issue.number}/labels/agent-working`, 'DELETE');
138
+ issue.labels = fresh.labels.filter(l => l.name !== 'agent-working');
139
+ return true;
140
+ }
141
+
142
+ // A uniquely authenticated preparation failure can be recorded as failed only
143
+ // when the agent and publisher were skipped. It never authorizes a new dispatch.
144
+ export async function reconcilePreparationFailure(api, repository, owner, issueNumber, commentId, runId, readArtifact = readActionsJson) {
145
+ const run = await api(`actions/runs/${runId}`);
146
+ if (run.status !== 'completed' || !['failure', 'timed_out'].includes(run.conclusion) || run.run_attempt !== 1) return false;
147
+ const dispatch = await loadDispatch(api, repository, owner, issueNumber, commentId, run, readArtifact);
148
+ if (dispatch.receipt.workerRunId && String(dispatch.receipt.workerRunId) !== String(run.id) || !['requested', 'running', 'unknown', 'failed'].includes(dispatch.receipt.status) || dispatch.request.worker || !['triage', 'refine'].includes(dispatch.request.stage)) return false;
149
+ const inventory = await api(`actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`);
150
+ if (!Array.isArray(inventory.jobs) || inventory.total_count !== inventory.jobs.length || inventory.total_count > 100) return false;
151
+ const prepare = inventory.jobs.find(job => job.name === 'Authorize and reserve issue');
152
+ const agent = inventory.jobs.find(job => job.name === 'agent');
153
+ const publish = inventory.jobs.find(job => job.name === 'Validate, publish and release');
154
+ if (!['failure', 'timed_out'].includes(prepare?.conclusion) || agent?.conclusion !== 'skipped' || publish?.conclusion !== 'skipped') return false;
155
+ const issue = await api(`issues/${issueNumber}`), comments = await issueComments(api, issueNumber), repo = await api('');
156
+ const revision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
157
+ const workflowRevision = (await api(`commits/${encodeURIComponent(dispatch.request.ref)}`)).sha;
158
+ if (issue.state !== 'open' || issue.pull_request || issue.user?.login !== owner || issue.labels.some(label => ['agent-working', 'human-working', 'future', 'needs-info', 'waiting-review'].includes(label.name)) || dispatch.request.sourceRevision !== revision || dispatch.request.workflowRevision !== workflowRevision || intent(issue, comments, owner) !== dispatch.request.taskDigest || comments.some(comment => Number(comment.id) > Number(commentId) && comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && comment.body?.includes('<!-- lighthouse-dispatch:'))) return false;
159
+ if (!dispatch.receipt.workerRunId && !await claimDispatch(api, repository, dispatch, run.id)) return false;
160
+ await finishDispatch(api, repository, issueNumber, commentId, run.id, 'failed');
161
+ await publishObservedStop(api, { repository, issue: issueNumber, stage: dispatch.request.stage, run: String(run.id), attempt: String(run.run_attempt), sourceRevision: dispatch.request.sourceRevision, dispatch: { commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest }, observation: 'preparation-failure' });
162
+ return true;
163
+ }
164
+
165
+ // Restore only a completed, read-only stage whose authority and label still match.
166
+ export async function reconcilePublishedStage(api, repository, owner, issue, comments, observation, readArtifact = readActionsJson) {
167
+ const { dispatch, run, jobs = [], revision, workflowRevision } = observation;
168
+ if (!dispatch || !run) return 'not-applicable';
169
+ const publication = jobs.find(j => j.name === 'Validate, publish and release');
170
+ const publish = publication?.steps?.find(s => s.name === 'Recheck authority and publish the validated result');
171
+ const failedPublication = run.conclusion === 'failure' && jobs.find(j => j.name === 'agent')?.conclusion === 'success' && publish?.conclusion === 'failure';
172
+ const interrupted = failedPublication && ['running', 'unknown'].includes(dispatch.receipt.status);
173
+ const completed = (run.conclusion === 'success' || failedPublication) && ['completed', 'waiting', 'blocked'].includes(dispatch.receipt.status);
174
+ if (run.status !== 'completed' || (!completed && !interrupted) || run.run_attempt !== 1 || !['triage', 'refine'].includes(dispatch.request.stage) || dispatch.request.worker) return 'not-applicable';
175
+ const id = digest(`${repository}:${issue.number}:${dispatch.request.stage}:${run.id}:${run.run_attempt}`);
176
+ const marker = `<!-- lighthouse-actions:${id} -->`;
177
+ if (!interrupted && comments.some(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes(marker))) return 'present';
178
+ const incompatibleStage = l => ['triage', 'refine', 'propose', 'implement'].includes(l.name) && (!interrupted || l.name !== dispatch.request.stage);
179
+ if (issue.state !== 'open' || issue.pull_request || issue.user?.login !== owner || issue.labels.some(l => ['agent-working', 'human-working'].includes(l.name) || incompatibleStage(l)) || dispatch.request.sourceRevision !== revision || dispatch.request.workflowRevision !== workflowRevision || dispatch.request.taskDigest !== intent(issue, comments, owner)) return 'uncertain';
180
+ let snapshot, envelope;
181
+ try {
182
+ snapshot = await readArtifact(api, repository, run.id, `lighthouse-input-${issue.number}-${run.run_attempt}`, 'request.json');
183
+ envelope = await readArtifact(api, repository, run.id, `lighthouse-result-${issue.number}-${run.run_attempt}`, 'envelope.json');
184
+ } catch { return 'uncertain'; }
185
+ if (snapshot?.runId !== id || snapshot.issue !== issue.number || snapshot.stage !== dispatch.request.stage || String(snapshot.workflowRunId) !== String(run.id) || snapshot.sourceRevision !== revision || snapshot.workflowRevision !== workflowRevision || snapshot.taskDigest !== dispatch.request.taskDigest || snapshot.autoImplement !== (dispatch.request.autoImplement === true) || snapshot.dispatch?.requestId !== dispatch.request.id || snapshot.dispatch?.digest !== dispatch.receipt.digest || snapshot.dispatch?.commentId !== dispatch.commentId || validateStageEnvelope(envelope, snapshot).length) return 'uncertain';
186
+ const result = envelope.result;
187
+ if (!['passed', 'blocked'].includes(result.status)) return 'uncertain';
188
+ const question = result.status === 'blocked' ? pendingQuestion(result.output?.handoff?.question) : null;
189
+ const expectedStatus = result.status === 'passed' ? 'completed' : question ? 'waiting' : 'blocked';
190
+ if (!interrupted && dispatch.receipt.status !== expectedStatus) return 'uncertain';
191
+ const projection = resultHandoffState({ status: result.status, stage: snapshot.stage, question });
192
+ if (!projection.label || (!issue.labels.some(l => l.name === projection.label) && !(interrupted && issue.labels.some(l => l.name === snapshot.stage))) || issue.labels.some(l => ['ready', 'triaged', 'needs-info', 'waiting-review', 'execution-failed', 'pr-draft', 'pr-ready'].includes(l.name) && l.name !== projection.label)) return 'uncertain';
193
+ let action = question ? `Owner: ${question} Reply here.` : 'None.';
194
+ let next = question ? `The answer will be checked against this question before ${snapshot.stage} continues.` : 'Any continuation follows the configured authorization.';
195
+ if (snapshot.stage === 'refine' && result.status === 'passed') {
196
+ const paused = issue.labels.some(l => l.name === 'future');
197
+ action = !snapshot.autoImplement ? 'Owner: review the refinement and request the next authorized stage through the configured workflow.' : paused ? 'Owner: remove the future label when implementation may resume.' : 'None.';
198
+ next = !snapshot.autoImplement ? 'No automatic continuation is configured for this refinement.' : paused ? 'The workflow will recheck the authorized implementation request.' : 'The workflow will request implementation under the configured continuation policy; the owner can pause it with the future label.';
199
+ } else if (result.status === 'blocked' && !question) action = visibleNextAction(result.nextAction);
200
+ if (interrupted && snapshot.autoImplement && result.status === 'passed') { action = 'Owner: inspect the restored result and request the next authorized stage.'; next = 'The interrupted execution did not confirm an automatic continuation.'; }
201
+ const body = handoff({ status: projection.status, stage: snapshot.stage, happened: `A previous handoff was restored from retained execution evidence.\n\n${envelope.effect.body}`, action, next, details: `Outcome: ${result.status}\n\n[Workflow run](https://github.com/${repository}/actions/runs/${run.id}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n<!-- lighthouse-handoff:v1 -->\n<!-- lighthouse-stage:${snapshot.stage}:${result.status} -->${question ? '\n<!-- lighthouse-question -->' : ''}` });
202
+ await checkDispatchClaim(api, repository, issue.number, { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest }, run.id);
203
+ const fresh = await api(`issues/${issue.number}`), freshComments = await issueComments(api, issue.number);
204
+ const currentRepo = await api(''), currentRevision = (await api(`commits/${encodeURIComponent(currentRepo.default_branch)}`)).sha;
205
+ if (currentRevision !== revision || (await api(`commits/${encodeURIComponent(dispatch.request.ref)}`)).sha !== workflowRevision || fresh.state !== 'open' || fresh.user?.login !== owner || fresh.pull_request || fresh.labels.some(l => ['agent-working', 'human-working'].includes(l.name) || incompatibleStage(l)) || (!fresh.labels.some(l => l.name === projection.label) && !(interrupted && fresh.labels.some(l => l.name === snapshot.stage))) || fresh.labels.some(l => ['ready', 'triaged', 'needs-info', 'waiting-review', 'execution-failed', 'pr-draft', 'pr-ready'].includes(l.name) && l.name !== projection.label) || intent(fresh, freshComments, owner) !== dispatch.request.taskDigest) return 'uncertain';
206
+ if (freshComments.some(c => c.user?.login === 'github-actions[bot]' && Number(c.id) > Number(dispatch.commentId) && c.body?.includes('<!-- lighthouse-dispatch:'))) return 'uncertain';
207
+ if (interrupted && fresh.labels.some(l => l.name === snapshot.stage)) {
208
+ if (!fresh.labels.some(l => l.name === projection.label)) {
209
+ await checkDispatchClaim(api, repository, issue.number, { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest }, run.id);
210
+ await api(`issues/${issue.number}/labels`, 'POST', { labels: [projection.label] });
211
+ }
212
+ await checkDispatchClaim(api, repository, issue.number, { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest }, run.id);
213
+ await api(`issues/${issue.number}/labels/${snapshot.stage}`, 'DELETE');
214
+ }
215
+ if (freshComments.some(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker))) {
216
+ if (interrupted) await finishDispatch(api, repository, issue.number, dispatch.commentId, run.id, question ? 'question' : result.status);
217
+ return 'present';
218
+ }
219
+ await checkDispatchClaim(api, repository, issue.number, { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest }, run.id);
220
+ await api(`issues/${issue.number}/comments`, 'POST', { body });
221
+ if (interrupted) await finishDispatch(api, repository, issue.number, dispatch.commentId, run.id, question ? 'question' : result.status);
222
+ return 'restored';
223
+ }
224
+
225
+ // Restore an uncertain handoff only for the latest authenticated unattended attempt.
226
+ export async function reconcileUnattendedStop(api, repository, owner, issue, comments) {
227
+ if (issue.state !== 'open' || issue.pull_request || issue.user?.login !== owner) return 'not-applicable';
228
+ const relevant = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && /<!-- lighthouse-(?:unattended:|unattended-recovery:|dispatch:|actions:|stop:)/.test(c.body ?? ''));
229
+ const last = relevant.at(-1);
230
+ const heading = last?.body?.match(/^(?:## )?(?:Working|Requested|Queued) · (ff|hotfix)\b/);
231
+ const runMatch = last?.body?.match(/\[Execution\]\(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/([1-9][0-9]*)\)/);
232
+ if (!heading || !runMatch || !last.body.includes(`https://github.com/${repository}/actions/runs/${runMatch[1]}`)) return 'not-applicable';
233
+ const route = heading[1], runId = runMatch[1];
234
+ const marker = `<!-- lighthouse-unattended:${digest(JSON.stringify({ repository, issue: issue.number, route, run: runId }))} -->`;
235
+ if (!last.body.includes(marker)) return 'uncertain';
236
+ const run = await api(`actions/runs/${runId}`);
237
+ if (run?.repository?.full_name !== repository || run.path !== '.github/workflows/lighthouse-sdlc-unattended.yml' || run.event !== 'workflow_dispatch' || run.actor?.login !== owner || run.triggering_actor?.login !== owner || run.display_title !== `${route} #${issue.number} · ${owner}` || run.run_attempt !== 1) return 'uncertain';
238
+ if (run.status !== 'completed' || !['failure', 'timed_out', 'cancelled'].includes(run.conclusion)) return 'not-applicable';
239
+ const fresh = await api(`issues/${issue.number}`), latest = await issueComments(api, issue.number);
240
+ const current = latest.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && /<!-- lighthouse-(?:unattended:|unattended-recovery:|dispatch:|actions:|stop:)/.test(c.body ?? '')).at(-1);
241
+ if (fresh.state !== 'open' || fresh.user?.login !== owner || current?.id !== last.id) return 'uncertain';
242
+ const reservation = fresh.labels.some(l => l.name === 'agent-working') ? ' The agent-working reservation remains; verify ownership before releasing it.' : '';
243
+ const body = handoff({ status: 'unknown', stage: route, happened: `The unattended execution ended ${run.conclusion}, but its terminal handoff is missing. Publication and integration effects have not been confirmed.`, action: `Workflow maintainer: inspect the execution, artifacts, issue and any candidate PR before resuming.${reservation}`, next: 'Do not retry or release a reservation until the observed effects and current ownership are reconciled.', link: `[Execution](https://github.com/${repository}/actions/runs/${runId})`, details: `<!-- lighthouse-unattended-recovery:${runId}:1 -->` });
244
+ await api(`issues/${issue.number}/comments`, 'POST', { body });
245
+ return 'restored';
63
246
  }
64
247
 
65
248
  // Called again by the worker, before claiming a maintenance-created retry.
@@ -76,7 +259,7 @@ export async function verifyRecoveryOrigin(api, repository, owner, dispatch, par
76
259
  if (JSON.stringify(originalFields(r)) !== JSON.stringify(originalFields(prior.request))) throw Error('Recovery changed the original authorization');
77
260
  if (!revalidate) return;
78
261
  const jobs = await api(`actions/runs/${priorRun.id}/attempts/${priorRun.run_attempt}/jobs?per_page=100`);
79
- if (jobs.total_count > 100) throw Error('Execution job inventory exceeds recovery bounds');
262
+ if (!Array.isArray(jobs.jobs) || !Number.isSafeInteger(jobs.total_count) || jobs.total_count !== jobs.jobs.length || jobs.total_count > 100) throw Error('Execution job inventory is incomplete or exceeds recovery bounds');
80
263
  const issue = await api(`issues/${r.issue}`), comments = await issueComments(api, r.issue), repo = await api('');
81
264
  const revision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
82
265
  const workflowRevision = (await api(`commits/${encodeURIComponent(r.ref)}`)).sha;
@@ -86,15 +269,30 @@ export async function verifyRecoveryOrigin(api, repository, owner, dispatch, par
86
269
 
87
270
  export async function prepareRecovery(api, repository, owner, issueNumber, parent, enabled, now = Date.now(), readArtifact = readActionsJson) {
88
271
  const issue = await api(`issues/${issueNumber}`), comments = await issueComments(api, issueNumber);
272
+ await reconcileUnattendedStop(api, repository, owner, issue, comments);
89
273
  const entries = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.body.includes('<!-- lighthouse-dispatch:')).map(comment => ({ comment, receipt: readDispatchReceipt(comment, repository, issueNumber) }));
274
+ const unattended = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes('<!-- lighthouse-unattended:')).at(-1);
275
+ if (unattended && (!entries.length || unattended.id > entries.at(-1).comment.id)) return { observations: [] };
90
276
  const latest = new Map();
91
277
  for (const entry of entries) latest.set(entry.receipt.recovery?.rootId ?? entry.receipt.id, entry);
92
278
  const observations = [];
93
279
  const currentRoot = entries.at(-1)?.receipt.recovery?.rootId ?? entries.at(-1)?.receipt.id;
94
280
  for (const { comment } of latest.values()) {
95
281
  const observation = await observeDispatch(api, repository, owner, comment, issue, comments, now, readArtifact);
96
- const { dispatch, ...publicObservation } = observation;
282
+ const { dispatch, stop, run, jobs, revision, workflowRevision, ...publicObservation } = observation;
283
+ if (observation.rootId === currentRoot) {
284
+ if (await reconcileAbandonedReservation(api, repository, owner, issue, comments, observation, readArtifact)) Object.assign(publicObservation, { state: 'reconciled', code: 'reservation-released', nextAction: 'Follow the retained outcome before requesting further work.' });
285
+ const preparationRun = observation.observedRunId ?? run?.id;
286
+ if (preparationRun && await reconcilePreparationFailure(api, repository, owner, issueNumber, comment.id, preparationRun, readArtifact)) { observation.stop = null; Object.assign(publicObservation, { state: 'reconciled', code: 'preparation-failed', nextAction: 'Inspect the confirmed failed execution before requesting new work.' }); }
287
+ const reconciliation = await reconcilePublishedStage(api, repository, owner, issue, comments, observation, readArtifact);
288
+ if (reconciliation === 'restored' || reconciliation === 'present') {
289
+ observation.stop = null;
290
+ if (run?.conclusion === 'failure') Object.assign(publicObservation, { state: 'reconciled', code: 'handoff-restored', nextAction: 'Follow the restored handoff.' });
291
+ }
292
+ if (reconciliation === 'uncertain') Object.assign(publicObservation, { state: 'uncertain', code: 'handoff-unconfirmed', nextAction: 'Inspect the retained result and current issue state before restoring its handoff.' });
293
+ }
97
294
  observations.push(publicObservation);
295
+ if (observation.stop && observation.rootId === currentRoot) await publishObservedStop(api, { repository, issue: issueNumber, stage: dispatch.request.stage, ...observation.stop, sourceRevision: dispatch.request.sourceRevision, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest } });
98
296
  if (observation.state !== 'retryable' || enabled !== true || observation.rootId !== currentRoot) continue;
99
297
  const attempt = observation.attempt + 1;
100
298
  const request = { ...dispatch.request, id: recoveryId(repository, observation.rootId, attempt), routerRunId: String(parent.id), routerAttempt: parent.run_attempt, routerRevision: parent.head_sha, routerRef: parent.head_branch, routerEventName: parent.event, recovery: { rootId: observation.rootId, previousComment: comment.id, attempt } };
@@ -1,4 +1,4 @@
1
- # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f4d9b81a1c900e855e8ef604fe4596e5eebf87041febc68e1351da79acff567e","body_hash":"900316ff8535f6f9b38fea05138e4e8aa8ed64b3d4844ebad51810faf4bddea5","compiler_version":"v0.93.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.101"},"agent_image_runner":"[\"self-hosted\",\"linux\",\"lighthouse-workflow\"]"}
1
+ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6e978e33b557d99c929fbd67778258832f1938ec206c0624ee4cf9f5da7f88ff","body_hash":"900316ff8535f6f9b38fea05138e4e8aa8ed64b3d4844ebad51810faf4bddea5","compiler_version":"v0.93.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.101"},"agent_image_runner":"[\"self-hosted\",\"linux\",\"lighthouse-workflow\"]"}
2
2
  # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/checkout","sha":"fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09","version":"v5"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/setup-node","sha":"a0853c24544627f65ddf259abe73b1d18a591444","version":"v5"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"},{"repo":"github/gh-aw-actions/setup","sha":"d9128eb3ea40c18b77c76184eda03c0ff2608d79","version":"v0.93.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70","digest":"sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70@sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70","digest":"sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70@sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70","digest":"sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70@sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.37","digest":"sha256:9c7f242f6dec2d7d7b55d4c9e07872d59b85377c6cb3de46c915a5d1ffb91442","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.37@sha256:9c7f242f6dec2d7d7b55d4c9e07872d59b85377c6cb3de46c915a5d1ffb91442"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382","pinned_image":"ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382"},{"image":"ghcr.io/github/github-mcp-server:v2.0.2","digest":"sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796","pinned_image":"ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796"}],"mcp_servers":[{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","issue_read","list_branches","list_commits","list_issue_types","list_issues","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_issues","search_pull_requests","search_repositories"]},{"name":"safeoutputs","tools":["noop","report_missing_data","report_missing_tool"]}],"threat_detection":{"mode":"disabled"},"admission":false}
3
3
  # This file was automatically generated by gh-aw (v0.93.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
4
4
  #
@@ -112,7 +112,7 @@ jobs:
112
112
  needs:
113
113
  - pre_activation
114
114
  - prepare
115
- if: needs.pre_activation.outputs.activated == 'true' && (needs.prepare.outputs.eligible == 'true')
115
+ if: needs.pre_activation.outputs.activated == 'true' && (false)
116
116
  runs-on: ubuntu-24.04
117
117
  # Permissions for the activation job (workflow permissions default to none).
118
118
  permissions:
@@ -454,9 +454,10 @@ jobs:
454
454
  needs:
455
455
  - activation
456
456
  - apply
457
+ - finalize
457
458
  - prepare
458
459
  - publish
459
- if: (needs.prepare.outputs.eligible == 'true') && (needs.activation.outputs.daily_ai_credits_exceeded != 'true')
460
+ if: needs.activation.outputs.daily_ai_credits_exceeded != 'true'
460
461
  runs-on:
461
462
  - self-hosted
462
463
  - linux
@@ -1260,10 +1261,13 @@ jobs:
1260
1261
  GH_HOST="${GITHUB_SERVER_URL#https://}"
1261
1262
  GH_HOST="${GH_HOST#http://}"
1262
1263
  echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
1263
- - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
1264
+ - name: Load the approved source without credentials
1265
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
1264
1266
  with:
1265
- persist-credentials: false
1266
- ref: ${{ needs.prepare.outputs.head }}
1267
+ name: lighthouse-product-apply-source-${{ inputs.issue }}-${{ github.run_attempt }}
1268
+ path: ${{ runner.temp }}/product-source
1269
+ - name: Restore the approved source
1270
+ run: tar -xzf "$RUNNER_TEMP/product-source/lighthouse-product-source.tar.gz" -C "$GITHUB_WORKSPACE"
1267
1271
  - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
1268
1272
  with:
1269
1273
  path: .lighthouse-harness
@@ -1277,6 +1281,7 @@ jobs:
1277
1281
  run: |
1278
1282
  npm ci --ignore-scripts
1279
1283
  npm install --no-save --package-lock=false --ignore-scripts @prodshape/integration-openspec@0.6.6
1284
+ working-directory: .lighthouse-harness
1280
1285
  - name: Load the approval checkpoint
1281
1286
  uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
1282
1287
  with:
@@ -1294,6 +1299,13 @@ jobs:
1294
1299
  ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}/apply-result.json
1295
1300
  ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}/affected-citations.json
1296
1301
  retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '30' }}
1302
+ - name: Preserve the bounded apply proof
1303
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
1304
+ with:
1305
+ if-no-files-found: error
1306
+ name: lighthouse-product-apply-proof-${{ inputs.issue }}-${{ github.run_attempt }}
1307
+ path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}/apply-proof.json
1308
+ retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '30' }}
1297
1309
 
1298
1310
  conclusion:
1299
1311
  name: conclusion
@@ -1301,6 +1313,7 @@ jobs:
1301
1313
  - activation
1302
1314
  - agent
1303
1315
  - apply
1316
+ - finalize
1304
1317
  - prepare
1305
1318
  - publish
1306
1319
  - safe_outputs
@@ -1319,9 +1332,9 @@ jobs:
1319
1332
  cancel-in-progress: false
1320
1333
  queue: max
1321
1334
  env:
1322
- GH_AW_JOB_DISPLAY_NAMES: "{\"activation\":\"activation\",\"agent\":\"agent\",\"apply\":\"Apply with the pinned ProductShape provider\",\"conclusion\":\"conclusion\",\"pre_activation\":\"pre_activation\",\"prepare\":\"Recheck product approval\",\"publish\":\"Recheck and publish provider output\",\"safe_outputs\":\"safe_outputs\"}"
1323
- GH_AW_JOB_RESULTS: "{\"activation\":{\"result\":\"${{ needs.activation.result }}\"},\"agent\":{\"result\":\"${{ needs.agent.result }}\"},\"apply\":{\"result\":\"${{ needs.apply.result }}\"},\"prepare\":{\"result\":\"${{ needs.prepare.result }}\"},\"publish\":{\"result\":\"${{ needs.publish.result }}\"},\"safe_outputs\":{\"result\":\"${{ needs.safe_outputs.result }}\"}}"
1324
- GH_AW_JOB_TIMING_ELIGIBLE: "{\"activation\":true,\"agent\":true,\"apply\":true,\"conclusion\":true,\"pre_activation\":true,\"prepare\":true,\"publish\":true,\"safe_outputs\":true}"
1335
+ GH_AW_JOB_DISPLAY_NAMES: "{\"activation\":\"activation\",\"agent\":\"agent\",\"apply\":\"Apply with the pinned ProductShape provider\",\"conclusion\":\"conclusion\",\"finalize\":\"Report an interrupted product apply\",\"pre_activation\":\"pre_activation\",\"prepare\":\"Recheck product approval\",\"publish\":\"Recheck and publish provider output\",\"safe_outputs\":\"safe_outputs\"}"
1336
+ GH_AW_JOB_RESULTS: "{\"activation\":{\"result\":\"${{ needs.activation.result }}\"},\"agent\":{\"result\":\"${{ needs.agent.result }}\"},\"apply\":{\"result\":\"${{ needs.apply.result }}\"},\"finalize\":{\"result\":\"${{ needs.finalize.result }}\"},\"prepare\":{\"result\":\"${{ needs.prepare.result }}\"},\"publish\":{\"result\":\"${{ needs.publish.result }}\"},\"safe_outputs\":{\"result\":\"${{ needs.safe_outputs.result }}\"}}"
1337
+ GH_AW_JOB_TIMING_ELIGIBLE: "{\"activation\":true,\"agent\":true,\"apply\":true,\"conclusion\":true,\"finalize\":true,\"pre_activation\":true,\"prepare\":true,\"publish\":true,\"safe_outputs\":true}"
1325
1338
  GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
1326
1339
  outputs:
1327
1340
  noop_comment_body: ${{ steps.noop.outputs.noop_comment_body }}
@@ -1592,9 +1605,52 @@ jobs:
1592
1605
  overwrite: true
1593
1606
  retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }}
1594
1607
 
1608
+ finalize:
1609
+ name: Report an interrupted product apply
1610
+ needs:
1611
+ - apply
1612
+ - prepare
1613
+ - publish
1614
+ if: always()
1615
+ runs-on: ubuntu-24.04
1616
+ # Permissions for the finalize job (workflow permissions default to none).
1617
+ permissions:
1618
+ actions: read
1619
+ contents: read
1620
+ issues: write
1621
+ pull-requests: write
1622
+ env:
1623
+ LIGHTHOUSE_APPLY_RESULT: ${{ needs.apply.result }}
1624
+ LIGHTHOUSE_ISSUE: ${{ inputs.issue }}
1625
+ LIGHTHOUSE_PREPARE_RESULT: ${{ needs.prepare.result }}
1626
+ LIGHTHOUSE_PUBLISH_RESULT: ${{ needs.publish.result }}
1627
+ steps:
1628
+ - name: Configure GH_HOST for enterprise compatibility
1629
+ id: ghes-host-config
1630
+ shell: bash
1631
+ run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
1632
+ # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
1633
+ # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
1634
+ GH_HOST="${GITHUB_SERVER_URL#https://}"
1635
+ GH_HOST="${GH_HOST#http://}"
1636
+ echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
1637
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
1638
+ with:
1639
+ persist-credentials: false
1640
+ ref: __LIGHTHOUSE_REF__
1641
+ repository: juangcarmona/lighthouse
1642
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
1643
+ with:
1644
+ node-version: 24
1645
+ - name: Publish the authenticated outcome
1646
+ run: node src/packages/sdlc/src/issue-actions.mjs product-apply-cleanup
1647
+ env:
1648
+ GH_TOKEN: ${{ github.token }}
1649
+
1595
1650
  pre_activation:
1596
1651
  name: pre_activation
1597
1652
  needs: prepare
1653
+ if: false
1598
1654
  runs-on: ubuntu-24.04
1599
1655
  env:
1600
1656
  GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
@@ -1626,7 +1682,7 @@ jobs:
1626
1682
  permissions:
1627
1683
  actions: read
1628
1684
  contents: read
1629
- issues: read
1685
+ issues: write
1630
1686
  pull-requests: read
1631
1687
  env:
1632
1688
  LIGHTHOUSE_DISPATCH_REQUIRED: true
@@ -1668,6 +1724,30 @@ jobs:
1668
1724
  name: lighthouse-product-apply-input-${{ inputs.issue }}-${{ github.run_attempt }}
1669
1725
  path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}/product-apply.json
1670
1726
  retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '14' }}
1727
+ - name: Check out the approved private source without persisted credentials
1728
+ if: steps.prepare.outputs.eligible == 'true'
1729
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
1730
+ with:
1731
+ fetch-depth: 0
1732
+ path: product-source
1733
+ persist-credentials: false
1734
+ ref: ${{ steps.prepare.outputs.head }}
1735
+ - name: Preserve the approved source for the credential-free provider
1736
+ if: steps.prepare.outputs.eligible == 'true'
1737
+ run: |
1738
+ test "$(git -C product-source rev-parse HEAD)" = "$APPROVED_HEAD"
1739
+ if git -C product-source config --local --name-only --get-regexp '^http\..*\.extraheader$' >/dev/null; then exit 1; fi
1740
+ tar -czf "$RUNNER_TEMP/lighthouse-product-source.tar.gz" -C product-source .
1741
+ env:
1742
+ APPROVED_HEAD: ${{ steps.prepare.outputs.head }}
1743
+ - name: Upload the approved source
1744
+ if: steps.prepare.outputs.eligible == 'true'
1745
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
1746
+ with:
1747
+ if-no-files-found: error
1748
+ name: lighthouse-product-apply-source-${{ inputs.issue }}-${{ github.run_attempt }}
1749
+ path: ${{ runner.temp }}/lighthouse-product-source.tar.gz
1750
+ retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '14' }}
1671
1751
 
1672
1752
  publish:
1673
1753
  name: Recheck and publish provider output
@@ -1678,7 +1758,7 @@ jobs:
1678
1758
  runs-on: ubuntu-24.04
1679
1759
  # Permissions for the publish job (workflow permissions default to none).
1680
1760
  permissions:
1681
- actions: read
1761
+ actions: write
1682
1762
  contents: write
1683
1763
  issues: write
1684
1764
  pull-requests: write
@@ -1717,6 +1797,12 @@ jobs:
1717
1797
  with:
1718
1798
  name: lighthouse-product-apply-result-${{ inputs.issue }}-${{ github.run_attempt }}
1719
1799
  path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}
1800
+ - name: Load the bounded apply proof
1801
+ if: needs.apply.result == 'success'
1802
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
1803
+ with:
1804
+ name: lighthouse-product-apply-proof-${{ inputs.issue }}-${{ github.run_attempt }}
1805
+ path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}
1720
1806
  - name: Stop when the provider apply did not complete
1721
1807
  if: needs.apply.result != 'success'
1722
1808
  run: exit 1
@@ -29,7 +29,7 @@ on:
29
29
  required: true
30
30
  type: string
31
31
  needs: [prepare]
32
- if: needs.prepare.outputs.eligible == 'true'
32
+ if: "false"
33
33
  runs-on: [self-hosted, linux, lighthouse-workflow]
34
34
  runs-on-slim: ubuntu-24.04
35
35
  timeout-minutes: 30
@@ -62,7 +62,7 @@ jobs:
62
62
  permissions:
63
63
  actions: read
64
64
  contents: read
65
- issues: read
65
+ issues: write
66
66
  pull-requests: read
67
67
  env:
68
68
  LIGHTHOUSE_ISSUE: ${{ inputs.issue }}
@@ -92,6 +92,30 @@ jobs:
92
92
  path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}/product-apply.json
93
93
  if-no-files-found: error
94
94
  retention-days: 14
95
+ - name: Check out the approved private source without persisted credentials
96
+ if: steps.prepare.outputs.eligible == 'true'
97
+ uses: actions/checkout@v5
98
+ with:
99
+ ref: ${{ steps.prepare.outputs.head }}
100
+ path: product-source
101
+ fetch-depth: 0
102
+ persist-credentials: false
103
+ - name: Preserve the approved source for the credential-free provider
104
+ if: steps.prepare.outputs.eligible == 'true'
105
+ env:
106
+ APPROVED_HEAD: ${{ steps.prepare.outputs.head }}
107
+ run: |
108
+ test "$(git -C product-source rev-parse HEAD)" = "$APPROVED_HEAD"
109
+ if git -C product-source config --local --name-only --get-regexp '^http\..*\.extraheader$' >/dev/null; then exit 1; fi
110
+ tar -czf "$RUNNER_TEMP/lighthouse-product-source.tar.gz" -C product-source .
111
+ - name: Upload the approved source
112
+ if: steps.prepare.outputs.eligible == 'true'
113
+ uses: actions/upload-artifact@v7
114
+ with:
115
+ name: lighthouse-product-apply-source-${{ inputs.issue }}-${{ github.run_attempt }}
116
+ path: ${{ runner.temp }}/lighthouse-product-source.tar.gz
117
+ if-no-files-found: error
118
+ retention-days: 14
95
119
  apply:
96
120
  name: Apply with the pinned ProductShape provider
97
121
  needs: [prepare]
@@ -99,10 +123,13 @@ jobs:
99
123
  runs-on: ubuntu-24.04
100
124
  permissions: {}
101
125
  steps:
102
- - uses: actions/checkout@v5
126
+ - name: Load the approved source without credentials
127
+ uses: actions/download-artifact@v8
103
128
  with:
104
- persist-credentials: false
105
- ref: ${{ needs.prepare.outputs.head }}
129
+ name: lighthouse-product-apply-source-${{ inputs.issue }}-${{ github.run_attempt }}
130
+ path: ${{ runner.temp }}/product-source
131
+ - name: Restore the approved source
132
+ run: tar -xzf "$RUNNER_TEMP/product-source/lighthouse-product-source.tar.gz" -C "$GITHUB_WORKSPACE"
106
133
  - uses: actions/checkout@v5
107
134
  with:
108
135
  repository: juangcarmona/lighthouse
@@ -113,6 +140,7 @@ jobs:
113
140
  with:
114
141
  node-version: 24
115
142
  - name: Install provider packages without credentials
143
+ working-directory: .lighthouse-harness
116
144
  run: |
117
145
  npm ci --ignore-scripts
118
146
  npm install --no-save --package-lock=false --ignore-scripts @prodshape/integration-openspec@0.6.6
@@ -133,13 +161,20 @@ jobs:
133
161
  ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}/affected-citations.json
134
162
  if-no-files-found: error
135
163
  retention-days: 30
164
+ - name: Preserve the bounded apply proof
165
+ uses: actions/upload-artifact@v7
166
+ with:
167
+ name: lighthouse-product-apply-proof-${{ inputs.issue }}-${{ github.run_attempt }}
168
+ path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}/apply-proof.json
169
+ if-no-files-found: error
170
+ retention-days: 30
136
171
  publish:
137
172
  name: Recheck and publish provider output
138
173
  needs: [prepare, apply]
139
174
  if: always() && needs.prepare.outputs.eligible == 'true'
140
175
  runs-on: ubuntu-24.04
141
176
  permissions:
142
- actions: read
177
+ actions: write
143
178
  contents: write
144
179
  issues: write
145
180
  pull-requests: write
@@ -169,6 +204,12 @@ jobs:
169
204
  with:
170
205
  name: lighthouse-product-apply-result-${{ inputs.issue }}-${{ github.run_attempt }}
171
206
  path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}
207
+ - name: Load the bounded apply proof
208
+ if: needs.apply.result == 'success'
209
+ uses: actions/download-artifact@v8
210
+ with:
211
+ name: lighthouse-product-apply-proof-${{ inputs.issue }}-${{ github.run_attempt }}
212
+ path: ${{ runner.temp }}/lighthouse-${{ github.run_id }}-${{ github.run_attempt }}
172
213
  - name: Stop when the provider apply did not complete
173
214
  if: needs.apply.result != 'success'
174
215
  run: exit 1
@@ -176,6 +217,34 @@ jobs:
176
217
  env:
177
218
  GH_TOKEN: ${{ github.token }}
178
219
  run: node src/packages/sdlc/src/issue-actions.mjs product-apply-publish
220
+ finalize:
221
+ name: Report an interrupted product apply
222
+ needs: [prepare, apply, publish]
223
+ if: always()
224
+ runs-on: ubuntu-24.04
225
+ permissions:
226
+ actions: read
227
+ contents: read
228
+ issues: write
229
+ pull-requests: write
230
+ env:
231
+ LIGHTHOUSE_ISSUE: ${{ inputs.issue }}
232
+ LIGHTHOUSE_PREPARE_RESULT: ${{ needs.prepare.result }}
233
+ LIGHTHOUSE_APPLY_RESULT: ${{ needs.apply.result }}
234
+ LIGHTHOUSE_PUBLISH_RESULT: ${{ needs.publish.result }}
235
+ steps:
236
+ - uses: actions/checkout@v5
237
+ with:
238
+ repository: juangcarmona/lighthouse
239
+ ref: __LIGHTHOUSE_REF__
240
+ persist-credentials: false
241
+ - uses: actions/setup-node@v5
242
+ with:
243
+ node-version: 24
244
+ - name: Publish the authenticated outcome
245
+ env:
246
+ GH_TOKEN: ${{ github.token }}
247
+ run: node src/packages/sdlc/src/issue-actions.mjs product-apply-cleanup
179
248
  ---
180
249
 
181
250
  # Apply an approved Product Change