diff --git a/.github/workflows/bump-idfkit.yml b/.github/workflows/bump-idfkit.yml index cca14ea3f..f53f32e1f 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,44 +95,203 @@ 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: Run tests + - 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 + # 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 + 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) + 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 + # 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 }} + PREVIOUS: ${{ steps.prev.outputs.level }} + TESTS: ${{ steps.tests.outcome }} + 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 "- 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 + 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 + 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 + } > "$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.