feat: one directory per rule, and path-addressed verify/test #434
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: 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." |