diff --git a/.claude/skills/project-setup/SKILL.md b/.claude/skills/project-setup/SKILL.md index c6528bc..9022fd8 100644 --- a/.claude/skills/project-setup/SKILL.md +++ b/.claude/skills/project-setup/SKILL.md @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76ecaf4..646045b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 + + - name: Structural lint of the plan file + run: make lint GHPROJECT_DIR=.github-project-engine + static: runs-on: ubuntu-latest steps: @@ -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 diff --git a/.github/workflows/project-plan-update.yml b/.github/workflows/project-plan-update.yml index d25ccd0..8fa344b 100644 --- a/.github/workflows/project-plan-update.yml +++ b/.github/workflows/project-plan-update.yml @@ -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 + - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 4a631c5..d7c1939 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Makefile b/Makefile index 09040b4..bd55c97 100644 --- a/Makefile +++ b/Makefile @@ -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 || { \ diff --git a/README.md b/README.md index 438cd59..4efa928 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,23 @@ that.) cd your-project ``` -2. **Author your plan.** Replace the worked example in +2. **Detach the engine copy** (one time). Template creation copies + this whole repository — including the engine, which has exactly one + home ([see the channels](#the-engine-getting-and-upgrading-it) + below). Keeping a copy would freeze you at creation-day behavior, + so: + + ```sh + make init + ``` + + deletes the copied engine, installs a consumer `flake.nix` that + takes the engine as a pinned input, and prints the next steps + (`nix flake lock` to pin — or set `GHPROJECT_DIR` to an engine + checkout if you don't use Nix). Every `make` target below works + identically before and after this step. + +3. **Author your plan.** Replace the worked example in `docs/GITHUB_PROJECT.md` with your milestones, labels, and issues — keep the structure (the example demonstrates every construct exactly once). Set the header line to your repository: @@ -59,7 +75,7 @@ that.) **Repository**: `you/your-project` ``` -3. **Validate and preview** (lint needs no network; the dry run makes +4. **Validate and preview** (lint needs no network; the dry run makes read-only API calls): ```sh @@ -67,7 +83,7 @@ that.) make populate-dry ``` -4. **Push the structure to GitHub** (prompts before creating): +5. **Push the structure to GitHub** (prompts before creating): ```sh make populate @@ -76,7 +92,7 @@ that.) Each created issue's number is written back into the file immediately, as a `(#N)` suffix on its heading — commit the result. -5. **From then on**: work the issues on GitHub as usual, and run +6. **From then on**: work the issues on GitHub as usual, and run ```sh make update @@ -111,52 +127,91 @@ summary — it never commits. To let it act, set the repository variable Delete the workflow file if you want no scheduled runs at all. -## Upgrading a project created from this template +## The engine: getting and upgrading it -Template consumers fork at creation time and never see later -improvements — except that the tooling is deliberately confined to two -self-contained paths (`scripts/`, plus the workflow shell in -`scripts/ci/`), so re-vendoring is one command. From your project's -root: +Your project owns its **data** — `docs/GITHUB_PROJECT.md`, a thin +Makefile, the workflows. The **engine** (`gh_project_populate.py`, +`gh_project_update.py`, `gh_project_lint.py` and their library) is +never copied into consumer projects: it has exactly one home, this +repository, and the Makefile finds it through whichever channel is +available: -```sh -V=$(curl -fsSL https://raw.githubusercontent.com/williamdemeo/github-project/main/scripts/VERSION) \ - && curl -fsSL https://github.com/williamdemeo/github-project/archive/refs/tags/v$V.tar.gz \ - | tar -xz --strip-components=1 "github-project-$V/scripts" "github-project-$V/Makefile" -``` +1. **Nix flake input** (primary). `make init` installs a `flake.nix` + taking this repository as an input; your `flake.lock` pins the + engine version. `nix develop` puts the `gh-project-*` CLIs on PATH + (the Makefile finds them there), and the re-exported apps allow + direct runs: -Then run `make test` and review `git diff` before committing. Your -`docs/GITHUB_PROJECT.md`, workflows, and everything else are untouched; -`scripts/VERSION` records which release you now carry. + ```sh + nix run .#update -- docs/GITHUB_PROJECT.md + ``` + + Upgrade deliberately: + + ```sh + nix flake update github-project + ``` + + (If your project already has a flake, merge the input and outputs + from `templates/consumer/flake.nix` into it instead.) + +2. **Checkout** (no Nix). Clone this repository anywhere and point + `GHPROJECT_DIR` at it — the engine is stdlib-only Python 3.11+, so a + plain `python3` runs it: + + ```sh + make update GHPROJECT_DIR=~/git/github-project + ``` + + Upgrading is `git pull` in that checkout. + +3. **Installed CLI** (planned). A `pyproject.toml` with console entry + points for `uv tool install` / pipx from a tagged release; the + Makefile already resolves `gh-project-*` from PATH, so this channel + needs no consumer-side changes when it lands. + +In CI, consumer projects need no engine either: the shipped workflows +fetch an engine checkout automatically when the tree has no local copy — +**pinned**, never floating `main`. The pin resolves in order: the +`github-project` revision in your `flake.lock` (so Nix consumers have +exactly one pin to manage), the `GHPROJECT_ENGINE_REF` repository +variable, then the default release tag baked into the workflow. + +The engine's own `make test` (115 offline tests) and `nix flake check` +run in this repository — `scripts/VERSION` is the engine version your +lock or checkout carries. ## The Nix path A machine with [Nix](https://nixos.org/) (flakes enabled) needs nothing -else installed — not even Python: - -```sh -nix develop # provides python3, gh, gnumake -make lint -``` +else installed — not even Python. In this repository (or a template +copy): `nix develop` provides python3, gh, and make. In a detached +consumer project, `nix develop` additionally carries the engine CLIs, +pinned by your lock. -The flake is a convenience, never a requirement: this repository's own -CI uses plain `setup-python`, and everything documented above works -without Nix. +The flake is the primary way the engine is *distributed*, but never a +requirement for *using* it: the setup-python CI jobs and the checkout +channel work without Nix. ## Repository layout ``` docs/GITHUB_PROJECT.md the roadmap (a worked example until you replace it) -scripts/ self-contained tooling (re-vendorable as a unit) +scripts/ THE ENGINE — lives here and only here; `make init` + removes it from projects created off the template gh_project_populate.py file → GitHub gh_project_update.py GitHub → file gh_project_lint.py structural validation, no network _gh_project_lib.py shared parsing, planning, gh client - _utils/ vendored functional-Python core (Result, file_ops, ...) + _utils/ functional-Python core (Result, file_ops, ...) tests/ offline test suite (recorded fake `gh`) - VERSION which release of the tooling this tree carries + ci/ workflow shell — survives `make init` + VERSION the engine version (also the flake package version) +templates/consumer/ the flake.nix `make init` installs in consumers +flake.nix the engine package + apps (+ dev shell) .claude/skills/ committed Claude Code skills for the two workflows -.github/workflows/ CI + the opt-in freshness workflow +.github/workflows/ CI + the opt-in freshness workflow (both self-adapt + to trees with no engine copy) ``` ## Design notes @@ -171,11 +226,15 @@ scripts/ self-contained tooling (re-vendorable as a unit) reported instead of silently creating a parallel scheme. - **Staged and rate-limit-aware**: `--labels-only`, `--milestones-only`, `--issues-only`, `--start-from M2-3`, `--delay`. +- **The engine is referenced, never copied**: consumer projects hold + data (the plan file, a thin Makefile) and pin the engine by flake + lock or checkout — a copied engine decays the moment the real one + improves, which is the failure mode this repository exists to end. - Provenance: this template distills the gh-project tooling that grew up in [agda-algebras](https://github.com/ualib/agda-algebras), [williamdemeo.github.io](https://github.com/williamdemeo/williamdemeo.github.io), - and agda-native-air, and is now its upstream — fixes land here and - downstream projects re-vendor. + and agda-native-air, and is now its single home — fixes land here and + reach consumers through their engine pin. ## License diff --git a/flake.nix b/flake.nix index 09db60b..6d2f6d3 100644 --- a/flake.nix +++ b/flake.nix @@ -1,12 +1,23 @@ # File: flake.nix # -# Strictly optional convenience: `nix develop` drops you into a shell -# with everything the Makefile needs (python3, gh, gnumake), so a -# machine without Python can still run the tooling. Nothing in this -# repository requires Nix — the plain path is documented first in the -# README, and CI uses setup-python, so consumers are never Nix-bound. +# The engine's single home (issue #2). Outputs: +# +# packages.default the engine: gh-project-{populate,update,lint} +# CLIs wrapping the stdlib-only scripts, with +# python3 and gh on their runtime PATH +# apps.{populate,update,update-check,lint} +# `nix run .#update -- docs/GITHUB_PROJECT.md` +# checks.engine-tests the offline unit/integration suite +# devShells.default hacking on this repository +# +# Consumers add this repository as a flake input, put the package in +# their dev shell (or re-export the apps), and pin it in their +# flake.lock — upgrades happen deliberately via `nix flake update +# github-project`. Nothing here is required to USE the engine: it is +# stdlib-only Python, so a plain checkout plus python3 works too (see +# the README's consumption channels). { - description = "Dev shell for the github-project template (python3, gh, make)"; + description = "github-project engine: populate, update, and lint a GitHub roadmap from one plan file"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; @@ -14,10 +25,82 @@ let systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forAllSystems = f: nixpkgs.lib.genAttrs systems - (system: f nixpkgs.legacyPackages.${system}); + (system: f system nixpkgs.legacyPackages.${system}); + version = nixpkgs.lib.trim (builtins.readFile ./scripts/VERSION); in { - devShells = forAllSystems (pkgs: { + packages = forAllSystems (system: pkgs: rec { + default = gh-project-engine; + gh-project-engine = pkgs.stdenv.mkDerivation { + pname = "gh-project-engine"; + inherit version; + src = ./scripts; + # python3 must be on the check-phase PATH: the recorded fake + # `gh` the integration tests exec is itself a python3 script. + nativeBuildInputs = [ pkgs.makeWrapper pkgs.python3 ]; + dontBuild = true; + + # The suite is offline by design (recorded fake `gh`), so it + # can run inside the sandbox; `nix flake check` gets teeth. + # patchShebangs first: the fake gh's `#!/usr/bin/env python3` + # has no /usr/bin/env in the sandbox, and the resulting ENOENT + # masquerades as "gh: No such file or directory". + doCheck = true; + checkPhase = '' + patchShebangs tests/fake_gh + ${pkgs.python3.interpreter} -m unittest discover -s tests + ''; + + installPhase = '' + lib=$out/lib/gh-project-engine + mkdir -p $lib $out/bin + cp gh_project_populate.py gh_project_update.py \ + gh_project_lint.py _gh_project_lib.py VERSION $lib/ + cp -r _utils $lib/_utils + for tool in populate update lint; do + makeWrapper ${pkgs.python3.interpreter} $out/bin/gh-project-$tool \ + --add-flags "$lib/gh_project_$tool.py" \ + --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.gh ]} + done + ''; + + meta = { + description = "Populate, update, and lint a GitHub roadmap from one plan file"; + homepage = "https://github.com/williamdemeo/github-project"; + license = pkgs.lib.licenses.mit; + mainProgram = "gh-project-update"; + }; + }; + }); + + apps = forAllSystems (system: pkgs: + let + engine = self.packages.${system}.gh-project-engine; + app = exe: { + type = "app"; + program = "${engine}/bin/${exe}"; + }; + updateCheckBin = pkgs.writeShellApplication { + name = "gh-project-update-check"; + text = ''exec ${engine}/bin/gh-project-update --check "$@"''; + }; + in + rec { + populate = app "gh-project-populate"; + update = app "gh-project-update"; + lint = app "gh-project-lint"; + update-check = { + type = "app"; + program = "${updateCheckBin}/bin/gh-project-update-check"; + }; + default = update; + }); + + checks = forAllSystems (system: pkgs: { + engine-tests = self.packages.${system}.gh-project-engine; + }); + + devShells = forAllSystems (system: pkgs: { default = pkgs.mkShell { packages = [ pkgs.python3 pkgs.gh pkgs.gnumake ]; }; diff --git a/scripts/VERSION b/scripts/VERSION index 6e8bf73..0ea3a94 100644 --- a/scripts/VERSION +++ b/scripts/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/scripts/ci/project-plan-update.sh b/scripts/ci/project-plan-update.sh index cce12cc..6af4a33 100755 --- a/scripts/ci/project-plan-update.sh +++ b/scripts/ci/project-plan-update.sh @@ -54,10 +54,32 @@ set -uo pipefail REPO="${REPO:-${GITHUB_REPOSITORY:-}}" PLAN="${PLAN:-docs/GITHUB_PROJECT.md}" MODE="${PROJECT_PLAN_AUTO_UPDATE:-}" -UPDATE="${UPDATE:-python3 scripts/gh_project_update.py}" SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}" BRANCH="project-plan-update" +# Engine resolution, mirroring the Makefile's channels: an explicit +# $UPDATE override wins; otherwise local scripts (engine repo or +# pre-init template copy), then a gh-project-update CLI on PATH, then a +# $GHPROJECT_DIR checkout (the workflow fetches one when no local copy +# exists). +if [ -z "${UPDATE:-}" ]; then + if [ -f scripts/gh_project_update.py ]; then + UPDATE="python3 scripts/gh_project_update.py" + elif command -v gh-project-update >/dev/null 2>&1; then + UPDATE="gh-project-update" + elif [ -n "${GHPROJECT_DIR:-}" ] \ + && [ -f "$GHPROJECT_DIR/scripts/gh_project_update.py" ]; then + UPDATE="python3 $GHPROJECT_DIR/scripts/gh_project_update.py" + else + { + printf '### The freshness check could not run\n\n' + printf 'No engine found: no local scripts/, no gh-project-update on\n' + printf 'PATH, and no usable GHPROJECT_DIR.\n' + } >> "$SUMMARY" + exit 0 + fi +fi + flags=() [ -n "${REPO}" ] && flags+=(--repo "$REPO") [ -n "${NO_ENV_PREFIX:-}" ] && flags+=(--no-env-prefix) diff --git a/scripts/tests/test_makefile_stub.py b/scripts/tests/test_makefile_stub.py new file mode 100644 index 0000000..6540350 --- /dev/null +++ b/scripts/tests/test_makefile_stub.py @@ -0,0 +1,146 @@ +""" +File: scripts/tests/test_makefile_stub.py + +Description: + Tests for the channel-agnostic Makefile stub and the `make init` + detach step (issue #2): after init, the engine copy is gone, the + consumer flake is installed, and the stub resolves the engine through + each remaining channel (PATH CLI, GHPROJECT_DIR checkout) or fails + with the channel-listing error. + + These run `make` on a throwaway copy of the repository tree — still + offline, no nix required (the Nix channel itself is exercised by + `nix flake check` in CI, not here). +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# In the Nix sandbox the flake builds from ./scripts alone, so the +# surrounding repository tree (Makefile, templates/) is absent — these +# tests only make sense against a full checkout. +FULL_TREE = ( + (REPO_ROOT / "Makefile").exists() + and (REPO_ROOT / "templates" / "consumer" / "flake.nix").exists() +) + +ENGINE_FILES = ( + "scripts/gh_project_populate.py", + "scripts/gh_project_update.py", + "scripts/gh_project_lint.py", + "scripts/_gh_project_lib.py", + "scripts/VERSION", + "scripts/_utils", + "scripts/tests", +) + + +@unittest.skipIf(shutil.which("make") is None, "GNU make not available") +@unittest.skipIf(not FULL_TREE, "full repository tree not available") +class MakeInit(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tree = Path(self._tmp.name) / "consumer" + shutil.copytree( + REPO_ROOT, self.tree, + ignore=shutil.ignore_patterns( + ".git", "__pycache__", "result", ".direnv"), + symlinks=True, + ) + + def make(self, *args: str, env_extra: dict | None = None, + cwd: Path | None = None) -> subprocess.CompletedProcess: + env = {**os.environ, **(env_extra or {})} + # --no-print-directory: under `make test` these are sub-makes, + # and the inherited -w directory chatter would pollute the + # single-line outputs the assertions read. + return subprocess.run( + ["make", "--no-print-directory", *args], cwd=cwd or self.tree, + env=env, capture_output=True, text=True, timeout=300, + ) + + def run_init(self) -> None: + proc = self.make("init", "INIT_YES=1") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + + def test_init_detaches_the_engine(self) -> None: + self.run_init() + for rel in ENGINE_FILES: + self.assertFalse((self.tree / rel).exists(), rel) + # The freshness workflow's shell survives; so does the plan. + self.assertTrue((self.tree / "scripts/ci/project-plan-update.sh").exists()) + self.assertTrue((self.tree / "docs/GITHUB_PROJECT.md").exists()) + # The consumer flake replaced the engine flake; the engine's + # lock and the template dir are gone. + flake = (self.tree / "flake.nix").read_text(encoding="utf-8") + self.assertIn("github-project.url", flake) + self.assertFalse((self.tree / "flake.lock").exists()) + self.assertFalse((self.tree / "templates").exists()) + + def test_init_is_idempotent(self) -> None: + self.run_init() + again = self.make("init", "INIT_YES=1") + self.assertEqual(again.returncode, 0, again.stderr) + self.assertIn("Nothing to do", again.stdout) + + def test_post_init_modes(self) -> None: + self.run_init() + + # No channel available: mode is `missing`, targets fail with the + # channel-listing error. + mode = self.make("engine-mode", env_extra={"GHPROJECT_DIR": ""}) + self.assertEqual(mode.stdout.strip(), "missing") + lint = self.make("lint", env_extra={"GHPROJECT_DIR": ""}) + self.assertNotEqual(lint.returncode, 0) + self.assertIn("engine was not found", lint.stdout + lint.stderr) + + # Checkout channel: GHPROJECT_DIR pointing at the engine repo. + lint = self.make("lint", f"GHPROJECT_DIR={REPO_ROOT}") + self.assertEqual(lint.returncode, 0, lint.stdout + lint.stderr) + self.assertIn("0 error(s)", lint.stdout) + + # PATH channel: gh-project-* shims ahead on PATH (stands in for + # the Nix dev shell / an installed CLI). + bindir = Path(self._tmp.name) / "bin" + bindir.mkdir() + for tool in ("populate", "update", "lint"): + shim = bindir / f"gh-project-{tool}" + shim.write_text( + "#!/bin/sh\n" + f'exec python3 "{REPO_ROOT}/scripts/gh_project_{tool}.py" "$@"\n', + encoding="utf-8", + ) + shim.chmod(0o755) + env = {"PATH": f"{bindir}:{os.environ['PATH']}", "GHPROJECT_DIR": ""} + mode = self.make("engine-mode", env_extra=env) + self.assertEqual(mode.stdout.strip(), "path") + lint = self.make("lint", env_extra=env) + self.assertEqual(lint.returncode, 0, lint.stdout + lint.stderr) + + # make test degrades to a pointer at the engine repository. + test = self.make("test") + self.assertEqual(test.returncode, 0, test.stderr) + self.assertIn("engine's tests run in its own repository", test.stdout) + + +@unittest.skipIf(shutil.which("make") is None, "GNU make not available") +@unittest.skipIf(not FULL_TREE, "full repository tree not available") +class PreInitTree(unittest.TestCase): + def test_engine_repo_resolves_local(self) -> None: + proc = subprocess.run( + ["make", "--no-print-directory", "engine-mode"], cwd=REPO_ROOT, + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(proc.stdout.strip(), "local") + + +if __name__ == "__main__": + unittest.main() diff --git a/templates/consumer/flake.nix b/templates/consumer/flake.nix new file mode 100644 index 0000000..36724a1 --- /dev/null +++ b/templates/consumer/flake.nix @@ -0,0 +1,42 @@ +# File: flake.nix +# +# Consumer-side wiring for the github-project roadmap engine. `make +# init` installs this as the project's flake.nix (replacing the engine's +# own flake, which only makes sense in the engine repository). +# +# The engine arrives as a flake input, pinned by this project's +# flake.lock: `nix develop` puts the gh-project-* CLIs on PATH (the +# Makefile stub finds them there), and the re-exported apps allow +# `nix run .#update` directly. Upgrade deliberately with +# `nix flake update github-project`. +# +# If your project already has a flake, merge the input and the two +# output attributes into it instead of replacing it. +{ + description = "Project dev shell with the github-project roadmap engine"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + inputs.github-project.url = "github:williamdemeo/github-project"; + + outputs = { self, nixpkgs, github-project }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems + (system: f system nixpkgs.legacyPackages.${system}); + in + { + devShells = forAllSystems (system: pkgs: { + default = pkgs.mkShell { + packages = [ + pkgs.gnumake + pkgs.gh + github-project.packages.${system}.gh-project-engine + ]; + }; + }); + + # `nix run .#update -- docs/GITHUB_PROJECT.md`, pinned by this + # project's own flake.lock. + apps = forAllSystems (system: pkgs: github-project.apps.${system}); + }; +}