Skip to content

Commit fa412cb

Browse files
svelderrainruizGitHub Copilot
andauthored
Release Conductor: stop recurring develop failures on missing queue report and disabled apply mode (#906) (#909)
* Keep release conductor proposal-only without queue evidence (#906) * Run release conductor tests in validate lint job (#906) --------- Co-authored-by: GitHub Copilot <copilot@users.noreply.github.com>
1 parent 6849cef commit fa412cb

9 files changed

Lines changed: 272 additions & 21 deletions

.github/workflows/release-conductor.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,14 @@ jobs:
8989
9090
$eventName = '${{ github.event_name }}'
9191
$apply = $false
92+
$conductorEnabled = ($env:RELEASE_CONDUCTOR_ENABLED -eq '1')
9293
if ($eventName -eq 'workflow_dispatch') {
9394
$apply = ('${{ inputs.apply }}' -eq 'true')
9495
} elseif ($eventName -eq 'workflow_run') {
95-
$apply = $true
96+
$apply = $conductorEnabled
97+
if (-not $apply) {
98+
Write-Host 'Release conductor apply mode disabled; workflow_run will remain proposal-only.'
99+
}
96100
}
97101
98102
if ($apply) {
@@ -131,4 +135,4 @@ jobs:
131135
path: |
132136
tests/results/_agent/release/release-conductor-report.json
133137
tests/results/_agent/policy/policy-state-snapshot.json
134-
if-no-files-found: error
138+
if-no-files-found: error

.github/workflows/validate.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ jobs:
153153
run: |
154154
pwsh -File tools/PrePush-Checks.ps1
155155
156+
- name: Release conductor contract tests
157+
shell: bash
158+
run: |
159+
node tools/npm/run-script.mjs priority:release:conductor:test
160+
156161
- name: Policy guard (branch protection)
157162
if: ${{ github.event.repository.fork == false }}
158163
shell: pwsh

docs/DEVELOPER_GUIDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ For Docker/Desktop VI history validation, run fast-loop lanes explicitly:
342342
For queue-aware release proposals, run `node tools/npm/run-script.mjs priority:release:conductor -- --dry-run`.
343343
Apply mode requires `RELEASE_CONDUCTOR_ENABLED=1`; if signing material is unavailable, the conductor remains
344344
proposal-only and emits evidence without mutating tags.
345+
Hosted `schedule` and `workflow_run` conductor lanes stay proposal-only when apply mode is disabled, and dry-runs
346+
record advisory-only queue-evidence / no-recent-success diagnostics instead of failing for missing queue artifacts or
347+
idle dwell windows.
345348
Use `node tools/npm/run-script.mjs priority:remediation:slo` to compute remediation SLO governance metrics
346349
(MTTD, route latency, MTTR by priority, reopen rate, queue/trunk/release signals) and emit
347350
`tests/results/_agent/slo/remediation-slo-report.json` plus governor state

docs/schemas/release-conductor-report-v1.schema.json

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,11 @@
212212
"decision": {
213213
"type": "object",
214214
"additionalProperties": false,
215-
"required": ["status", "blockerCount", "blockers"],
215+
"required": ["status", "blockerCount", "blockers", "advisoryCount", "advisories"],
216216
"properties": {
217217
"status": { "type": "string", "enum": ["pass", "fail"] },
218218
"blockerCount": { "type": "integer", "minimum": 0 },
219+
"advisoryCount": { "type": "integer", "minimum": 0 },
219220
"blockers": {
220221
"type": "array",
221222
"items": {
@@ -227,8 +228,20 @@
227228
"message": { "type": "string" }
228229
}
229230
}
231+
},
232+
"advisories": {
233+
"type": "array",
234+
"items": {
235+
"type": "object",
236+
"additionalProperties": false,
237+
"required": ["code", "message"],
238+
"properties": {
239+
"code": { "type": "string" },
240+
"message": { "type": "string" }
241+
}
242+
}
230243
}
231244
}
232245
}
233246
}
234-
}
247+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"priority:queue:supervisor": "node tools/priority/queue-supervisor.mjs",
6363
"priority:queue:readiness": "node tools/priority/queue-readiness.mjs",
6464
"priority:review:signal": "tsc -p tsconfig.json && node dist/tools/priority/copilot-review-signal.js",
65+
"priority:release:conductor:test": "node --test tools/priority/__tests__/release-conductor.test.mjs tools/priority/__tests__/release-conductor-schema.test.mjs tools/priority/__tests__/release-conductor-workflow-contract.test.mjs",
6566
"priority:validation:attestation": "tsc -p tsconfig.json && node dist/tools/priority/validation-agent-attestation.js",
6667
"priority:validation:broker": "node tools/priority/validation-approval-broker.mjs",
6768
"priority:validation:helper": "node tools/priority/validation-approval-helper.mjs",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env node
2+
3+
import test from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import { readFile } from 'node:fs/promises';
6+
import path from 'node:path';
7+
import { fileURLToPath } from 'node:url';
8+
9+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
10+
11+
test('release conductor workflow keeps workflow_run proposal-only when apply mode is disabled', async () => {
12+
const workflowPath = path.join(repoRoot, '.github', 'workflows', 'release-conductor.yml');
13+
const workflow = await readFile(workflowPath, 'utf8');
14+
15+
assert.match(workflow, /RELEASE_CONDUCTOR_ENABLED:\s+\$\{\{\s*vars\.RELEASE_CONDUCTOR_ENABLED \|\| '0'\s*\}\}/);
16+
assert.match(
17+
workflow,
18+
/elseif \(\$eventName -eq 'workflow_run'\) \{\s+\$apply = \$conductorEnabled\s+if \(-not \$apply\) \{\s+Write-Host 'Release conductor apply mode disabled; workflow_run will remain proposal-only\.'\s+\}\s+\}/ms
19+
);
20+
});

