Skip to content

OpenSpec Rot Check

OpenSpec Rot Check #27

Workflow file for this run

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."