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
83 changes: 74 additions & 9 deletions .github/workflows/archive-old-specs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" <<EOF
Automated archive of old Worktree Kit specs.

Associated issues:
$ARCHIVE_ISSUE_LINES
EOF

git switch -c "$branch"
git commit -m "chore: archive old specs"
git push origin "$branch"
gh pr create \
--base "${{ github.ref_name }}" \
new_pr_url="$(gh pr create \
--base "$DEFAULT_BRANCH" \
--head "$branch" \
--title "Archive old specs" \
--body "Automated archive of old Worktree Kit specs."

--body-file "$body_file" \
--label "$archive_pr_label")"
if [[ ! "$new_pr_url" =~ ^https://github\.com/[^/]+/[^/]+/pull/([0-9]+)$ ]]; then
echo "gh pr create returned an invalid PR URL: $new_pr_url" >&2
exit 1
fi
50 changes: 45 additions & 5 deletions scripts/ci/archive-old-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Expand All @@ -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<<EOF\n${issueLines}\nEOF\n`);
}

function archiveSpecs({ specsDir = DEFAULT_SPECS_DIR, now = new Date(), limit = 10, maxAgeDays = 10, runner = execFileSync } = {}) {
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 });
const archived = [];
const skippedExistingIssue = [];
const associatedIssues = [];

for (const specId of specIds) {
if (archiveIssueExists(specId, { runner })) {
const existingIssue = findArchiveIssue(specId, { runner });
if (existingIssue) {
fs.rmSync(path.join(specsDir, specId), { recursive: true, force: false });
skippedExistingIssue.push(specId);
associatedIssues.push({ specId, issueNumber: existingIssue.number });
continue;
}

runner('zest-dev', ['dump', specId], { stdio: 'inherit' });
const archiveIssue = findArchiveIssueWithRetry(specId, { runner, attempts: postDumpIssueLookupAttempts, delayMs: postDumpIssueLookupDelayMs, sleep });
if (!archiveIssue) throw new Error(`Archive issue was not created for ${specId}`);
fs.rmSync(path.join(specsDir, specId), { recursive: true, force: false });
archived.push(specId);
associatedIssues.push({ specId, issueNumber: archiveIssue.number });
}

return { archived, skippedExistingIssue };
return { archived, skippedExistingIssue, associatedIssues };
}

function main() {
const result = archiveSpecs();
writeGitHubOutput(result);
if (result.archived.length === 0 && result.skippedExistingIssue.length === 0) {
console.log('No old specs eligible for archival.');
return;
Expand All @@ -112,6 +149,9 @@ module.exports = {
archiveIssueExists,
archiveSpecs,
cutoffTimestamp,
findArchiveIssue,
findArchiveIssueWithRetry,
formatIssueLine,
listEarliestSpecs,
parseUtcDatePrefix,
selectSpecsToArchive
Expand Down
50 changes: 49 additions & 1 deletion scripts/ci/test-archive-old-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ const {
} = require('./archive-old-specs');

function noExistingArchiveRunner(calls = []) {
const dumpedSpecs = new Set();
return (cmd, args) => {
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 '';
};
}
Expand Down Expand Up @@ -72,18 +75,21 @@ function testDeletesOnlyAfterSuccessfulDump() {
makeSpec(specsDir, '20260601-ok');
makeSpec(specsDir, '20260602-fails');
const calls = [];
const dumpedSpecs = new Set();

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') {
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/);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -151,6 +196,9 @@ function main() {
testSelectsOnlyMoreThanTenDaysOld();
testDeletesOnlyAfterSuccessfulDump();
testExistingArchiveIssueDeletesWithoutDumpingAgain();
testRecordsIssueCreatedByDump();
testFailsWhenDumpDoesNotCreateArchiveIssue();
testRetriesIssueLookupAfterDumpUntilSearchCatchesUp();
testMissingSpecsDirectoryFailsFast();
testRunsGlobalZestDevDump();
console.log('archive-old-specs tests passed');
Expand Down
Loading