From 965d5073c3349b49b2cf706715c2cc7551aab56b Mon Sep 17 00:00:00 2001 From: didericis Date: Sat, 6 Jun 2026 22:02:21 -0400 Subject: [PATCH] ci(prd): add prd-new placeholder convention and numbering workflow Implements #213: PRDs use prd-new-.md while a PR is open; a post-merge workflow on main assigns sequential numbers and renames the file. A required PR check blocks prd-new-*.md from landing on main without going through the workflow. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/prd-check.yml | 39 ++++++++++ .gitea/workflows/prd-number.yml | 123 ++++++++++++++++++++++++++++++++ AGENTS.md | 9 +-- docs/prds/README.md | 11 +-- 4 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/prd-check.yml create mode 100644 .gitea/workflows/prd-number.yml diff --git a/.gitea/workflows/prd-check.yml b/.gitea/workflows/prd-check.yml new file mode 100644 index 0000000..7172e06 --- /dev/null +++ b/.gitea/workflows/prd-check.yml @@ -0,0 +1,39 @@ +# Block PRs that add prd-new-*.md files directly to main. +# +# prd-new-*.md files are placeholders — they must go through a PR so +# the post-merge prd-number workflow can assign a sequential number and +# rename the file. A direct push or a PR that slips through without +# triggering the check would leave an un-numbered PRD on main. + +name: prd-check + +on: + pull_request: + branches: + - main + paths: + - 'docs/prds/prd-new-*.md' + +jobs: + no-prd-new-on-main: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fail if prd-new-*.md files are present in the diff + run: | + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + new_prds=$(git diff --name-only --diff-filter=A "$base" "$head" \ + | grep -E '^docs/prds/prd-new-.+\.md$' || true) + if [ -n "$new_prds" ]; then + echo "ERROR: PRs to main must not add prd-new-*.md files directly." + echo "These files must be merged via a feature branch so the" + echo "prd-number workflow can assign a sequential number on merge:" + echo "$new_prds" + exit 1 + fi + echo "OK: no prd-new-*.md files added in this PR." diff --git a/.gitea/workflows/prd-number.yml b/.gitea/workflows/prd-number.yml new file mode 100644 index 0000000..10b7738 --- /dev/null +++ b/.gitea/workflows/prd-number.yml @@ -0,0 +1,123 @@ +# Assign sequential numbers to prd-new-*.md files on merge to main. +# +# When a PR merges to main and includes prd-new-*.md files this workflow: +# 1. Finds the next available NNNN number by scanning existing PRDs. +# 2. Renames each prd-new-*.md to NNNN-.md. +# 3. Updates the title header (# PRD prd-new: → # PRD NNNN:). +# 4. Flips Status: Draft → Active when the merge commit also touched +# files outside docs/prds/ (i.e. the implementation shipped together +# with the PRD). +# 5. Commits the renaming back to main. +# +# No-op if the push contained no prd-new-*.md files. + +name: prd-number + +on: + push: + branches: + - main + paths: + - 'docs/prds/prd-new-*.md' + +jobs: + assign-numbers: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Assign PRD numbers + run: | + python3 - <<'EOF' + import os + import re + import subprocess + import sys + from pathlib import Path + + prds_dir = Path("docs/prds") + + # Files added in the latest commit (HEAD vs HEAD~1). + result = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=A", "HEAD~1", "HEAD"], + capture_output=True, text=True, check=True, + ) + added = [Path(p) for p in result.stdout.splitlines()] + new_prds = [p for p in added if p.parent == prds_dir + and re.match(r"prd-new-.+\.md$", p.name)] + + if not new_prds: + print("No prd-new-*.md files added in this commit — nothing to do.") + sys.exit(0) + + # Determine whether non-PRD files were also changed (for Status flip). + all_changed = subprocess.run( + ["git", "diff", "--name-only", "HEAD~1", "HEAD"], + capture_output=True, text=True, check=True, + ).stdout.splitlines() + non_prd_changed = any( + not f.startswith("docs/prds/") for f in all_changed + ) + + # Find next available number. + existing = sorted( + int(m.group(1)) + for p in prds_dir.glob("*.md") + if (m := re.match(r"^(\d{4})-", p.name)) + ) + next_num = (max(existing) + 1) if existing else 1 + + for prd_path in sorted(new_prds): + slug = re.sub(r"^prd-new-", "", prd_path.stem) + new_name = f"{next_num:04d}-{slug}.md" + new_path = prds_dir / new_name + print(f" {prd_path.name} → {new_name}") + + content = prd_path.read_text() + + # Update title header. + content = re.sub( + r"^(#\s+PRD\s+)prd-new(:)", + rf"\g<1>{next_num:04d}\2", + content, + count=1, + flags=re.MULTILINE, + ) + + # Conditionally flip Status. + if non_prd_changed: + content = re.sub( + r"(\*\*Status:\*\*\s*)Draft", + r"\g<1>Active", + content, + count=1, + ) + + new_path.write_text(content) + subprocess.run(["git", "rm", str(prd_path)], check=True) + subprocess.run(["git", "add", str(new_path)], check=True) + next_num += 1 + + subprocess.run( + ["git", "commit", "-m", "ci(prd): assign sequential numbers to new PRDs"], + check=True, + ) + subprocess.run(["git", "push"], check=True) + EOF diff --git a/AGENTS.md b/AGENTS.md index a26b731..15f7c02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,11 @@ the container lifecycle and the copying of skills and env vars into it. - Three kinds of doc, each with its own conventions in-folder; see `docs/README.md` for when to write which: - - **PRDs** (`docs/prds/`) — one feature per file, numbered - `NNNN-kebab.md`. A `Status:` line tracks lifecycle: Draft → Active - (shipped to `main`) → Superseded/Retargeted. Format in - `docs/prds/README.md`. + - **PRDs** (`docs/prds/`) — one feature per file. While a PR is open + the file is named `prd-new-.md`; CI assigns a sequential + number on merge to `main` and renames it. A `Status:` line tracks + lifecycle: Draft → Active (shipped to `main`) → + Superseded/Retargeted. Format in `docs/prds/README.md`. - **Research notes** (`docs/research/`) — opinionated investigations; unnumbered kebab-case, freeform and verdict-first. See `docs/research/README.md`. diff --git a/docs/prds/README.md b/docs/prds/README.md index a947199..a064c8a 100644 --- a/docs/prds/README.md +++ b/docs/prds/README.md @@ -7,9 +7,12 @@ document vs. a research note or a decision record). ## Naming and numbering -`NNNN-kebab-title.md`, zero-padded and sequential (`0024-…`, `0025-…`). -Numbers are never reused; gaps are fine (there is no 0005). The number -is assigned at creation and stays fixed for the life of the doc. +New PRDs use a `prd-new-.md` placeholder name while the PR +is open. On merge to `main` a CI workflow assigns the next sequential +number (`0024-…`, `0025-…`), renames the file, and updates the title +header. Numbers are never reused; gaps are fine. + +Once numbered, the filename stays fixed for the life of the doc. ## Status @@ -23,7 +26,7 @@ The `Status:` line near the top tracks the PRD's lifecycle: ## Format ```markdown -# PRD NNNN: +# PRD prd-new: ← placeholder; CI fills in the number on merge - **Status:** Draft - **Author:**