From 4fd3baf3141199834bcf1f9daf0ac8b933bca25e Mon Sep 17 00:00:00 2001 From: William DeMeo Date: Sun, 16 Aug 2026 13:20:52 -0600 Subject: [PATCH 1/6] flake: ship the engine as a package with runnable apps (#2) packages.gh-project-engine wraps the stdlib-only scripts as gh-project-{populate,update,lint} CLIs with python3 and gh on their runtime PATH; version comes from scripts/VERSION. apps expose nix run .#{populate,update,update-check,lint}; checks.engine-tests runs the offline suite inside the sandbox (the recorded fake gh makes that possible), so nix flake check executes all 111 tests. The devShell is unchanged. Consumers take this repository as a flake input and pin it in their flake.lock; upgrading is a deliberate nix flake update github-project. AI-assisted: Claude Fable 5 (Anthropic) --- flake.nix | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 8 deletions(-) diff --git a/flake.nix b/flake.nix index 09db60b..2aa1af6 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,78 @@ 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. + doCheck = true; + checkPhase = '' + ${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 ]; }; From 5d3be7923a7070f99ba8b7c7f67c4d423c24585c Mon Sep 17 00:00:00 2001 From: William DeMeo Date: Sun, 16 Aug 2026 13:22:47 -0600 Subject: [PATCH 2/6] Makefile: resolve the engine by channel; add make init; ship consumer flake (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Makefile now finds the engine through whichever channel is available — local scripts/ (the engine repo itself, or a template copy that has not yet detached), gh-project-* CLIs on PATH (a Nix dev shell carrying the engine package, or a future pipx install), or a GHPROJECT_DIR checkout — and fails with a channel-listing error when none is present. make engine-mode prints the resolution for debugging. make init is the one-time detach step for projects created from the template: it deletes the copied engine (scripts py files, _utils, tests, VERSION; scripts/ci stays — the freshness workflow's shell lives there), installs templates/consumer/flake.nix as the project's flake (github-project as a pinned input: dev shell with the CLIs, re-exported apps), and prints the next steps. Prompted by default; INIT_YES=1 for automation. make test degrades to a pointer at the engine repo once the local copy is gone. Verified in a throwaway copy: init leaves only scripts/ci, all three post-init channels resolve (missing-mode error text, checkout mode, PATH mode), and the consumer flake's dev shell and re-exported apps run lint with zero engine files in the tree, pinned by the consumer's own flake.lock. AI-assisted: Claude Fable 5 (Anthropic) --- Makefile | 113 ++++++++++++++++++++++++++++++----- templates/consumer/flake.nix | 42 +++++++++++++ 2 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 templates/consumer/flake.nix diff --git a/Makefile b/Makefile index 09040b4..dfa5d63 100644 --- a/Makefile +++ b/Makefile @@ -1,49 +1,130 @@ # 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 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 guard-gh ## 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 guard-gh ## 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 guard-gh ## 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 guard-gh ## 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/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}); + }; +} From 84e6e35a9c38681ed697317d281d4ecd880807b6 Mon Sep 17 00:00:00 2001 From: William DeMeo Date: Sun, 16 Aug 2026 13:25:42 -0600 Subject: [PATCH 3/6] Workflows and CI self-adapt to detached consumers; test the stub and init (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci/project-plan-update.sh resolves the engine through the same channels as the Makefile (explicit UPDATE override, local scripts, gh-project-update on PATH, GHPROJECT_DIR checkout) and reports 'could not run' — never fake drift — when none is available. Both workflows gain a hashFiles-guarded step that fetches an engine checkout exactly when the tree carries no local copy, so the same YAML serves the engine repository, pre-init template copies, and detached consumers with no init-time patching. ci.yml also grows an engine-repo-only nix job (install-nix + nix flake check, which builds the package and runs the suite in the sandbox); the setup-python jobs stay Nix-free so consumers are never Nix-bound. test_makefile_stub.py pins the detach lifecycle on a throwaway copy of the tree: init removes exactly the engine files (scripts/ci and the plan survive), installs the consumer flake, drops lock+templates, is idempotent; post-init the stub resolves checkout and PATH channels, errors helpfully when none exists, and make test degrades to a pointer at the engine repo. Guarded to skip inside the Nix sandbox, where only scripts/ is present. Suite: 111 -> 115. AI-assisted: Claude Fable 5 (Anthropic) --- .github/workflows/ci.yml | 57 +++++++-- .github/workflows/project-plan-update.yml | 12 ++ scripts/ci/project-plan-update.sh | 24 +++- scripts/tests/test_makefile_stub.py | 143 ++++++++++++++++++++++ 4 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 scripts/tests/test_makefile_stub.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76ecaf4..fa6ecad 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,20 @@ 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 + - name: Fetch the engine (detached consumer projects only) + if: hashFiles('scripts/gh_project_populate.py') == '' + uses: actions/checkout@v4 + with: + repository: williamdemeo/github-project + 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 +61,31 @@ 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 diff --git a/.github/workflows/project-plan-update.yml b/.github/workflows/project-plan-update.yml index d25ccd0..623d8de 100644 --- a/.github/workflows/project-plan-update.yml +++ b/.github/workflows/project-plan-update.yml @@ -32,10 +32,22 @@ jobs: with: python-version: "3.12" + # Projects that ran `make init` carry no engine copy; fetch one. + # (hashFiles is empty when the file is absent.) The engine repo + # itself, and template copies that have not detached yet, skip + # this step and use their local scripts/. + - name: Fetch the engine (detached consumer projects only) + if: hashFiles('scripts/gh_project_update.py') == '' + uses: actions/checkout@v4 + with: + repository: williamdemeo/github-project + 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/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..073fc40 --- /dev/null +++ b/scripts/tests/test_makefile_stub.py @@ -0,0 +1,143 @@ +""" +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 {})} + return subprocess.run( + ["make", *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", "engine-mode"], cwd=REPO_ROOT, + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(proc.stdout.strip(), "local") + + +if __name__ == "__main__": + unittest.main() From e15200a7668551ff65818441a999d39e79879295 Mon Sep 17 00:00:00 2001 From: William DeMeo Date: Sun, 16 Aug 2026 13:27:50 -0600 Subject: [PATCH 4/6] Docs: engine-single-home story; bump engine to 0.2.0 (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: the quickstart gains the one-time 'make init' detach step; the re-vendor section is replaced by the three consumption channels (flake input with deliberate 'nix flake update github-project' upgrades; GHPROJECT_DIR checkout for non-Nix use; installed CLI planned — the Makefile already resolves gh-project-* from PATH, so that channel needs no consumer-side change when it lands). Layout and design notes now state the engine is referenced, never copied. CLAUDE.md replaces the re-vendor guidance with the single-home rule and tells a session how to recognize a detached consumer tree. The project-setup skill gains the detach step. scripts/VERSION - now also the flake package version - bumps to 0.2.0 for the distribution-model change (engine behavior itself is unchanged). AI-assisted: Claude Fable 5 (Anthropic) --- .claude/skills/project-setup/SKILL.md | 7 ++ CLAUDE.md | 14 ++- README.md | 121 +++++++++++++++++++------- scripts/VERSION | 2 +- 4 files changed, 106 insertions(+), 38 deletions(-) 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/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/README.md b/README.md index 438cd59..a8a8741 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,87 @@ 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. + +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 +222,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/scripts/VERSION b/scripts/VERSION index 6e8bf73..0ea3a94 100644 --- a/scripts/VERSION +++ b/scripts/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 From 446b51c9c2958684a8a2de522b7116ac55955ecf Mon Sep 17 00:00:00 2001 From: William DeMeo Date: Sun, 16 Aug 2026 13:34:12 -0600 Subject: [PATCH 5/6] Fix the sandboxed check phase and make-test chatter surfaced by CI Two environment-dependence bugs the local relaxed-sandbox nix hid: - In a strict sandbox there is no /usr/bin/env, so the recorded fake gh's shebang died with an ENOENT that masqueraded as 'gh: No such file or directory'. patchShebangs tests/fake_gh in the check phase rewrites it to the store python3. Verified with a forced 'nix build --option sandbox true': 115 tests, 4 skipped (the full-tree make tests, absent by design from the scripts-only src). - Under 'make test' the stub tests' sub-makes inherit -w and print Entering/Leaving directory lines into the single-line outputs the assertions read; --no-print-directory on every test invocation. AI-assisted: Claude Fable 5 (Anthropic) --- flake.nix | 4 ++++ scripts/tests/test_makefile_stub.py | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 2aa1af6..6d2f6d3 100644 --- a/flake.nix +++ b/flake.nix @@ -42,8 +42,12 @@ # 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 ''; diff --git a/scripts/tests/test_makefile_stub.py b/scripts/tests/test_makefile_stub.py index 073fc40..6540350 100644 --- a/scripts/tests/test_makefile_stub.py +++ b/scripts/tests/test_makefile_stub.py @@ -59,8 +59,11 @@ def setUp(self) -> None: 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", *args], cwd=cwd or self.tree, + ["make", "--no-print-directory", *args], cwd=cwd or self.tree, env=env, capture_output=True, text=True, timeout=300, ) @@ -133,7 +136,7 @@ def test_post_init_modes(self) -> None: class PreInitTree(unittest.TestCase): def test_engine_repo_resolves_local(self) -> None: proc = subprocess.run( - ["make", "engine-mode"], cwd=REPO_ROOT, + ["make", "--no-print-directory", "engine-mode"], cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, ) self.assertEqual(proc.stdout.strip(), "local") From cb4287282409957288baff72889026b022695c62 Mon Sep 17 00:00:00 2001 From: William DeMeo Date: Sun, 16 Aug 2026 13:54:41 -0600 Subject: [PATCH 6/6] Address Copilot on PR #4: pin the CI engine fetch, fix the path-mode gh guard, smoke-test the consumer flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four findings were legitimate: - Both workflows' engine fetch was floating upstream main — in the freshness workflow with contents:write and pull-requests:write, so an upstream change became unreviewed code execution with mutation privileges in every detached consumer; in ci.yml it made consumer CI behavior change under an unchanged commit. The fetch is now pinned, resolved in order: the github-project rev in the consumer's own flake.lock (one pin to manage for Nix consumers, upgraded by the same deliberate 'nix flake update github-project'), the GHPROJECT_ENGINE_REF repository variable, the default release tag. - guard-gh probed the caller's ambient PATH even in path mode, failing a 'nix profile install' user whose wrapped CLI carries its own gh. The guard now applies only to local/checkout modes (bare python3 scripts genuinely need ambient gh); verified with a gh-less PATH — execution reaches the CLI, whose own runtime is authoritative. - templates/consumer/flake.nix had no automated coverage (the engine package builds from ./scripts, and the stub tests skip without the full tree). The nix CI job now assembles a detached consumer, points its input at the engine under test via --override-input, and runs lint through the consumer's dev shell and re-exported apps; the step script was executed verbatim locally before landing. AI-assisted: Claude Fable 5 (Anthropic) --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++ .github/workflows/project-plan-update.yml | 24 +++++++++++++--- Makefile | 19 ++++++++++--- README.md | 6 +++- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa6ecad..646045b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,26 @@ jobs: 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 @@ -89,3 +104,22 @@ jobs: - 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 623d8de..8fa344b 100644 --- a/.github/workflows/project-plan-update.yml +++ b/.github/workflows/project-plan-update.yml @@ -32,15 +32,31 @@ jobs: with: python-version: "3.12" - # Projects that ran `make init` carry no engine copy; fetch one. - # (hashFiles is empty when the file is absent.) The engine repo - # itself, and template copies that have not detached yet, skip - # this step and use their local scripts/. + # 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 diff --git a/Makefile b/Makefile index dfa5d63..bd55c97 100644 --- a/Makefile +++ b/Makefile @@ -57,20 +57,31 @@ endif 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-engine guard-gh ## Preview what populate would create (no mutations) +populate-dry: guard-engine $(GH_GUARD) ## Preview what populate would create (no mutations) $(POPULATE) $(PLAN) $(FLAGS) --dry-run -populate: guard-engine guard-gh ## Create labels, milestones, issues on GitHub from $(PLAN) +populate: guard-engine $(GH_GUARD) ## Create labels, milestones, issues on GitHub from $(PLAN) $(POPULATE) $(PLAN) $(FLAGS) -update: guard-engine guard-gh ## Rewrite $(PLAN)'s generated regions from live GitHub state +update: guard-engine $(GH_GUARD) ## Rewrite $(PLAN)'s generated regions from live GitHub state $(UPDATE) $(PLAN) $(FLAGS) -update-check: guard-engine guard-gh ## Report whether $(PLAN) is stale without writing +update-check: guard-engine $(GH_GUARD) ## Report whether $(PLAN) is stale without writing $(UPDATE) $(PLAN) $(FLAGS) --check lint: guard-engine ## Validate $(PLAN)'s structure (no network) diff --git a/README.md b/README.md index a8a8741..4efa928 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,11 @@ available: 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. +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