OpenSpec Rot Check #27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: OpenSpec Rot Check | |
| # A daily sweep of `main` for OpenSpec debris — the direction-agnostic backstop | |
| # the merge-time archive gate structurally cannot provide. | |
| # | |
| # Incremental-forward stacks land the change directory on `main` UNARCHIVED and | |
| # only archive it on the final slice (invariant I1: main ends clean, but may | |
| # transiently carry an in-flight change while the stack drains). If that stack | |
| # stalls or is abandoned part-way, the unarchived change sits on `main` | |
| # indefinitely. This sweep catches two failure modes on `main`: | |
| # | |
| # DONE — a change whose tasks are ALL checked but which was never moved under | |
| # archive/. Debris regardless of age → fails immediately. The tip | |
| # archive gate catches this at merge time; this is defence in depth | |
| # for an admin-merge or botched down-merge that slipped past it. | |
| # STALE — any unarchived change that is NOT done (work unfinished, tasks.md | |
| # empty/placeholder, or no tasks.md) with NO git activity in its | |
| # directory for STALE_DAYS. A stalled/abandoned stack rotting on main. | |
| # Fails after the window. Because every non-done change is treated as | |
| # live, an abandoned change can't slip the net by having an empty | |
| # tasks.md — the exact case this backstop exists for. | |
| # | |
| # Age is read from git history, never file mtime: a fresh `actions/checkout` | |
| # stamps every file's mtime to checkout time, so mtime would always look fresh. | |
| on: | |
| schedule: | |
| # Daily near the start of the Pacific business day. Cron is fixed-UTC and | |
| # can't follow DST, so 15:17 UTC lands at 8:17am PDT / 7:17am PST — early | |
| # either way. The :17 is deliberately off the top of the hour (GitHub delays | |
| # runs scheduled on the heavily-contended :00). | |
| - cron: "17 15 * * *" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| env: | |
| STALE_DAYS: "7" | |
| jobs: | |
| rot: | |
| name: "openspec: rot" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 # full history — git log needs it to date each change dir | |
| - name: Fail on stale or complete-but-unarchived OpenSpec changes | |
| run: | | |
| set -euo pipefail | |
| if [ ! -d openspec/changes ]; then | |
| echo "openspec/changes does not exist — nothing to check." | |
| exit 0 | |
| fi | |
| stale_days="${STALE_DAYS:-7}" | |
| now=$(date +%s) | |
| cutoff=$(( stale_days * 24 * 3600 )) | |
| fail=0 | |
| while IFS= read -r dir; do | |
| [ -z "$dir" ] && continue | |
| name=$(basename "$dir") | |
| tasks="$dir/tasks.md" | |
| if [ -f "$tasks" ]; then | |
| incomplete=$(grep -cE '^[[:space:]]*- \[ \]' "$tasks" || true) | |
| complete=$(grep -cE '^[[:space:]]*- \[[xX]\]' "$tasks" || true) | |
| else | |
| incomplete=0 | |
| complete=0 | |
| fi | |
| incomplete=${incomplete:-0} | |
| complete=${complete:-0} | |
| total=$(( incomplete + complete )) | |
| # DONE: every task checked but not archived. Debris now, not later. | |
| if [ "$total" -gt 0 ] && [ "$incomplete" -eq 0 ]; then | |
| echo "::error::openspec/changes/$name: all $total task(s) complete but the change is not archived. Move it under openspec/changes/archive/ (e.g. /openspec-archive-change $name)." | |
| fail=1 | |
| continue | |
| fi | |
| # Anything unarchived that is not DONE is LIVE by definition — its | |
| # work is unfinished, its tasks.md is empty/placeholder (new changes | |
| # start with an empty one), or it has no tasks.md at all. So there is | |
| # no "not-live" branch that could let an abandoned change slip the | |
| # net; staleness applies to every non-DONE change. | |
| # | |
| # Age is the last commit touching ANY file in the change directory | |
| # (NOT mtime — a fresh checkout stamps mtime to checkout time). Dating | |
| # the whole folder means real activity on proposal.md / specs counts | |
| # as "not abandoned", not just edits to tasks.md. | |
| last=$(git log -1 --format=%ct -- "$dir" || true) | |
| if [ -z "$last" ]; then | |
| echo "::warning::openspec/changes/$name: unarchived but has no git history to date; cannot assess staleness." | |
| continue | |
| fi | |
| age=$(( now - last )) | |
| if [ "$age" -gt "$cutoff" ]; then | |
| days=$(( age / 86400 )) | |
| echo "::error::openspec/changes/$name: live but untouched for ${days}d (> ${stale_days}d). A stalled/abandoned stack is leaving OpenSpec debris on main — land or close it, then archive the change." | |
| fail=1 | |
| fi | |
| done < <(find openspec/changes -mindepth 1 -maxdepth 1 -type d ! -name archive) | |
| if [ "$fail" -ne 0 ]; then | |
| echo "" | |
| echo "OpenSpec rot detected — see the errors above." | |
| exit 1 | |
| fi | |
| echo "No OpenSpec rot — OK." |