fix: Resolve references by keyword set, and cover what shipped untested #51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Repair what is mechanical, commit it, and fail only on what needs a person. | |
| # | |
| # Contract: after this workflow, main is formatted, linted, aliased, and its | |
| # skill listings are sorted. Anything a tool can fix is fixed and committed | |
| # here; the run goes red only for findings no fixer can settle, which is the | |
| # signal that a human must decide something. | |
| # | |
| # The rules themselves live in the repository, not in this file: ruff.toml | |
| # holds the lint and format policy, and the `.github/gate` workspace member | |
| # holds the conventions from AGENTS.md. This file decides when they run and who is | |
| # allowed to write. | |
| # | |
| # Least privilege, following .github/workflows/sync-skills.yml: | |
| # - `fix` holds `contents: read` and only computes; it produces the new file | |
| # contents as a value. `commit` holds the sole `contents: write` and only | |
| # applies that value. | |
| # - `commit` checks out nothing, so no working tree, no hook, and no | |
| # credential material on disk or in argv. | |
| # | |
| # Signed commits: the repair goes through the Git Data API carrying no author, | |
| # committer, or signature field, the documented condition for GitHub to sign a | |
| # bot commit with its own key, so repairs satisfy a `Require signed commits` | |
| # ruleset with no key, secret, or bypass actor. force=false keeps the ref | |
| # update a fast-forward; losing that race is a warning, because the next push | |
| # recomputes the same repairs. | |
| # | |
| # No loop: a commit made with GITHUB_TOKEN does not trigger workflows, so the | |
| # repair commit cannot start this workflow again. Every fixer is idempotent in | |
| # any case, so a second run over a repaired tree finds nothing to do. | |
| # | |
| # Actions are pinned to their major tag, which is the maintained line for the | |
| # trusted publishers used here; ruff and uv, which decide what a clean tree | |
| # looks like, are pinned to exact versions instead. | |
| name: Gate | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| # Deny-all baseline; each job re-grants the minimum it needs. | |
| permissions: {} | |
| concurrency: | |
| group: gate-${{ github.ref }} | |
| cancel-in-progress: true | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| # Rule codes and formatting move between ruff releases, so CI names the exact | |
| # build. ruff.toml states the floor any run must satisfy. | |
| RUFF_VERSION: 0.16.1 | |
| jobs: | |
| fix: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read # read the tree under test; this job never writes to it | |
| outputs: | |
| changed: ${{ steps.collect.outputs.changed }} | |
| entries: ${{ steps.collect.outputs.entries }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # Pinned to a minor rather than a major, unlike the trusted actions | |
| # above, because this one carries the toolchain that decides what a | |
| # clean tree looks like. | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| - name: Apply every fixer | |
| # Fixers never fail this job: `ruff check --fix` exits nonzero when | |
| # findings it cannot fix remain, and the gate does the same, but that | |
| # verdict belongs to the assertion step below, after the repairs have | |
| # been collected. Only safe fixes are applied, because an unsafe fix can | |
| # change behavior and must not land unreviewed. | |
| run: | | |
| set -uo pipefail | |
| uvx "ruff@${RUFF_VERSION}" check --fix . || true | |
| uvx "ruff@${RUFF_VERSION}" format . | |
| uv run --project .github/gate btm-repo-gate fix || true | |
| - name: Collect the repairs | |
| id: collect | |
| # The repaired files cross to the writing job as one JSON value: the | |
| # exact tree entries the Git Data API accepts, so that job adds no | |
| # policy of its own. Mode comes from the filesystem, which keeps an | |
| # executable bit or a symlink from being flattened into a plain file. | |
| run: | | |
| set -euo pipefail | |
| # Scratch lives outside the checkout: a file written here would show | |
| # up as an untracked change and commit itself. | |
| ndjson="${RUNNER_TEMP}/entries.ndjson" | |
| entries="${RUNNER_TEMP}/entries.json" | |
| : > "${ndjson}" | |
| while IFS= read -r -d '' entry; do | |
| status=${entry:0:2} | |
| path=${entry:3} | |
| case "${status}" in | |
| ' M' | 'M ' | 'MM' | 'AM' | 'A ' | '??') ;; | |
| *) | |
| echo "::warning::unexpected git status '${status}' for ${path}; leaving it uncommitted" | |
| continue | |
| ;; | |
| esac | |
| if [ -L "${path}" ]; then | |
| jq -n --arg path "${path}" --arg content "$(readlink "${path}")" \ | |
| '{path: $path, mode: "120000", type: "blob", content: $content}' \ | |
| >> "${ndjson}" | |
| else | |
| if [ -x "${path}" ]; then mode=100755; else mode=100644; fi | |
| jq -n --arg path "${path}" --arg mode "${mode}" --rawfile content "${path}" \ | |
| '{path: $path, mode: $mode, type: "blob", content: $content}' \ | |
| >> "${ndjson}" | |
| fi | |
| done < <(git status --porcelain=v1 -z --untracked-files=all) | |
| jq -sc . "${ndjson}" > "${entries}" | |
| count=$(jq 'length' "${entries}") | |
| # A job output is capped at 1 MB. Refusing early names the real limit | |
| # instead of letting the platform truncate a repair into corruption. | |
| size=$(wc -c < "${entries}") | |
| if [ "${size}" -gt 524288 ]; then | |
| echo "::error::repairs total ${size} bytes, over the 512 KB this workflow carries in one run; apply them locally with 'ruff check --fix . && ruff format . && uv run --project .github/gate btm-repo-gate fix'" | |
| exit 1 | |
| fi | |
| if [ "${count}" -eq 0 ]; then | |
| echo "changed=false" >> "${GITHUB_OUTPUT}" | |
| echo "Nothing to repair." >> "${GITHUB_STEP_SUMMARY}" | |
| else | |
| echo "changed=true" >> "${GITHUB_OUTPUT}" | |
| { | |
| echo "Repaired ${count} file(s):" | |
| jq -r '.[] | "- `\(.path)`"' "${entries}" | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| fi | |
| { | |
| echo 'entries<<ENTRIES_JSON' | |
| cat "${entries}" | |
| echo 'ENTRIES_JSON' | |
| } >> "${GITHUB_OUTPUT}" | |
| - name: Assert what no fixer can settle | |
| # The sole judge, run against the repaired tree, so it reports only | |
| # what a person must decide. Every check runs even when an earlier one | |
| # fails, because their findings are independent. | |
| # | |
| # `uv lock --check` fails when a manifest moved without the lock: no | |
| # fixer relocks, because choosing versions is a person's call. | |
| # | |
| # mypy runs strict with the pydantic plugin, so a field name or type | |
| # that no longer matches its model fails here rather than surfacing as | |
| # a rejection an agent hits mid-session. | |
| run: | | |
| set -uo pipefail | |
| rc=0 | |
| uvx "ruff@${RUFF_VERSION}" check --output-format=github . || rc=1 | |
| uv lock --check || rc=1 | |
| uv run --all-packages pytest -q || rc=1 | |
| uv run mypy || rc=1 | |
| uv run --project .github/gate btm-repo-gate check || rc=1 | |
| exit "${rc}" | |
| commit: | |
| needs: fix | |
| # Runs even when the assertion above failed: repairs that were computed are | |
| # still correct and still worth landing, and withholding them would only | |
| # make the remaining, human-sized problem harder to see. | |
| if: ${{ !cancelled() && needs.fix.outputs.changed == 'true' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: write # sole grant: create the repair commit and move the ref | |
| steps: | |
| - name: Commit the repairs and fast-forward the branch | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| # Read through the environment, never interpolated into the script. | |
| ENTRIES: ${{ needs.fix.outputs.entries }} | |
| BASE: ${{ github.sha }} | |
| BRANCH: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| repo="repos/${GITHUB_REPOSITORY}" | |
| message=$(printf 'style: Apply automated formatting and convention repairs\n\nProduced by .github/workflows/gate.yml from ruff and btm-repo-gate.\n\n%s\n' \ | |
| "$(jq -r '.[] | "- \(.path)"' <<<"${ENTRIES}")") | |
| # tree -> commit -> ref are dependent effects, each consuming the | |
| # previous sha, so they share one failure arm; gh has already printed | |
| # the HTTP error. Omitting author, committer, and signature is what | |
| # makes GitHub sign the commit. | |
| land() { | |
| tree=$(jq -n --arg base "${BASE}" --argjson entries "${ENTRIES}" \ | |
| '{base_tree: $base, tree: $entries}' \ | |
| | gh api "${repo}/git/trees" --input - -q .sha) || return 1 | |
| commit=$(jq -n --arg m "${message}" --arg t "${tree}" --arg p "${BASE}" \ | |
| '{message: $m, tree: $t, parents: [$p]}' \ | |
| | gh api "${repo}/git/commits" --input - -q .sha) || return 1 | |
| gh api --method PATCH "${repo}/git/refs/heads/${BRANCH}" \ | |
| -f sha="${commit}" -F force=false > /dev/null || return 1 | |
| } | |
| if ! land; then | |
| echo "::warning::${BRANCH} moved or GitHub rejected the update; the repairs are unchanged and the next push recomputes them" | |
| exit 0 | |
| fi | |
| echo "Committed ${commit:0:12} to ${BRANCH}, signed by GitHub." \ | |
| | tee -a "${GITHUB_STEP_SUMMARY}" |