tools/priority/__tests__/release-conductor.test.mjs

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,158 @@ test('runReleaseConductor blocks apply when release conductor flag is disabled',
196196
assert.equal(commandCalls.some((entry) => entry.command === 'git' && entry.args[0] === 'tag'), false);
197197
});
198198

199+
test('runReleaseConductor keeps dry-run proposal-only when queue evidence is missing and no recent success exists', async () => {
200+
const readJsonOptionalFn = async (filePath) => {
201+
const normalized = String(filePath);
202+
if (normalized.includes('queue-supervisor-report.json')) {
203+
return {
204+
exists: false,
205+
error: null,
206+
path: filePath,
207+
payload: null
208+
};
209+
}
210+
return {
211+
exists: true,
212+
error: null,
213+
path: filePath,
214+
payload: {
215+
schema: 'priority/policy-live-state@v1',
216+
generatedAt: '2026-03-06T10:00:00Z',
217+
state: {}
218+
}
219+
};
220+
};
221+
222+
const runGhJsonFn = (args) => {
223+
if (args[0] !== 'api') {
224+
throw new Error(`unexpected gh args: ${args.join(' ')}`);
225+
}
226+
return {
227+
workflow_runs: [
228+
{
229+
id: 1,
230+
status: 'completed',
231+
conclusion: 'success',
232+
updated_at: '2026-03-06T09:00:00Z'
233+
}
234+
]
235+
};
236+
};
237+
238+
const { report, exitCode } = await runReleaseConductor({
239+
repoRoot: process.cwd(),
240+
now: new Date('2026-03-06T12:00:00.000Z'),
241+
args: {
242+
apply: false,
243+
dryRun: true,
244+
reportPath: 'tests/results/_agent/release/release-conductor-report.json',
245+
queueReportPath: 'tests/results/_agent/queue/queue-supervisor-report.json',
246+
policySnapshotPath: 'tests/results/_agent/policy/policy-state-snapshot.json',
247+
repo: 'owner/repo',
248+
stream: 'comparevi-cli',
249+
channel: 'stable',
250+
version: '0.8.0',
251+
dwellMinutes: 60,
252+
quarantineStaleHours: 24,
253+
help: false
254+
},
255+
environment: {
256+
GITHUB_REPOSITORY: 'owner/repo',
257+
RELEASE_CONDUCTOR_ENABLED: '0'
258+
},
259+
runGhJsonFn,
260+
runCommandFn: () => ({ status: 0, stdout: '', stderr: '' }),
261+
readJsonOptionalFn,
262+
writeReportFn: async (reportPath) => reportPath
263+
});
264+
265+
assert.equal(exitCode, 0);
266+
assert.equal(report.decision.status, 'pass');
267+
assert.equal(report.release.proposalOnly, true);
268+
assert.equal(report.gates.greenDwell.status, 'fail');
269+
assert.equal(report.gates.queueHealth.status, 'fail');
270+
assert.equal(report.gates.quarantine.status, 'fail');
271+
assert.equal(report.decision.blockerCount, 0);
272+
assert.ok(report.decision.advisories.some((entry) => entry.code === 'green-dwell-no-recent-success'));
273+
assert.ok(report.decision.advisories.some((entry) => entry.code === 'queue-report-unavailable-dry-run'));
274+
});
275+
276+
test('runReleaseConductor still blocks dry-run when the dwell window contains workflow failures', async () => {
277+
const readJsonOptionalFn = async (filePath) => {
278+
const normalized = String(filePath);
279+
if (normalized.includes('queue-supervisor-report.json')) {
280+
return {
281+
exists: true,
282+
error: null,
283+
path: filePath,
284+
payload: {
285+
paused: false,
286+
throughputController: { mode: 'healthy' },
287+
retryHistory: {}
288+
}
289+
};
290+
}
291+
return {
292+
exists: true,
293+
error: null,
294+
path: filePath,
295+
payload: {
296+
schema: 'priority/policy-live-state@v1',
297+
generatedAt: '2026-03-06T10:00:00Z',
298+
state: {}
299+
}
300+
};
301+
};
302+
303+
const runGhJsonFn = (args) => {
304+
if (args[0] !== 'api') {
305+
throw new Error(`unexpected gh args: ${args.join(' ')}`);
306+
}
307+
return {
308+
workflow_runs: [
309+
{
310+
id: 1,
311+
status: 'completed',
312+
conclusion: 'failure',
313+
updated_at: '2026-03-06T11:45:00Z'
314+
}
315+
]
316+
};
317+
};
318+
319+
const { report, exitCode } = await runReleaseConductor({
320+
repoRoot: process.cwd(),
321+
now: new Date('2026-03-06T12:00:00.000Z'),
322+
args: {
323+
apply: false,
324+
dryRun: true,
325+
reportPath: 'tests/results/_agent/release/release-conductor-report.json',
326+
queueReportPath: 'tests/results/_agent/queue/queue-supervisor-report.json',
327+
policySnapshotPath: 'tests/results/_agent/policy/policy-state-snapshot.json',
328+
repo: 'owner/repo',
329+
stream: 'comparevi-cli',
330+
channel: 'stable',
331+
version: '0.8.0',
332+
dwellMinutes: 60,
333+
quarantineStaleHours: 24,
334+
help: false
335+
},
336+
environment: {
337+
GITHUB_REPOSITORY: 'owner/repo',
338+
RELEASE_CONDUCTOR_ENABLED: '0'
339+
},
340+
runGhJsonFn,
341+
runCommandFn: () => ({ status: 0, stdout: '', stderr: '' }),
342+
readJsonOptionalFn,
343+
writeReportFn: async (reportPath) => reportPath
344+
});
345+
346+
assert.equal(exitCode, 1);
347+
assert.equal(report.decision.status, 'fail');
348+
assert.ok(report.decision.blockers.some((entry) => entry.code === 'green-dwell-failed'));
349+
});
350+
199351
test('runReleaseConductor creates signed tag when apply is enabled and signing key is available', async () => {
200352
const readJsonOptionalFn = async (filePath) => {
201353
const normalized = String(filePath);
@@ -351,4 +503,4 @@ test('runReleaseConductor stays proposal-only when signing material is unavailab
351503
assert.equal(report.release.proposalOnly, true);
352504
assert.equal(report.release.tagCreated, false);
353505
assert.equal(commandCalls.some((entry) => entry.command === 'git' && entry.args[0] === 'tag'), false);
354-
});
506+
});

tools/priority/__tests__/validate-scope-routing-contract.test.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@ test('validate heavy jobs consume scoped lane decisions without skipping require
3737
assert.match(workflow, /VALIDATE_SCOPE_RUN_VI_HISTORY:\s+\$\{\{\s*needs\.validate-scope-plan\.outputs\.run_vi_history\s*\}\}/);
3838
assert.match(workflow, /VALIDATE_SCOPE_VI_HISTORY_REASON:\s+\$\{\{\s*needs\.validate-scope-plan\.outputs\.vi_history_reason\s*\}\}/);
3939
});
40+
41+
test('validate lint job runs release conductor contract coverage for issue branches', () => {
42+
const workflow = readRepoFile('.github/workflows/validate.yml');
43+
44+
assert.match(workflow, /name:\s+Release conductor contract tests/);
45+
assert.match(workflow, /node tools\/npm\/run-script\.mjs priority:release:conductor:test/);
46+
});

0 commit comments

Comments
 (0)