From dedd6a4ab8657d878b1ee26924f6585670f63697 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 11 Sep 2026 11:27:51 -0400 Subject: [PATCH 1/2] Join the consumer register: self-check, rehearsal, and a bump that surfaces what it closes - main.yml calls check-consumer.yml from idfkit-conformance at governance-2026.18, so this repository cannot drift from its register entry (id idfkit-docs) unnoticed. - rehearse-candidate.yml builds any idfkit ref and type-checks the scripts that import idfkit against it, without touching pyproject.toml or uv.lock. There is no suite here, so that is the rehearsal. - bump-idfkit.yml keeps its pin, test, repair and pull request steps, and now lists the statements of unavailability the new level closes, closes this repository's not-yet lag in the register through a paired pull request, and can decline a level as a deliberate lag instead. Waits on governance-2026.18, which is cut from the idfkit-conformance pull request that adds the register. --- .github/workflows/bump-idfkit.yml | 197 ++++++++++++++++++++++- .github/workflows/main.yml | 10 ++ .github/workflows/rehearse-candidate.yml | 44 +++++ CLAUDE.md | 29 ++++ 4 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/rehearse-candidate.yml diff --git a/.github/workflows/bump-idfkit.yml b/.github/workflows/bump-idfkit.yml index cca14ea3f..584f6f9a6 100644 --- a/.github/workflows/bump-idfkit.yml +++ b/.github/workflows/bump-idfkit.yml @@ -1,16 +1,84 @@ name: Bump idfkit + +# Adopt a newly published idfkit, as a pull request. +# +# idfkit's notify-downstream.yml dispatches this on every release, in the wave order the consumer +# register (governance/consumers.toml in idfkit-conformance) gives it; a maintainer may also run it by +# hand. It pins the version, relocks, runs the tests, lets Claude attempt a minimal repair when they +# fail, and opens a pull request. That part is unchanged: it works. +# +# Feature 004 of the unification added three things, each because an adoption could otherwise report +# complete while something it made false stood unexamined: +# +# Statements. Every `idfkit:unavailable` marker in this repository rests on a parity ledger entry. +# When the new level closes that entry, the statement beside the marker has become false. The +# markers resting on an entry that moved to complete between the old level's governance tag and +# the new one are listed in the pull request, which opens as a draft, and this run fails while any +# is listed (FR-025). Reviewing one is removing the statement, or setting its `own_reason`. +# The register. When the register holds a lag for this repository, the adoption closes it, and the +# register change lands with the adoption rather than afterwards (FR-023) as a paired pull request +# in idfkit-conformance. A level moving needs no register change at all otherwise, because the +# register points at pyproject.toml rather than holding the level. +# Declining. A maintainer who will not take a level runs this with `decline_reason`. Nothing is +# bumped; the register records a deliberate lag with that reason, which is a resting state rather +# than a bot pull request left open for a month (FR-024). +# +# The register and the ledger are read at the governance tag this repository pins in main.yml, never +# at a branch. Dispatching to idfkit-conformance needs CONFORMANCE_DISPATCH_TOKEN (actions: write on +# that repository); without it the exact command is written where a maintainer will see it. + on: workflow_dispatch: inputs: idfkit_version: + description: The published idfkit version to adopt, with or without a leading v. required: true type: string + decline_reason: + description: > + Leave empty to adopt. To decline this level instead, state why as a policy, never a + version. Nothing is bumped, and the register records a deliberate lag. + required: false + type: string + default: "" + permissions: contents: write pull-requests: write id-token: write + +env: + CONSUMER: idfkit-docs + LIBRARY: python + GOVERNANCE_LEVEL: governance-2026.18 + jobs: + decline: + if: inputs.decline_reason != '' + runs-on: ubuntu-latest + steps: + - name: Record the decline in the consumer register + env: + GH_TOKEN: ${{ secrets.CONFORMANCE_DISPATCH_TOKEN }} + REASON: ${{ inputs.decline_reason }} + RAW_VERSION: ${{ inputs.idfkit_version }} + run: | + set -euo pipefail + version="${RAW_VERSION#v}" + command=(gh workflow run record-adoption.yml --repo idfkit/idfkit-conformance + -f consumer="$CONSUMER" -f library="$LIBRARY" -f outcome=decline -f reason="$REASON") + printf '## %s declines idfkit %s\n\nReason: %s\n' "$CONSUMER" "$version" "$REASON" >> "$GITHUB_STEP_SUMMARY" + if [ -n "${GH_TOKEN:-}" ]; then + "${command[@]}" + echo "Dispatched record-adoption.yml in idfkit-conformance; its pull request records the deliberate lag." >> "$GITHUB_STEP_SUMMARY" + else + printf '\nNot recorded: CONFORMANCE_DISPATCH_TOKEN is not set. Run:\n\n```\n%s\n```\n' "$(printf '%q ' "${command[@]}")" >> "$GITHUB_STEP_SUMMARY" + echo "::error title=Decline not recorded::A decline the register does not hold is not a decline. Run the command in the step summary." + exit 1 + fi + bump: + if: inputs.decline_reason == '' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -27,10 +95,67 @@ jobs: echo "Normalized version: $version" - name: Install uv uses: astral-sh/setup-uv@v5 + - name: Read the level this adoption moves from + id: prev + run: | + set -euo pipefail + previous=$(python3 - <<'PY' + import pathlib, re, tomllib + dependencies = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["dependencies"] + pins = [d.split("==", 1)[1].strip() for d in dependencies if re.match(r"idfkit\s*==", d)] + if len(pins) != 1: + raise SystemExit(f"expected one exact idfkit pin in pyproject.toml, found {pins}") + print(pins[0]) + PY + ) + echo "level=$previous" >> "$GITHUB_OUTPUT" - name: Bump idfkit run: | uv add "idfkit==${{ steps.ver.outputs.version }}" uv lock + - name: Read the register and the ledger at the pinned governance tag + uses: actions/checkout@v4 + with: + repository: idfkit/idfkit-conformance + ref: refs/tags/${{ env.GOVERNANCE_LEVEL }} + fetch-depth: 0 + path: .governance + persist-credentials: false + # Out of this repository's tree: the pull request below commits every change in it, and pytest + # would collect the governance repository's own tests. + - name: Move the governance checkout aside + run: mv .governance "$RUNNER_TEMP/governance" + - name: List the statements this adoption surfaces + id: statements + env: + GH_TOKEN: ${{ github.token }} + PREVIOUS: ${{ steps.prev.outputs.level }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set +e + python3 "$RUNNER_TEMP/governance/tools/adoption_statements.py" --library "$LIBRARY" \ + --from "$PREVIOUS" --to "$VERSION" --root . --conformance "$RUNNER_TEMP/governance" \ + > "$RUNNER_TEMP/statements.txt" 2>&1 + code=$? + set -e + cat "$RUNNER_TEMP/statements.txt" + if [ "$code" -ge 2 ]; then + echo "::error title=Statements unknown::The governance levels of idfkit $PREVIOUS and $VERSION could not be read, so the statements this adoption surfaces are unknown. That is not the same as none." + exit 2 + fi + if [ "$code" -eq 1 ]; then echo "surfaced=true" >> "$GITHUB_OUTPUT"; else echo "surfaced=false" >> "$GITHUB_OUTPUT"; fi + - name: Read whether the register holds a lag this adoption closes + id: lag + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import os, pathlib, tomllib + path = pathlib.Path(os.environ["RUNNER_TEMP"], "governance", "governance", "consumers.toml") + register = tomllib.loads(path.read_text()) + consumer = next(c for c in register["consumer"] if c["id"] == os.environ["CONSUMER"]) + lag = next((r.get("lag") for r in consumer.get("libraries", []) if r["library"] == os.environ["LIBRARY"]), None) + print(f"closes={'true' if lag else 'false'}") + print(f"tracked={(lag or {}).get('issue') or (lag or {}).get('reason') or ''}") + PY - name: Run tests id: tests continue-on-error: true @@ -54,17 +179,75 @@ jobs: - name: Rerun tests after Claude repair if: steps.tests.outcome == 'failure' run: uv run pytest + - name: Write the pull request body + env: + VERSION: ${{ steps.ver.outputs.version }} + PREVIOUS: ${{ steps.prev.outputs.level }} + TESTS: ${{ steps.tests.outcome }} + SURFACED: ${{ steps.statements.outputs.surfaced }} + CLOSES: ${{ steps.lag.outputs.closes }} + TRACKED: ${{ steps.lag.outputs.tracked }} + HAS_TOKEN: ${{ secrets.CONFORMANCE_DISPATCH_TOKEN != '' }} + run: | + { + echo "Automated idfkit dependency bump." + echo "- Version: \`$VERSION\`, from \`$PREVIOUS\`" + echo "- Initial test result: \`$TESTS\`" + echo "- Test command: \`uv run pytest\`" + echo + echo "If tests initially failed, Claude attempted a minimal compatibility repair." + echo + echo "### Statements of unavailability" + if [ "$SURFACED" = true ]; then + echo "**This adoption is not complete.** The new level closes a capability that a statement in this repository says is unavailable. Remove each statement, or set its \`own_reason\` so it stops citing the library, then mark this pull request ready (FR-025):" + echo + echo '```' + cat "$RUNNER_TEMP/statements.txt" + echo '```' + else + echo "None of this repository's statements rests on a capability this level closed." + fi + echo + echo "### Consumer register" + if [ "$CLOSES" = true ]; then + echo "The register holds a lag for \`$CONSUMER\` ($LIBRARY), tracked at $TRACKED. This adoption closes it." + if [ "$HAS_TOKEN" = true ]; then + echo "A paired pull request in idfkit-conformance removes it; merge the two together (FR-023)." + else + echo "Record that in a paired pull request, which needs this run in idfkit-conformance:" + echo + echo '```' + echo "gh workflow run record-adoption.yml --repo idfkit/idfkit-conformance -f consumer=$CONSUMER -f library=$LIBRARY -f outcome=close-lag -f adoption=" + echo '```' + fi + else + echo "Nothing to record: the register points at pyproject.toml and holds no level, and no lag for this repository." + fi + } > "$RUNNER_TEMP/pr-body.md" - name: Open PR + id: pr uses: peter-evans/create-pull-request@v6 with: branch: bot/bump-idfkit-${{ steps.ver.outputs.version }} title: "Bump idfkit to ${{ steps.ver.outputs.version }}" commit-message: "Bump idfkit to ${{ steps.ver.outputs.version }}" reviewers: samuelduchesne - body: | - Automated idfkit dependency bump. - - Version: `${{ steps.ver.outputs.version }}` - - Initial test result: `${{ steps.tests.outcome }}` - - Test command: `uv run pytest` - - If tests initially failed, Claude attempted a minimal compatibility repair. + draft: ${{ steps.statements.outputs.surfaced == 'true' }} + body-path: ${{ runner.temp }}/pr-body.md + - name: Record the closed lag in the register + if: steps.lag.outputs.closes == 'true' + env: + GH_TOKEN: ${{ secrets.CONFORMANCE_DISPATCH_TOKEN }} + PR_URL: ${{ steps.pr.outputs.pull-request-url }} + run: | + if [ -z "${GH_TOKEN:-}" ]; then + echo "::warning title=Register not updated::CONFORMANCE_DISPATCH_TOKEN is not set. The pull request body carries the command that records the closed lag." + exit 0 + fi + gh workflow run record-adoption.yml --repo idfkit/idfkit-conformance \ + -f consumer="$CONSUMER" -f library="$LIBRARY" -f outcome=close-lag -f adoption="$PR_URL" + - name: Refuse to call the adoption complete while a statement is unreviewed + if: steps.statements.outputs.surfaced == 'true' + run: | + echo "::error title=Statements unreviewed::This adoption surfaced statements of unavailability that the new level made false. The pull request is a draft until each is reviewed." + exit 1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4ce668fc8..557f72203 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,6 +11,16 @@ permissions: contents: read jobs: + # This repository's entry in the consumer register (governance/consumers.toml in + # idfkit-conformance, id `idfkit-docs`) must still describe it: the level declared where the + # register says, and no dependency on either library that the register does not point at. The tag + # is stated on `uses:` and again as the input, and the called workflow refuses the run unless the + # two match, so moving it is one reviewed edit. It says nothing about whether the level is current. + consumer-register: + uses: idfkit/idfkit-conformance/.github/workflows/check-consumer.yml@governance-2026.18 + with: + governance_level: governance-2026.18 + quality: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/rehearse-candidate.yml b/.github/workflows/rehearse-candidate.yml new file mode 100644 index 000000000..28b1253db --- /dev/null +++ b/.github/workflows/rehearse-candidate.yml @@ -0,0 +1,44 @@ +# Rehearse this repository against an unpublished idfkit, before it is released (feature 004, US2). +# +# A maintainer names a commit, branch or tag in idfkit/idfkit. The reusable workflow builds a wheel +# from it, installs it over this repository's synced environment without touching pyproject.toml or +# uv.lock, and type-checks the scripts that import idfkit at the declared level and again at the +# candidate. This repository has no test suite and no type checker of its own, so the rehearsal +# lays pyright over those scripts without adding it to any manifest. +# +# The commands it runs are not written here. They are this repository's rehearsal entry in +# governance/consumers.toml in idfkit-conformance, read at the pinned tag below. +# +# Dispatch only. A failure the candidate causes is a finding against idfkit, and it never blocks a +# pull request here. + +name: rehearse-candidate + +on: + workflow_dispatch: + inputs: + # One option, and declared anyway: both libraries' rehearse-all.yml dispatch every consumer + # with `-f library=...`, and GitHub rejects a dispatch carrying an input the workflow does not + # declare. This repository resolves only the first language. + library: + description: The library to rehearse. This repository resolves only python. + required: true + type: choice + options: [python] + default: python + candidate_ref: + description: A commit, branch or tag in idfkit/idfkit to rehearse against. Required. + required: true + type: string + +permissions: + contents: read + +jobs: + rehearse: + uses: idfkit/idfkit-conformance/.github/workflows/rehearse.yml@governance-2026.18 + with: + governance_level: governance-2026.18 + consumer: idfkit-docs + library: ${{ inputs.library }} + candidate_ref: ${{ inputs.candidate_ref }} diff --git a/CLAUDE.md b/CLAUDE.md index f36bc2cb9..3f4ce4034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,6 +182,27 @@ cd build/v25.2 && uv run zensical build --clean - The `build/` and `dist/` directories are gitignored. `build/sources/` holds cloned EnergyPlus repos; `build/vXX.X/` holds per-version Zensical projects; `dist/` holds the final deployed site. - The Zensical site configuration template is in `zensical.toml` (root). Per-version configs are generated dynamically by `scripts/convert.py`. +## The consumer register + +This repository is `idfkit-docs` in the consumer register, `governance/consumers.toml` in +idfkit-conformance, read at the governance tag pinned in `.github/workflows/main.yml`. The register +records where the idfkit level is declared and never the level itself. + +- **Where the level lives**: the exact `idfkit==X` pin in `project.dependencies` of `pyproject.toml`, + and nowhere else. Bumping it needs no register change. +- **Its standing**: behind the current idfkit, recorded as a `not-yet` lag tracked by the open bump + pull request, because an adoption is in progress rather than declined. If this repository decides + to follow idfkit only on some other schedule, dispatch the bump with `decline_reason` stating that + policy, and the register records it as deliberate instead. +- **Self-check**: the `consumer-register` job in `main.yml` calls `check-consumer.yml` at that tag. + It fails when the level moves to a file the register does not point at, or when a new dependency + on either library appears that the register does not name. It does not care whether the level is + current. +- **Rehearsal**: `.github/workflows/rehearse-candidate.yml` builds a wheel from any idfkit ref and + type-checks `scripts/` against it with pyright laid over the environment, without touching + `pyproject.toml` or `uv.lock`. This repository has no test suite and no type checker of its own, so + that check is the whole rehearsal. + ## Automated idfkit bumps When invoked by `.github/workflows/bump-idfkit.yml` on test failure after an idfkit version bump: @@ -196,3 +217,11 @@ When invoked by `.github/workflows/bump-idfkit.yml` on test failure after an idf - files changed - tests run - remaining risks + +The bump also does three things that are not repairs, and none of them is for the repair step to undo: + +- It lists every `idfkit:unavailable` statement marker resting on a capability the new level closed, + opens the pull request as a draft, and fails while one is listed. +- When the register holds a lag for this repository, it closes it through a paired pull request in + idfkit-conformance. +- Dispatched with `decline_reason`, it bumps nothing and records a deliberate lag instead. From f71ceadff0ab64d4dd01f6c5264c676d4f9946b3 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 11 Sep 2026 12:47:39 -0400 Subject: [PATCH 2/2] Type-check the scripts that import idfkit where the bump ran absent tests, and close a lag only at the current level T118: this repository has no test suite, so the bump's `uv run pytest` exited 5 on every bump, every bump read as failed, and the Claude repair step ran against nothing. The bump now runs the register's rehearsal type check over the three scripts that import idfkit, which is the one instrument here that sees an idfkit change, and keeps the repair-and-rerun structure around it. T119: the lag decision closes the register's lag only when the adopted level is the library's current one; an older adoption leaves it open and says why. The step is identical in every consumer's bump workflow. --- .github/workflows/bump-idfkit.yml | 58 +++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bump-idfkit.yml b/.github/workflows/bump-idfkit.yml index 584f6f9a6..f53f32e1f 100644 --- a/.github/workflows/bump-idfkit.yml +++ b/.github/workflows/bump-idfkit.yml @@ -144,8 +144,41 @@ jobs: exit 2 fi if [ "$code" -eq 1 ]; then echo "surfaced=true" >> "$GITHUB_OUTPUT"; else echo "surfaced=false" >> "$GITHUB_OUTPUT"; fi + # T119, FR-023: an adoption closes the register's lag only when it reaches the library's current + # level, the newest release of any kind. A bump to anything older, a manual dispatch of an earlier + # release say, moves the consumer forward and leaves it behind all the same, so the lag stays open. + # This step is identical in every consumer's bump workflow; change it everywhere or nowhere. + - name: Read whether the adopted level is the library's current level + id: current + env: + ADOPTED: ${{ steps.ver.outputs.version }} + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os, re, urllib.request + + def norm(v): + v = v.strip().lower().removeprefix("v") + return re.sub(r"[.-]?(alpha|beta|a|b|rc|dev|post)[.-]?(\d*)", lambda m: {"alpha": "a", "beta": "b"}.get(m[1], m[1]) + m[2], v) + + def key(v): + base, _, pre = norm(v).partition("rc") + nums = [int(x) for x in re.findall(r"\d+", re.split(r"[a-z]", base, maxsplit=1)[0])] + return (*nums, 0 if pre else 1, int(pre) if pre.isdigit() else 0) + + if os.environ["LIBRARY"] == "python": + data = json.load(urllib.request.urlopen("https://pypi.org/pypi/idfkit/json", timeout=30)) + versions = [v for v, files in data["releases"].items() if files] + else: + data = json.load(urllib.request.urlopen("https://registry.npmjs.org/@idfkit%2Fcore", timeout=30)) + versions = list(data["versions"]) + current = max(versions, key=key) + print(f"current={current}") + print(f"is_current={'true' if norm(current) == norm(os.environ['ADOPTED']) else 'false'}") + PY - name: Read whether the register holds a lag this adoption closes id: lag + env: + IS_CURRENT: ${{ steps.current.outputs.is_current }} run: | python3 - <<'PY' >> "$GITHUB_OUTPUT" import os, pathlib, tomllib @@ -153,32 +186,39 @@ jobs: register = tomllib.loads(path.read_text()) consumer = next(c for c in register["consumer"] if c["id"] == os.environ["CONSUMER"]) lag = next((r.get("lag") for r in consumer.get("libraries", []) if r["library"] == os.environ["LIBRARY"]), None) - print(f"closes={'true' if lag else 'false'}") + current = os.environ["IS_CURRENT"] == "true" + print(f"closes={'true' if lag and current else 'false'}") + print(f"held={'true' if lag and not current else 'false'}") print(f"tracked={(lag or {}).get('issue') or (lag or {}).get('reason') or ''}") PY - - name: Run tests + # T118: this repository has no test suite, so `uv run pytest` exited 5 on every bump, every bump read + # as failed, and the repair step ran against nothing. The check that does see an idfkit change here + # is the register's rehearsal type check over the three scripts that import idfkit, so the bump runs + # that, and the repair-and-rerun structure around it is unchanged. A new script importing idfkit is + # added here and in the register together. + - name: Type-check the scripts that import idfkit id: tests continue-on-error: true run: | - uv run pytest 2>&1 | tee "$RUNNER_TEMP/bump-test-output.txt" + uv run --with pyright pyright scripts/convert.py scripts/schema_utils.py scripts/version_manager.py 2>&1 | tee "$RUNNER_TEMP/bump-test-output.txt" - name: Ask Claude to repair test failures if: steps.tests.outcome == 'failure' uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | - idfkit was bumped to ${{ steps.ver.outputs.version }}, and the test suite failed. + idfkit was bumped to ${{ steps.ver.outputs.version }}, and the type check over the scripts that import it failed. Task: - Read the failure output at ${{ runner.temp }}/bump-test-output.txt. - Diagnose the compatibility issue caused by the idfkit upgrade. - Make the minimal code changes required. - Do not refactor unrelated code. - Preserve public APIs unless the idfkit upgrade requires a change. - - Rerun: uv run pytest + - Rerun: uv run --with pyright pyright scripts/convert.py scripts/schema_utils.py scripts/version_manager.py - Leave a concise summary of root cause, changed files, and final test result. - name: Rerun tests after Claude repair if: steps.tests.outcome == 'failure' - run: uv run pytest + run: uv run --with pyright pyright scripts/convert.py scripts/schema_utils.py scripts/version_manager.py - name: Write the pull request body env: VERSION: ${{ steps.ver.outputs.version }} @@ -187,13 +227,15 @@ jobs: SURFACED: ${{ steps.statements.outputs.surfaced }} CLOSES: ${{ steps.lag.outputs.closes }} TRACKED: ${{ steps.lag.outputs.tracked }} + HELD: ${{ steps.lag.outputs.held }} + CURRENT: ${{ steps.current.outputs.current }} HAS_TOKEN: ${{ secrets.CONFORMANCE_DISPATCH_TOKEN != '' }} run: | { echo "Automated idfkit dependency bump." echo "- Version: \`$VERSION\`, from \`$PREVIOUS\`" echo "- Initial test result: \`$TESTS\`" - echo "- Test command: \`uv run pytest\`" + echo "- Check command: \`uv run --with pyright pyright scripts/convert.py scripts/schema_utils.py scripts/version_manager.py\`" echo echo "If tests initially failed, Claude attempted a minimal compatibility repair." echo @@ -220,6 +262,8 @@ jobs: echo "gh workflow run record-adoption.yml --repo idfkit/idfkit-conformance -f consumer=$CONSUMER -f library=$LIBRARY -f outcome=close-lag -f adoption=" echo '```' fi + elif [ "$HELD" = true ]; then + echo "The register holds a lag for \`$CONSUMER\` ($LIBRARY), tracked at $TRACKED, and this adoption does not close it: \`$VERSION\` is not the library's current level, \`$CURRENT\`. The consumer moves forward and is still behind, so the lag stays open (FR-023)." else echo "Nothing to record: the register points at pyproject.toml and holds no level, and no lag for this repository." fi