diff --git a/.github/workflows/archive-old-specs.yml b/.github/workflows/archive-old-specs.yml new file mode 100644 index 0000000..1c870a4 --- /dev/null +++ b/.github/workflows/archive-old-specs.yml @@ -0,0 +1,65 @@ +name: Archive Old Worktree Kit Specs + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: archive-old-worktree-kit-specs + cancel-in-progress: false + +jobs: + archive-old-specs: + name: Archive old specs + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install published zest-dev CLI + run: npm install -g zest-dev + + - name: Ensure archive labels exist + 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" + + - name: Archive eligible specs + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/ci/archive-old-specs.js + + - name: Create PR for spec removals + env: + GH_TOKEN: ${{ github.token }} + run: | + 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; } + branch="archive-old-specs/${{ github.run_id }}-${{ github.run_attempt }}" + git switch -c "$branch" + git commit -m "chore: archive old specs" + git push origin "$branch" + gh pr create \ + --base "${{ github.ref_name }}" \ + --head "$branch" \ + --title "Archive old specs" \ + --body "Automated archive of old Worktree Kit specs." + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 065f082..5b172a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Validate local install script run: sh scripts/test-install-local.sh + - name: Test old spec archival automation + run: node scripts/ci/test-archive-old-specs.js + - name: Run test suite with coverage run: | cargo llvm-cov clean --workspace diff --git a/scripts/ci/archive-old-specs.js b/scripts/ci/archive-old-specs.js new file mode 100644 index 0000000..b671f98 --- /dev/null +++ b/scripts/ci/archive-old-specs.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +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; + +function parseUtcDatePrefix(specId) { + const prefix = specId.slice(0, 8); + const year = Number(prefix.slice(0, 4)); + const month = Number(prefix.slice(4, 6)); + const day = Number(prefix.slice(6, 8)); + const timestamp = Date.UTC(year, month - 1, day); + const date = new Date(timestamp); + + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + throw new Error(`Invalid spec date prefix: ${specId}`); + } + + return timestamp; +} + +function cutoffTimestamp(now = new Date(), maxAgeDays = 10) { + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - maxAgeDays * DAY_MS; +} + +function listEarliestSpecs({ specsDir = DEFAULT_SPECS_DIR, limit = 10 } = {}) { + if (!fs.existsSync(specsDir)) { + throw new Error(`Specs directory not found: ${specsDir}`); + } + + return fs.readdirSync(specsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && SPEC_ID_PATTERN.test(entry.name)) + .map(entry => entry.name) + .sort() + .slice(0, limit); +} + +function selectSpecsToArchive({ specsDir = DEFAULT_SPECS_DIR, now = new Date(), limit = 10, maxAgeDays = 10 } = {}) { + const cutoff = cutoffTimestamp(now, maxAgeDays); + return listEarliestSpecs({ specsDir, limit }) + .filter(specId => parseUtcDatePrefix(specId) < cutoff); +} + +function archiveIssueExists(specId, { runner = execFileSync } = {}) { + const title = `[archive] ${specId}`; + const output = runner('gh', [ + 'issue', + 'list', + '--state', + 'all', + '--search', + `${JSON.stringify(title)} in:title`, + '--json', + 'number', + '--limit', + '1' + ], { encoding: 'utf8' }); + const issues = JSON.parse(output); + if (!Array.isArray(issues)) { + throw new Error(`Unexpected gh issue list output for ${specId}`); + } + return issues.length > 0; +} + +function archiveSpecs({ specsDir = DEFAULT_SPECS_DIR, now = new Date(), limit = 10, maxAgeDays = 10, runner = execFileSync } = {}) { + const specIds = selectSpecsToArchive({ specsDir, now, limit, maxAgeDays }); + const archived = []; + const skippedExistingIssue = []; + + for (const specId of specIds) { + if (archiveIssueExists(specId, { runner })) { + fs.rmSync(path.join(specsDir, specId), { recursive: true, force: false }); + skippedExistingIssue.push(specId); + continue; + } + + runner('zest-dev', ['dump', specId], { stdio: 'inherit' }); + fs.rmSync(path.join(specsDir, specId), { recursive: true, force: false }); + archived.push(specId); + } + + return { archived, skippedExistingIssue }; +} + +function main() { + const result = archiveSpecs(); + if (result.archived.length === 0 && result.skippedExistingIssue.length === 0) { + console.log('No old specs eligible for archival.'); + return; + } + if (result.archived.length > 0) { + console.log(`Archived and removed ${result.archived.length} spec(s): ${result.archived.join(', ')}`); + } + if (result.skippedExistingIssue.length > 0) { + console.log(`Removed ${result.skippedExistingIssue.length} spec(s) with existing archive issue: ${result.skippedExistingIssue.join(', ')}`); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + archiveIssueExists, + archiveSpecs, + cutoffTimestamp, + listEarliestSpecs, + parseUtcDatePrefix, + selectSpecsToArchive +}; diff --git a/scripts/ci/test-archive-old-specs.js b/scripts/ci/test-archive-old-specs.js new file mode 100644 index 0000000..1a39a5a --- /dev/null +++ b/scripts/ci/test-archive-old-specs.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + archiveSpecs, + listEarliestSpecs, + selectSpecsToArchive +} = require('./archive-old-specs'); + +function noExistingArchiveRunner(calls = []) { + return (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { + return '[]'; + } + return ''; + }; +} + +function makeSpec(specsDir, specId) { + const dir = path.join(specsDir, specId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'spec.md'), '# Test\n'); +} + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zest-archive-specs-')); + const specsDir = path.join(root, 'specs', 'change'); + fs.mkdirSync(specsDir, { recursive: true }); + return { root, specsDir }; +} + +function testListsEarliestTenAndIgnoresActiveSymlinkEntry() { + const { specsDir } = fixture(); + for (let day = 1; day <= 12; day += 1) { + makeSpec(specsDir, `202601${String(day).padStart(2, '0')}-spec-${day}`); + } + fs.symlinkSync('20260101-spec-1', path.join(specsDir, 'active')); + + assert.deepStrictEqual(listEarliestSpecs({ specsDir }), [ + '20260101-spec-1', + '20260102-spec-2', + '20260103-spec-3', + '20260104-spec-4', + '20260105-spec-5', + '20260106-spec-6', + '20260107-spec-7', + '20260108-spec-8', + '20260109-spec-9', + '20260110-spec-10' + ]); +} + +function testSelectsOnlyMoreThanTenDaysOld() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260620-old'); + makeSpec(specsDir, '20260624-exactly-ten-days'); + makeSpec(specsDir, '20260625-newer'); + + assert.deepStrictEqual( + selectSpecsToArchive({ specsDir, now: new Date('2026-07-04T12:00:00Z') }), + ['20260620-old'] + ); +} + +function testDeletesOnlyAfterSuccessfulDump() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260601-ok'); + makeSpec(specsDir, '20260602-fails'); + const calls = []; + + assert.throws(() => archiveSpecs({ + specsDir, + now: new Date('2026-07-04T00:00:00Z'), + runner: (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { + return '[]'; + } + if (cmd === 'zest-dev' && args[1] === '20260602-fails') { + throw new Error('dump failed'); + } + return ''; + } + }), /dump failed/); + + assert.deepStrictEqual( + calls.filter(call => call.cmd === 'zest-dev').map(call => call.args), + [['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 testExistingArchiveIssueDeletesWithoutDumpingAgain() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260601-already-archived'); + const calls = []; + + const result = archiveSpecs({ + specsDir, + now: new Date('2026-07-04T00:00:00Z'), + runner: (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { + return '[{"number":123}]'; + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + } + }); + + assert.deepStrictEqual(result, { + archived: [], + skippedExistingIssue: ['20260601-already-archived'] + }); + assert.strictEqual(calls.length, 1); + assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-already-archived')), false); +} + +function testMissingSpecsDirectoryFailsFast() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zest-archive-specs-missing-')); + assert.throws( + () => listEarliestSpecs({ specsDir: path.join(root, 'specs', 'change') }), + /Specs directory not found/ + ); +} + +function testRunsGlobalZestDevDump() { + const { specsDir } = fixture(); + makeSpec(specsDir, '20260601-eligible'); + const calls = []; + + archiveSpecs({ + specsDir, + now: new Date('2026-07-04T00:00:00Z'), + runner: noExistingArchiveRunner(calls) + }); + + assert.deepStrictEqual( + calls.filter(call => call.cmd === 'zest-dev').map(call => call.args), + [['dump', '20260601-eligible']] + ); +} + +function main() { + testListsEarliestTenAndIgnoresActiveSymlinkEntry(); + testSelectsOnlyMoreThanTenDaysOld(); + testDeletesOnlyAfterSuccessfulDump(); + testExistingArchiveIssueDeletesWithoutDumpingAgain(); + testMissingSpecsDirectoryFailsFast(); + testRunsGlobalZestDevDump(); + console.log('archive-old-specs tests passed'); +} + +main();