diff --git a/.github/workflows/archive-old-specs.yml b/.github/workflows/archive-old-specs.yml index 1c870a4..20a03ef 100644 --- a/.github/workflows/archive-old-specs.yml +++ b/.github/workflows/archive-old-specs.yml @@ -21,6 +21,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} - name: Setup Node.js uses: actions/setup-node@v4 @@ -34,32 +36,95 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - [ "$(gh label list --search "spec:change" --json name --jq 'any(.name == "spec:change")')" = "true" ] \ - || gh label create "spec:change" --color "5319e7" --description "Zest Dev change spec" - [ "$(gh label list --search "archive" --json name --jq 'any(.name == "archive")')" = "true" ] \ - || gh label create "archive" --color "cfd3d7" --description "Archived spec snapshot" + set -euo pipefail + + ensure_label() { + local name="$1" + local color="$2" + local description="$3" + local exists + + exists="$(gh label list --limit 1000 --json name --jq ".[] | select(.name == \"$name\") | .name")" + [ "$exists" = "$name" ] || gh label create "$name" --color "$color" --description "$description" + } + + ensure_label "spec:change" "5319e7" "Zest Dev change spec" + ensure_label "archive" "cfd3d7" "Archived spec snapshot" + ensure_label "automation:archive-old-specs" "0e8a16" "Archive old specs automation PR" + + - name: Stop if archive PR is already open + id: check_open_archive_prs + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + archive_pr_label="automation:archive-old-specs" + open_archive_prs="$(gh pr list --state open --label "$archive_pr_label" --limit 1000 --json number,url --jq '.[] | "#\(.number) \(.url)"')" + if [ -n "$open_archive_prs" ]; then + echo "should_continue=false" >> "$GITHUB_OUTPUT" + echo "Found existing open archive PR(s); stopping before creating archive issues or a duplicate PR:" + echo "$open_archive_prs" + exit 0 + fi + + echo "should_continue=true" >> "$GITHUB_OUTPUT" - name: Archive eligible specs + id: archive_eligible_specs + if: steps.check_open_archive_prs.outputs.should_continue == 'true' env: GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }} run: node scripts/ci/archive-old-specs.js - name: Create PR for spec removals + if: steps.check_open_archive_prs.outputs.should_continue == 'true' env: GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + ARCHIVE_ISSUE_LINES: ${{ steps.archive_eligible_specs.outputs.archive_issue_lines }} run: | + set -euo pipefail + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add specs/change - git diff --cached --quiet && { echo "No spec removals to commit."; exit 0; } + if git diff --cached --quiet; then + echo "No spec removals to commit." + exit 0 + else + diff_status=$? + if [ "$diff_status" -ne 1 ]; then + exit "$diff_status" + fi + fi + + archive_pr_label="automation:archive-old-specs" branch="archive-old-specs/${{ github.run_id }}-${{ github.run_attempt }}" + if [ -z "$ARCHIVE_ISSUE_LINES" ]; then + echo "Spec removals exist, but archive issue output is empty." >&2 + exit 1 + fi + + body_file="$(mktemp)" + cat > "$body_file" <&2 + exit 1 + fi diff --git a/scripts/ci/archive-old-specs.js b/scripts/ci/archive-old-specs.js index b671f98..8e78679 100644 --- a/scripts/ci/archive-old-specs.js +++ b/scripts/ci/archive-old-specs.js @@ -7,6 +7,13 @@ const { execFileSync } = require('child_process'); const DEFAULT_SPECS_DIR = path.join('specs', 'change'); const SPEC_ID_PATTERN = /^\d{8}-[a-z0-9][a-z0-9-]*$/; const DAY_MS = 24 * 60 * 60 * 1000; +const POST_DUMP_ISSUE_LOOKUP_ATTEMPTS = 3; +const POST_DUMP_ISSUE_LOOKUP_DELAY_MS = 1000; + +function sleepMs(delayMs) { + if (delayMs <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs); +} function parseUtcDatePrefix(specId) { const prefix = specId.slice(0, 8); @@ -49,7 +56,7 @@ function selectSpecsToArchive({ specsDir = DEFAULT_SPECS_DIR, now = new Date(), .filter(specId => parseUtcDatePrefix(specId) < cutoff); } -function archiveIssueExists(specId, { runner = execFileSync } = {}) { +function findArchiveIssue(specId, { runner = execFileSync } = {}) { const title = `[archive] ${specId}`; const output = runner('gh', [ 'issue', @@ -67,31 +74,61 @@ function archiveIssueExists(specId, { runner = execFileSync } = {}) { if (!Array.isArray(issues)) { throw new Error(`Unexpected gh issue list output for ${specId}`); } - return issues.length > 0; + return issues[0] ?? null; +} + +function archiveIssueExists(specId, { runner = execFileSync } = {}) { + return findArchiveIssue(specId, { runner }) !== null; +} + +function findArchiveIssueWithRetry(specId, { runner = execFileSync, attempts = POST_DUMP_ISSUE_LOOKUP_ATTEMPTS, delayMs = POST_DUMP_ISSUE_LOOKUP_DELAY_MS, sleep = sleepMs } = {}) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const issue = findArchiveIssue(specId, { runner }); + if (issue || attempt === attempts) return issue; + sleep(delayMs); + } + return null; +} + +function formatIssueLine({ specId, issueNumber }) { + return `- #${issueNumber} \`${specId}\``; +} + +function writeGitHubOutput(result) { + if (!process.env.GITHUB_OUTPUT) return; + const issueLines = result.associatedIssues.map(formatIssueLine).join('\n'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `archive_issue_lines< { calls.push({ cmd, args }); if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { + if ([...dumpedSpecs].some(specId => args[5].includes(specId))) return '[{"number":456}]'; return '[]'; } + if (cmd === 'zest-dev' && args[0] === 'dump') dumpedSpecs.add(args[1]); return ''; }; } @@ -72,6 +75,7 @@ function testDeletesOnlyAfterSuccessfulDump() { makeSpec(specsDir, '20260601-ok'); makeSpec(specsDir, '20260602-fails'); const calls = []; + const dumpedSpecs = new Set(); assert.throws(() => archiveSpecs({ specsDir, @@ -79,11 +83,13 @@ function testDeletesOnlyAfterSuccessfulDump() { runner: (cmd, args) => { calls.push({ cmd, args }); if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { + if (args[5].includes('20260601-ok') && dumpedSpecs.has('20260601-ok')) return '[{"number":456}]'; return '[]'; } if (cmd === 'zest-dev' && args[1] === '20260602-fails') { throw new Error('dump failed'); } + if (cmd === 'zest-dev' && args[0] === 'dump') dumpedSpecs.add(args[1]); return ''; } }), /dump failed/); @@ -115,12 +121,51 @@ function testExistingArchiveIssueDeletesWithoutDumpingAgain() { assert.deepStrictEqual(result, { archived: [], - skippedExistingIssue: ['20260601-already-archived'] + skippedExistingIssue: ['20260601-already-archived'], + associatedIssues: [{ specId: '20260601-already-archived', issueNumber: 123 }] }); assert.strictEqual(calls.length, 1); assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-already-archived')), false); } +function testRecordsIssueCreatedByDump() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260601-eligible'); + assert.deepStrictEqual(archiveSpecs({ specsDir, now: new Date('2026-07-04T00:00:00Z'), runner: noExistingArchiveRunner() }), { + archived: ['20260601-eligible'], skippedExistingIssue: [], associatedIssues: [{ specId: '20260601-eligible', issueNumber: 456 }] + }); +} + +function testFailsWhenDumpDoesNotCreateArchiveIssue() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260601-missing-issue'); + assert.throws(() => archiveSpecs({ specsDir, now: new Date('2026-07-04T00:00:00Z'), runner: (cmd, args) => { + if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') return '[]'; + if (cmd === 'zest-dev' && args[0] === 'dump') return ''; + throw new Error(`unexpected command: ${cmd}`); + } }), /Archive issue was not created/); + assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-missing-issue')), true); +} + +function testRetriesIssueLookupAfterDumpUntilSearchCatchesUp() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260601-delayed-index'); + let dumped = false; + let lookups = 0; + const sleepCalls = []; + const result = archiveSpecs({ specsDir, now: new Date('2026-07-04T00:00:00Z'), postDumpIssueLookupDelayMs: 25, sleep: delay => sleepCalls.push(delay), runner: (cmd, args) => { + if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { + if (!dumped) return '[]'; + lookups += 1; + return lookups < 3 ? '[]' : '[{"number":789}]'; + } + if (cmd === 'zest-dev' && args[0] === 'dump') { dumped = true; return ''; } + throw new Error(`unexpected command: ${cmd}`); + } }); + assert.deepStrictEqual(result.associatedIssues, [{ specId: '20260601-delayed-index', issueNumber: 789 }]); + assert.deepStrictEqual(sleepCalls, [25, 25]); +} + function testMissingSpecsDirectoryFailsFast() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zest-archive-specs-missing-')); assert.throws( @@ -151,6 +196,9 @@ function main() { testSelectsOnlyMoreThanTenDaysOld(); testDeletesOnlyAfterSuccessfulDump(); testExistingArchiveIssueDeletesWithoutDumpingAgain(); + testRecordsIssueCreatedByDump(); + testFailsWhenDumpDoesNotCreateArchiveIssue(); + testRetriesIssueLookupAfterDumpUntilSearchCatchesUp(); testMissingSpecsDirectoryFailsFast(); testRunsGlobalZestDevDump(); console.log('archive-old-specs tests passed');