From 59f9ebeb26c74239af3de72042433502a59a4b67 Mon Sep 17 00:00:00 2001 From: nettee Date: Tue, 18 Aug 2026 14:35:31 +0800 Subject: [PATCH 1/2] fix(ci): preflight archive candidates before mutation Validate every selected archive candidate with the public dry-run dump command before checking archive issues, creating issues, or removing directories. Report invalid candidate paths and preserve all side effects when preflight fails. Generated-By: looper 0.11.8 (runner=worker, agent=codex) --- scripts/ci/archive-old-specs.js | 28 +++++++++++++ scripts/ci/test-archive-old-specs.js | 59 ++++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/scripts/ci/archive-old-specs.js b/scripts/ci/archive-old-specs.js index 8e78679..4f8bd9c 100644 --- a/scripts/ci/archive-old-specs.js +++ b/scripts/ci/archive-old-specs.js @@ -100,8 +100,34 @@ function writeGitHubOutput(result) { fs.appendFileSync(process.env.GITHUB_OUTPUT, `archive_issue_lines< { + if (Buffer.isBuffer(value)) return value.toString('utf8').trim(); + return typeof value === 'string' ? value.trim() : ''; + }) + .filter(Boolean); + + return details[0] || 'unknown error'; +} + +function preflightSpecs(specIds, { specsDir = DEFAULT_SPECS_DIR, runner = execFileSync } = {}) { + // Date-named directories are candidates by contract; invalid candidates are + // rejected here instead of being silently skipped or partially archived. + for (const specId of specIds) { + try { + runner('zest-dev', ['dump', specId, '--dry-run'], { encoding: 'utf8' }); + } catch (error) { + const candidatePath = path.join(specsDir, specId); + throw new Error(`Archive preflight failed for ${candidatePath}: ${commandErrorDetails(error)}`); + } + } +} + function archiveSpecs({ specsDir = DEFAULT_SPECS_DIR, now = new Date(), limit = 10, maxAgeDays = 10, runner = execFileSync, postDumpIssueLookupAttempts = POST_DUMP_ISSUE_LOOKUP_ATTEMPTS, postDumpIssueLookupDelayMs = POST_DUMP_ISSUE_LOOKUP_DELAY_MS, sleep = sleepMs } = {}) { const specIds = selectSpecsToArchive({ specsDir, now, limit, maxAgeDays }); + preflightSpecs(specIds, { specsDir, runner }); + const archived = []; const skippedExistingIssue = []; const associatedIssues = []; @@ -148,11 +174,13 @@ if (require.main === module) { module.exports = { archiveIssueExists, archiveSpecs, + commandErrorDetails, cutoffTimestamp, findArchiveIssue, findArchiveIssueWithRetry, formatIssueLine, listEarliestSpecs, + preflightSpecs, parseUtcDatePrefix, selectSpecsToArchive }; diff --git a/scripts/ci/test-archive-old-specs.js b/scripts/ci/test-archive-old-specs.js index 66f9b31..377b25c 100644 --- a/scripts/ci/test-archive-old-specs.js +++ b/scripts/ci/test-archive-old-specs.js @@ -19,7 +19,7 @@ function noExistingArchiveRunner(calls = []) { if ([...dumpedSpecs].some(specId => args[5].includes(specId))) return '[{"number":456}]'; return '[]'; } - if (cmd === 'zest-dev' && args[0] === 'dump') dumpedSpecs.add(args[1]); + if (cmd === 'zest-dev' && args[0] === 'dump' && !args.includes('--dry-run')) dumpedSpecs.add(args[1]); return ''; }; } @@ -30,6 +30,12 @@ function makeSpec(specsDir, specId) { fs.writeFileSync(path.join(dir, 'spec.md'), '# Test\n'); } +function makeInvalidSpecDirectory(specsDir, specId) { + const dir = path.join(specsDir, specId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'notes.md'), '# Runbook\n'); +} + function fixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zest-archive-specs-')); const specsDir = path.join(root, 'specs', 'change'); @@ -86,22 +92,57 @@ function testDeletesOnlyAfterSuccessfulDump() { if (args[5].includes('20260601-ok') && dumpedSpecs.has('20260601-ok')) return '[{"number":456}]'; return '[]'; } - if (cmd === 'zest-dev' && args[1] === '20260602-fails') { + if (cmd === 'zest-dev' && args[1] === '20260602-fails' && !args.includes('--dry-run')) { throw new Error('dump failed'); } - if (cmd === 'zest-dev' && args[0] === 'dump') dumpedSpecs.add(args[1]); + if (cmd === 'zest-dev' && args[0] === 'dump' && !args.includes('--dry-run')) dumpedSpecs.add(args[1]); return ''; } }), /dump failed/); assert.deepStrictEqual( calls.filter(call => call.cmd === 'zest-dev').map(call => call.args), - [['dump', '20260601-ok'], ['dump', '20260602-fails']] + [ + ['dump', '20260601-ok', '--dry-run'], + ['dump', '20260602-fails', '--dry-run'], + ['dump', '20260601-ok'], + ['dump', '20260602-fails'] + ] ); assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-ok')), false); assert.strictEqual(fs.existsSync(path.join(specsDir, '20260602-fails')), true); } +function testPreflightRejectsMixedValidAndInvalidBatchWithoutSideEffects() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260601-valid'); + makeInvalidSpecDirectory(specsDir, '20260602-runbook'); + const calls = []; + + assert.throws(() => archiveSpecs({ + specsDir, + now: new Date('2026-07-04T00:00:00Z'), + runner: (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'zest-dev' && args[1] === '20260602-runbook') { + throw new Error('Issue Spec Representation requires spec.md'); + } + if (cmd === 'gh') throw new Error('GitHub must not be touched during preflight'); + return ''; + } + }), /specs[\\/]change[\\/]20260602-runbook: Issue Spec Representation requires spec\.md/); + + assert.deepStrictEqual( + calls.map(call => call.args), + [ + ['dump', '20260601-valid', '--dry-run'], + ['dump', '20260602-runbook', '--dry-run'] + ] + ); + assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-valid')), true); + assert.strictEqual(fs.existsSync(path.join(specsDir, '20260602-runbook')), true); +} + function testExistingArchiveIssueDeletesWithoutDumpingAgain() { const { specsDir } = fixture(); makeSpec(specsDir, '20260601-already-archived'); @@ -115,6 +156,7 @@ function testExistingArchiveIssueDeletesWithoutDumpingAgain() { if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { return '[{"number":123}]'; } + if (cmd === 'zest-dev' && args[0] === 'dump' && args.includes('--dry-run')) return ''; throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); } }); @@ -124,7 +166,8 @@ function testExistingArchiveIssueDeletesWithoutDumpingAgain() { skippedExistingIssue: ['20260601-already-archived'], associatedIssues: [{ specId: '20260601-already-archived', issueNumber: 123 }] }); - assert.strictEqual(calls.length, 1); + assert.strictEqual(calls.length, 2); + assert.deepStrictEqual(calls[0].args, ['dump', '20260601-already-archived', '--dry-run']); assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-already-archived')), false); } @@ -159,7 +202,8 @@ function testRetriesIssueLookupAfterDumpUntilSearchCatchesUp() { lookups += 1; return lookups < 3 ? '[]' : '[{"number":789}]'; } - if (cmd === 'zest-dev' && args[0] === 'dump') { dumped = true; return ''; } + if (cmd === 'zest-dev' && args[0] === 'dump' && !args.includes('--dry-run')) { dumped = true; return ''; } + if (cmd === 'zest-dev' && args[0] === 'dump' && args.includes('--dry-run')) return ''; throw new Error(`unexpected command: ${cmd}`); } }); assert.deepStrictEqual(result.associatedIssues, [{ specId: '20260601-delayed-index', issueNumber: 789 }]); @@ -187,7 +231,7 @@ function testRunsGlobalZestDevDump() { assert.deepStrictEqual( calls.filter(call => call.cmd === 'zest-dev').map(call => call.args), - [['dump', '20260601-eligible']] + [['dump', '20260601-eligible', '--dry-run'], ['dump', '20260601-eligible']] ); } @@ -195,6 +239,7 @@ function main() { testListsEarliestTenAndIgnoresActiveSymlinkEntry(); testSelectsOnlyMoreThanTenDaysOld(); testDeletesOnlyAfterSuccessfulDump(); + testPreflightRejectsMixedValidAndInvalidBatchWithoutSideEffects(); testExistingArchiveIssueDeletesWithoutDumpingAgain(); testRecordsIssueCreatedByDump(); testFailsWhenDumpDoesNotCreateArchiveIssue(); From e463d0880f1a4f1bbfcd487f52ee29977c44913e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:36:20 +0000 Subject: [PATCH 2/2] ci: auto bump patch version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7a95273..68e83fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zest-dev", - "version": "1.0.11", + "version": "1.0.12", "description": "A lightweight, human-interactive development workflow for AI-assisted coding", "author": { "name": "nettee",