Skip to content

feat: one directory per rule, and path-addressed verify/test #447

feat: one directory per rule, and path-addressed verify/test

feat: one directory per rule, and path-addressed verify/test #447

name: PR OpenSpec Archive Check
# A change is archived exactly once, when ALL of its work has landed. In a
# stack of PRs, only the tip carries the archived change; the PRs below it still
# carry the in-flight change directory by design. So this check runs only on the
# tip of a stack (or a standalone PR) and is skipped on PRs that still have work
# stacked on top of them. Tip = no other OPEN PR targets this PR's head branch
# as its base.
#
# Drafts are skipped. A spec-only proposal is its own tip until its
# implementation is stacked on top, so the gate would otherwise demand it
# archive a change that has not been built yet — leaving the PR red for as long
# as the proposal is open, which is how teams learn to ignore red. A draft
# cannot merge, so the guarantee that no unarchived change reaches `main` is
# unaffected: the check runs when the PR is marked ready for review.
on:
pull_request:
# Both draft-transition events are REQUIRED, and neither is in the default
# set (opened/synchronize/reopened), because the archive job's condition
# depends on draft state:
# ready_for_review — without it a draft could be marked ready and merged
# on a stale green that was never re-evaluated. This
# one protects the guarantee.
# converted_to_draft — without it a PR that failed while ready keeps that
# failure after being converted back to draft, until
# some unrelated push happens to re-run it.
types:
[opened, synchronize, reopened, ready_for_review, converted_to_draft]
permissions:
contents: read
pull-requests: read
jobs:
stack-position:
name: "stack: position"
runs-on: ubuntu-latest
outputs:
is_tip: ${{ steps.detect.outputs.is_tip }}
steps:
- name: Determine whether this PR is the tip of its stack
id: detect
env:
GH_TOKEN: ${{ github.token }}
HEAD_REF: ${{ github.head_ref }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Count OPEN PRs that target this PR's head branch as their base.
# Any such PR means work is still stacked on top → not the tip.
# Do NOT guess on failure: if stack position can't be read, fail loudly
# rather than silently defaulting to "tip" (which runs the gate on a
# mid-stack PR) or "not tip" (which skips archiving on a real tip).
if ! children=$(gh pr list --repo "$REPO" --state open --base "$HEAD_REF" \
--json number --jq 'length'); then
echo "::error::Could not determine stack position (gh pr list failed). Re-run once the API is reachable."
exit 1
fi
children=${children:-0}
if [ "$children" -gt 0 ]; then
echo "is_tip=false" >> "$GITHUB_OUTPUT"
echo "This PR has $children open PR(s) stacked on top — changes still in flight."
echo "The OpenSpec archive check is skipped until this PR is the tip of the stack."
else
echo "is_tip=true" >> "$GITHUB_OUTPUT"
echo "No PRs are stacked on top — this PR is the tip (or standalone); the archive check will run."
fi
check-openspec-archived:
name: "stack: openspec-archived"
needs: stack-position
# Skipped on drafts. A proposal-only PR is its own tip — nothing is stacked
# on it yet — so the gate would demand it archive a change whose
# implementation has not been written, and it would sit red for as long as
# the proposal is open. A draft cannot merge, so nothing can reach `main`
# unarchived; the check runs the moment it is marked ready for review.
if: needs.stack-position.outputs.is_tip == 'true' && github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Fail if any unarchived OpenSpec change exists
run: |
set -euo pipefail
if [ ! -d openspec/changes ]; then
echo "openspec/changes does not exist — nothing to check."
exit 0
fi
UNARCHIVED=$(find openspec/changes -mindepth 1 -maxdepth 1 -type d ! -name archive)
if [ -n "$UNARCHIVED" ]; then
echo "::error::Unarchived OpenSpec change directories found under openspec/changes/:"
echo "$UNARCHIVED" | sed 's|^| - |'
echo ""
echo "This PR is the tip of its stack (no open PRs stacked on top), so its OpenSpec change must be archived before merge."
echo "Move it under openspec/changes/archive/ (e.g. /openspec-archive-change <name>) and commit."
exit 1
fi
echo "No unarchived OpenSpec changes — OK."