Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/skills/project-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ to GitHub.

## Procedure

0. **Detach the engine copy** (fresh template copies only): `make init`
deletes the copied engine — its only home is
williamdemeo/github-project — and installs a consumer flake taking
it as a pinned input. Then `nix flake lock` (or set `GHPROJECT_DIR`
to an engine checkout). Skip if `scripts/gh_project_populate.py` is
already absent. All make targets below work the same before and
after.
1. **Author the plan** by replacing the worked example in
`docs/GITHUB_PROJECT.md`, keeping every structural convention:
- `**Repository**: \`owner/name\`` header line (this is where the
Expand Down
91 changes: 82 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
name: ci

# The template's own CI: structural lint of the example plan, the test
# suite (offline; a recorded fake gh), shell lint, and a minimal Python
# lint. Deliberately not Nix-based — consumers must be able to use this
# template with nothing but python3, gh, and make (the flake is a
# convenience, never a requirement).
# CI for both lives of this tree. In the engine repository (and in
# template copies that have not yet run `make init`) the local engine
# is present: its test suite runs and the plan is linted with it. In a
# detached consumer project the engine steps skip themselves (hashFiles
# on an absent file is empty), an engine checkout is fetched, and the
# plan lint still runs — so consumers keep a structural gate on
# docs/GITHUB_PROJECT.md with no engine files in their tree.
#
# Deliberately not Nix-based: the flake is the primary *distribution*
# channel, but CI here must not force Nix on consumers (setup-python is
# enough). Engine-repo flake checks run in the separate nix job below.

on:
pull_request:
Expand All @@ -28,12 +34,35 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Structural lint of the example plan
run: make lint

- name: Test suite
- name: Engine test suite (local engine present)
if: hashFiles('scripts/gh_project_populate.py') != ''
run: make test

# Pinned, never floating `main`: a consumer's CI must not change
# behavior underneath an unchanged commit. Resolution order:
# the github-project rev in flake.lock (the pin Nix consumers
# upgrade deliberately), the GHPROJECT_ENGINE_REF repository
# variable, the default release tag.
- name: Resolve the engine pin (detached consumer projects only)
id: enginepin
if: hashFiles('scripts/gh_project_populate.py') == ''
run: |
rev=$(jq -r '.nodes["github-project"].locked.rev // empty' \
flake.lock 2>/dev/null || true)
echo "ref=${rev:-${{ vars.GHPROJECT_ENGINE_REF || 'v0.2.0' }}}" \
>> "$GITHUB_OUTPUT"

- name: Fetch the engine (detached consumer projects only)
if: hashFiles('scripts/gh_project_populate.py') == ''
uses: actions/checkout@v4
with:
repository: williamdemeo/github-project
ref: ${{ steps.enginepin.outputs.ref }}
path: .github-project-engine
Comment on lines +57 to +61

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — read-only permissions make this the lower-stakes sibling of the freshness-workflow finding, but the determinism argument stands on its own: a consumer commit must not change CI behavior because upstream moved. Fixed in cb42872 with the same pin resolution as there (consumer's flake.lock rev → GHPROJECT_ENGINE_REF variable → default release tag), so both workflows share one story and Nix consumers still have a single pin to manage.


- name: Structural lint of the plan file
run: make lint GHPROJECT_DIR=.github-project-engine

static:
runs-on: ubuntu-latest
steps:
Expand All @@ -47,6 +76,50 @@ jobs:
run: shellcheck scripts/ci/*.sh

- name: Python lint (errors and pyflakes only)
if: hashFiles('scripts/**/*.py') != ''
run: |
pip install ruff
ruff check --select E9,F scripts/

# Engine-repo only: build the package and run the suite in the Nix
# sandbox. Skipped automatically in detached consumers (no local
# engine → nothing to build here; their flake pins the engine input).
nix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Detect a local engine flake
id: engine
run: |
if [ -f scripts/VERSION ] && [ -f flake.nix ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi

- uses: cachix/install-nix-action@v27
if: steps.engine.outputs.present == 'true'

- name: nix flake check (builds the engine, runs the suite sandboxed)
if: steps.engine.outputs.present == 'true'
run: nix flake check

# The primary acceptance path end to end: assemble a detached
# consumer (make init), point its flake input at the engine under
# test, and run the engine through the consumer's own dev shell
# and re-exported apps — so breakage in templates/consumer/ or in
# the detach lifecycle fails CI, not the first real consumer.
- name: Consumer smoke test (detach, then run via the consumer flake)
if: steps.engine.outputs.present == 'true'
run: |
consumer=$(mktemp -d)/project
cp -r . "$consumer"
cd "$consumer"
rm -rf .git result
make init INIT_YES=1
git init -q && git add -A
nix flake lock --override-input github-project "path:$GITHUB_WORKSPACE"
test "$(nix develop --command make --no-print-directory engine-mode | tail -1)" = path
nix develop --command make lint
nix run .#lint -- docs/GITHUB_PROJECT.md
28 changes: 28 additions & 0 deletions .github/workflows/project-plan-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,38 @@ jobs:
with:
python-version: "3.12"

# Projects that ran `make init` carry no engine copy; fetch one —
# PINNED, never floating `main`: this job runs with write
# permissions, so executing whatever is currently on upstream
# main would let an upstream change become unreviewed code
# execution here. The pin is the project's own choice, resolved
# in order: the github-project rev in flake.lock (the same pin
# Nix consumers already upgrade deliberately), the
# GHPROJECT_ENGINE_REF repository variable, the default release
# tag. The engine repo itself, and template copies that have not
# detached yet, skip both steps and use their local scripts/.
- name: Resolve the engine pin (detached consumer projects only)
id: enginepin
if: hashFiles('scripts/gh_project_update.py') == ''
run: |
rev=$(jq -r '.nodes["github-project"].locked.rev // empty' \
flake.lock 2>/dev/null || true)
echo "ref=${rev:-${{ vars.GHPROJECT_ENGINE_REF || 'v0.2.0' }}}" \
>> "$GITHUB_OUTPUT"

- name: Fetch the engine (detached consumer projects only)
if: hashFiles('scripts/gh_project_update.py') == ''
uses: actions/checkout@v4
with:
repository: williamdemeo/github-project
ref: ${{ steps.enginepin.outputs.ref }}
path: .github-project-engine
Comment on lines +56 to +60

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the sharpest finding of this review, and the write permissions make it a genuine supply-chain hazard rather than a determinism nit: an upstream push to main became unreviewed code execution with contents:write / pull-requests:write in every detached consumer. Fixed in cb42872: the fetch is now pinned, resolved in order — the github-project rev in the consumer's own flake.lock (so Nix consumers manage exactly one pin, upgraded by the same deliberate nix flake update github-project), the GHPROJECT_ENGINE_REF repository variable, then the default release tag baked into the workflow. Floating main is no longer reachable from either workflow.


- name: Check, and update if armed
env:
GH_TOKEN: ${{ github.token }}
PROJECT_PLAN_AUTO_UPDATE: ${{ vars.PROJECT_PLAN_AUTO_UPDATE }}
GHPROJECT_DIR: .github-project-engine
# Required here. The update script strips GH_TOKEN and
# GITHUB_TOKEN by default, which is exactly how Actions
# authenticates; without this every gh call exits with a
Expand Down
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ Corollaries for editing:
frozen dataclasses, pure parsing/planning core with `gh` calls at the
edges, a `File:` header docstring in every file, and a test for every
pure function. Match it.
- The scripts directory is self-contained on purpose (vendored `_utils`,
`scripts/VERSION`): downstream projects re-vendor it wholesale. Do
not add imports that reach outside `scripts/`, and bump
`scripts/VERSION` when its behavior changes.
- The engine under `scripts/` has exactly one home: this repository.
Consumer projects never carry a copy — `make init` detaches a fresh
template copy, after which the Makefile resolves the engine from a
Nix dev shell (flake input), an installed `gh-project-*` CLI, or a
`GHPROJECT_DIR` checkout. Keep the engine self-contained (no imports
reaching outside `scripts/`), and bump `scripts/VERSION` — which is
also the flake package version — when its behavior changes.
- If `scripts/gh_project_populate.py` is absent, you are in a detached
consumer project: the engine's code and tests live upstream; work
with the plan file and the make targets only.
124 changes: 108 additions & 16 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,49 +1,141 @@
# File: Makefile
#
# The primary UX for this template. `make help` (the default) lists the
# targets; `make populate` / `make update` are the two directions of the
# contract stated in the README.
# The primary UX for the plan-file workflow. `make help` (the default)
# lists the targets; `make populate` / `make update` are the two
# directions of the contract stated in the README.
#
# The engine (gh_project_*.py) has exactly one home — the
# williamdemeo/github-project repository — and this Makefile finds it
# through whichever channel is available, in order:
#
# local scripts/gh_project_populate.py exists in THIS tree — the
# engine repository itself, or a template copy that has not
# yet run `make init`
# path gh-project-* CLIs on PATH — a Nix dev shell with the
# engine package (see templates/consumer/flake.nix), a
# `nix profile install`, or a future pipx install
# checkout GHPROJECT_DIR points at a checkout of the engine repo
#
# Variables:
# PLAN plan file (default: docs/GITHUB_PROJECT.md)
# REPO owner/name override (default: the plan file's
# **Repository**: header)
# PYTHON interpreter (default: python3)
# GHPROJECT_DIR engine checkout path (checkout channel only)
# NO_ENV_PREFIX set (to anything) wherever gh authenticates *through*
# GH_TOKEN/GITHUB_TOKEN — GitHub Actions included; by
# default the scripts strip those variables so they
# default the engine strips those variables so they
# cannot shadow a keychain-stored token.

PYTHON ?= python3
PLAN ?= docs/GITHUB_PROJECT.md
REPO ?=
GHPROJECT_DIR ?=

FLAGS := $(if $(REPO),--repo $(REPO)) $(if $(NO_ENV_PREFIX),--no-env-prefix)

.PHONY: help populate-dry populate update update-check lint test guard-gh
ifneq (,$(wildcard scripts/gh_project_populate.py))
ENGINE_MODE := local
POPULATE := $(PYTHON) scripts/gh_project_populate.py
UPDATE := $(PYTHON) scripts/gh_project_update.py
LINT := $(PYTHON) scripts/gh_project_lint.py
else ifneq (,$(shell command -v gh-project-update 2>/dev/null))
ENGINE_MODE := path
POPULATE := gh-project-populate
UPDATE := gh-project-update
LINT := gh-project-lint
else ifneq (,$(wildcard $(GHPROJECT_DIR)/scripts/gh_project_populate.py))
ENGINE_MODE := checkout
POPULATE := $(PYTHON) $(GHPROJECT_DIR)/scripts/gh_project_populate.py
UPDATE := $(PYTHON) $(GHPROJECT_DIR)/scripts/gh_project_update.py
LINT := $(PYTHON) $(GHPROJECT_DIR)/scripts/gh_project_lint.py
else
ENGINE_MODE := missing
endif

.PHONY: help populate-dry populate update update-check lint test init \
guard-gh guard-engine engine-mode
.DEFAULT_GOAL := help

# In `path` mode the resolved CLI is (typically) a Nix wrapper carrying
# its own `gh` on a wrapped PATH, so probing the caller's ambient PATH
# would fail a `nix profile install` user whose tooling actually works;
# the CLI reports its own error if gh is genuinely absent. local and
# checkout modes run bare python3 scripts and do need ambient gh.
ifeq ($(ENGINE_MODE),path)
GH_GUARD :=
else
GH_GUARD := guard-gh
endif

help: ## Show this help
@grep -hE '^[a-zA-Z][a-zA-Z0-9_-]*:.*## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*## "} {printf " \033[1m%-14s\033[0m %s\n", $$1, $$2}'

populate-dry: guard-gh ## Preview what populate would create (no mutations)
$(PYTHON) scripts/gh_project_populate.py $(PLAN) $(FLAGS) --dry-run
populate-dry: guard-engine $(GH_GUARD) ## Preview what populate would create (no mutations)
$(POPULATE) $(PLAN) $(FLAGS) --dry-run

populate: guard-gh ## Create labels, milestones, issues on GitHub from $(PLAN)
$(PYTHON) scripts/gh_project_populate.py $(PLAN) $(FLAGS)
populate: guard-engine $(GH_GUARD) ## Create labels, milestones, issues on GitHub from $(PLAN)
$(POPULATE) $(PLAN) $(FLAGS)

update: guard-gh ## Rewrite $(PLAN)'s generated regions from live GitHub state
$(PYTHON) scripts/gh_project_update.py $(PLAN) $(FLAGS)
update: guard-engine $(GH_GUARD) ## Rewrite $(PLAN)'s generated regions from live GitHub state
$(UPDATE) $(PLAN) $(FLAGS)

update-check: guard-gh ## Report whether $(PLAN) is stale (exit 1) without writing
$(PYTHON) scripts/gh_project_update.py $(PLAN) $(FLAGS) --check
update-check: guard-engine $(GH_GUARD) ## Report whether $(PLAN) is stale without writing
$(UPDATE) $(PLAN) $(FLAGS) --check

lint: ## Validate $(PLAN)'s structure (no network)
$(PYTHON) scripts/gh_project_lint.py $(PLAN)
lint: guard-engine ## Validate $(PLAN)'s structure (no network)
$(LINT) $(PLAN)

test: ## Run the test suite (no network; uses a recorded fake gh)
test: ## Run the engine test suite (offline; engine repo / pre-init copies only)
ifneq (,$(wildcard scripts/tests))
$(PYTHON) -m unittest discover -s scripts/tests
else
@echo "No local engine copy: the engine's tests run in its own repository"
@echo "(https://github.com/williamdemeo/github-project)."
endif

init: ## Detach this template copy from the engine (one-time, consumer projects)
ifneq ($(ENGINE_MODE),local)
@echo "Nothing to do: this tree carries no local engine copy."
else
@echo "This removes the copied engine from this tree. The engine's home is"
@echo "https://github.com/williamdemeo/github-project; after this, make"
@echo "resolves it from PATH (Nix dev shell) or GHPROJECT_DIR (checkout)."
@if [ -z "$(INIT_YES)" ]; then \
printf 'Continue? [y/N] '; read ans; \
case "$$ans" in y|Y|yes) ;; *) echo "Aborted."; exit 1;; esac; \
fi
rm -f scripts/gh_project_populate.py scripts/gh_project_update.py \
scripts/gh_project_lint.py scripts/_gh_project_lib.py \
scripts/VERSION
rm -rf scripts/_utils scripts/tests scripts/__pycache__
@if [ -f templates/consumer/flake.nix ]; then \
mv templates/consumer/flake.nix flake.nix; \
rm -f flake.lock; \
rm -rf templates; \
echo "Installed the consumer flake (engine as a pinned input)."; \
fi
@echo
@echo "Done. Next steps:"
@echo " 1. nix flake lock # pin the engine (Nix channel), or set"
@echo " GHPROJECT_DIR to an engine checkout instead"
@echo " 2. edit $(PLAN), then: make lint && make populate-dry"
@echo " 3. commit the result"
endif

engine-mode: ## Print which engine channel this invocation resolved (debugging)
@echo "$(ENGINE_MODE)"

guard-engine:
ifeq ($(ENGINE_MODE),missing)
@echo "error: the github-project engine was not found. Provide it via one of:"; \
echo " - Nix: enter the dev shell (nix develop) wired by your flake's"; \
echo " github-project input, so the gh-project-* CLIs are on PATH"; \
echo " - checkout: clone https://github.com/williamdemeo/github-project"; \
echo " and pass GHPROJECT_DIR=/path/to/that/clone"; \
exit 1
endif

guard-gh:
@command -v gh >/dev/null || { \
Expand Down
Loading
Loading