Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/archive-old-specs.yml
Original file line number Diff line number Diff line change
@@ -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."

3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions scripts/ci/archive-old-specs.js
Original file line number Diff line number Diff line change
@@ -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
};
159 changes: 159 additions & 0 deletions scripts/ci/test-archive-old-specs.js
Original file line number Diff line number Diff line change
@@ -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();
Loading