From 47c0c285981f6d2556ab97298a098a8dba35914b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 12:30:42 -0700 Subject: [PATCH 1/4] Host the Merge-Bot in the Hub and Measure Fleet Workflow Reuse The fleet copies its standard workflows into every repo, and a fix to a shared job is a fleet sweep rather than one edit. This lands the design for hosting each standard workflow once in the hub as a workflow_call task, with a downstream caller stub and a composite-action hook for what is genuinely repo-specific, and ships the first task, the merge-bot. - docs/reusable-workflows.md states the target model, the hook contract, the pin and secrets policy, the phases, and the merge-bot adoption. - spec/workflow_reuse.py measures the fleet against that model and writes reports/workflow-reuse.md, the burn-down: 108 files and 10,964 lines downstream today, 49 percent byte-identical to a hub canonical. - merge-bot-task.yml hosts the three jobs with the extra bot pairs as a rules input, declares no GITHUB_TOKEN scope (#521's hub half), and the hub's own merge-bot-pull-request.yml is the first caller stub. - The manifest contract for merge-bot-pull-request.yml is now the caller job, the hub task token, and the two mapped secrets, so every copied merge-bot reports the adoption it owes. - TODO.md carries the phased cluster and the adoption sweep, and the two skills that describe copied orchestration point at the design. --- .../references/release-publish-mechanics.md | 5 +- .agents/skills/workflow-ci-contract/SKILL.md | 1 + .claude-plugin/fleet-skills/.source-digest | 2 +- .../references/release-publish-mechanics.md | 5 +- .../skills/workflow-ci-contract/SKILL.md | 1 + .editorconfig | 2 +- .gitattributes | 1 + .github/workflows/merge-bot-pull-request.yml | 173 +------ .github/workflows/merge-bot-task.yml | 181 ++++++++ .github/workflows/validate-task.yml | 3 +- GOVERNANCE.md | 4 +- TODO.md | 85 +++- WORKFLOW.md | 4 +- catalog/snippets/workflows/README.md | 6 +- .../workflows/check-upstream-version-task.yml | 6 +- docs/reusable-workflows.md | 166 +++++++ reports/workflow-reuse.md | 161 +++++++ scripts/tests/test_repo_gate.py | 7 + spec/audit.py | 50 +- spec/fidelity-model.md | 6 +- spec/files.json | 2 +- spec/workflow_reuse.py | 429 ++++++++++++++++++ 22 files changed, 1098 insertions(+), 202 deletions(-) create mode 100644 .github/workflows/merge-bot-task.yml create mode 100644 docs/reusable-workflows.md create mode 100644 reports/workflow-reuse.md create mode 100755 spec/workflow_reuse.py diff --git a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index 0bccb3b9..4847f1d0 100644 --- a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -49,8 +49,9 @@ looks tidier for 1:1 but forks the `github-release` download and breaks its verb `build-release-task.yml`. Per the per-target subsetting rule above, delete the target jobs not shipped and add the ones that are. `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects the repo's own targets. Making that -list itself target-agnostic is a larger "factor build from orchestration" refactor that is -intentionally not done. +list itself target-agnostic is the release-chain phase of `docs/reusable-workflows.md` in the +hub, where the orchestrator becomes a hub-hosted task and each target a composite-action hook, +and until that phase ships the list stays per repo. ## Map your outputs to the right seam diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index 436d3ff5..8160ce98 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -15,6 +15,7 @@ description: >- - **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. - **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. - **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/` for what is its own. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. - **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build--task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_` input, the `build-` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4). ## Style Rules That Break in One-Line Diffs diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 3ad055fa..0f5c47be 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -b1c02e77cc574d26 +c69f21b41228327e diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index 0bccb3b9..4847f1d0 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -49,8 +49,9 @@ looks tidier for 1:1 but forks the `github-release` download and breaks its verb `build-release-task.yml`. Per the per-target subsetting rule above, delete the target jobs not shipped and add the ones that are. `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects the repo's own targets. Making that -list itself target-agnostic is a larger "factor build from orchestration" refactor that is -intentionally not done. +list itself target-agnostic is the release-chain phase of `docs/reusable-workflows.md` in the +hub, where the orchestrator becomes a hub-hosted task and each target a composite-action hook, +and until that phase ships the list stays per repo. ## Map your outputs to the right seam diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md index 436d3ff5..8160ce98 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -15,6 +15,7 @@ description: >- - **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. - **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. - **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/` for what is its own. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. - **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build--task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_` input, the `build-` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4). ## Style Rules That Break in One-Line Diffs diff --git a/.editorconfig b/.editorconfig index b4571269..1dc93b7b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -72,7 +72,7 @@ end_of_line = lf # Python is CRLF by the `[*]` default, since universal newlines accept it and it is commonly edited on Windows. # Pin LF only for a `.py` executed directly via its shebang, by path. # Here that is the CI validation entry point and the fleet-audit runner. -[spec/{validate,audit,fidelity_honesty}.py] +[spec/{validate,audit,fidelity_honesty,workflow_reuse}.py] end_of_line = lf # The agent-safety kit's Python is shebang-executable tooling run by path, namely the PreToolUse hook and its installer. diff --git a/.gitattributes b/.gitattributes index 3ccb1caf..d420f81f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,6 +23,7 @@ catalog/snippets/husky/pre-commit text eol=lf spec/validate.py text eol=lf spec/audit.py text eol=lf spec/fidelity_honesty.py text eol=lf +spec/workflow_reuse.py text eol=lf host-setup/agent-safety/gh-write-guard.py text eol=lf host-setup/agent-safety/install.py text eol=lf host-setup/agent-safety/test_install.py text eol=lf diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index a579cd47..bb3d57dd 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,10 +1,7 @@ name: Merge bot pull request action -# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push. -# - Merge method by base: develop = squash, main = merge. -# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs. -# - pull_request_target rather than pull_request, since jobs hold the App key, so the workflow and action SHAs resolve from the trusted base rather than the PR head. -# This is safe because no job checks out PR code, each one running gh pr merge by URL. +# Thin caller: the merge-bot is the hub's reusable merge-bot-task.yml, which every fleet repo reaches rather than carries. +# The trigger is pull_request_target so the called workflow resolves from the trusted base rather than the PR head, and no job checks out PR code. on: pull_request_target: types: [opened, reopened, synchronize] @@ -15,162 +12,14 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false -jobs: - - merge-dependabot: - name: Merge dependabot pull request job - runs-on: ubuntu-latest - # Dependabot PRs from this repo rather than from forks. - # Only on opened or reopened, so the disable job stays sticky. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - # Auto-merge every tier, semver-major included: the required checks are the gate, not the bump magnitude. - - name: Merge pull request step - run: | - set -Eeuo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - merge-codegen: - name: Merge codegen pull request job - runs-on: ubuntu-latest - # Codegen PRs from this repo. - # Head and base pairing is enforced strictly, codegen-main to main and codegen-develop to develop. - # Only on opened or reopened, so the disable job stays sticky. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'ptr727-codegen[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - ( - (github.event.pull_request.head.ref == 'codegen-main' && github.event.pull_request.base.ref == 'main') || - (github.event.pull_request.head.ref == 'codegen-develop' && github.event.pull_request.base.ref == 'develop') - ) - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} +# Every write in the called workflow uses the App token, so GITHUB_TOKEN gets no scope. +permissions: {} - - name: Merge pull request step - run: | - set -Eeuo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - merge-upstream-version: - name: Merge upstream version pull request job - runs-on: ubuntu-latest - # Upstream-version bump PRs from the App. - # Head and base pairing is enforced, upstream-version-main to main and upstream-version-develop to develop. - # Only on opened or reopened, so the disable job stays sticky. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'ptr727-codegen[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - ( - (github.event.pull_request.head.ref == 'upstream-version-main' && github.event.pull_request.base.ref == 'main') || - (github.event.pull_request.head.ref == 'upstream-version-develop' && github.event.pull_request.base.ref == 'develop') - ) - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Merge pull request step - run: | - set -Eeuo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - disable-auto-merge-on-maintainer-push: - name: Disable auto-merge on maintainer push job - runs-on: ubuntu-latest - # Fires when a maintainer pushes to a bot's branch, meaning a synchronize whose actor is not the bot. - # It disables auto-merge so the maintainer's commits do not merge with the bot's, and the maintainer re-enables it manually. - # The disable call is idempotent. - if: >- - github.event.action == 'synchronize' && - github.event.pull_request.head.repo.full_name == github.repository && - ( - github.event.pull_request.user.login == 'dependabot[bot]' || - github.event.pull_request.user.login == 'ptr727-codegen[bot]' - ) && - github.actor != github.event.pull_request.user.login - permissions: - pull-requests: write - - steps: - - - name: Generate GitHub App token step - # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} +jobs: - - name: Disable auto-merge step - run: gh pr merge --disable-auto "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} + merge-bot: + name: Merge bot pull request job + uses: ./.github/workflows/merge-bot-task.yml + secrets: + CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/merge-bot-task.yml b/.github/workflows/merge-bot-task.yml new file mode 100644 index 00000000..8f5a58aa --- /dev/null +++ b/.github/workflows/merge-bot-task.yml @@ -0,0 +1,181 @@ +name: Merge bot task + +# The fleet merge-bot, hosted here once and reached by every repo through a thin pull_request_target caller pinned to a hub commit. +# Auto-merges in-repo bot PRs (Dependabot, the App's codegen and version-bump PRs): enable on opened/reopened, disable on a maintainer push. +# - Merge method by base: develop = squash, main = merge, since each base's ruleset allows only that form. +# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs. +# Every write uses the App token, so no job here declares a GITHUB_TOKEN scope and the caller grants none. +# - The caller triggers on pull_request_target, so the workflow and action SHAs resolve from the trusted base rather than the PR head. +# This is safe because no job checks out PR code, each one running gh pr merge by URL. +# - The github context in a called workflow is the caller's, so github.event.pull_request is the caller's pull_request_target payload. +# - Concurrency lives in the caller, keyed on the PR number with cancel-in-progress false, so no group is declared here. +on: + workflow_call: + inputs: + app-login: + description: Login of the App that opens codegen and version-bump pull requests. + required: false + type: string + default: ptr727-codegen[bot] + rules: + description: >- + JSON array of extra App pull request rules, appended to the built-in codegen and upstream-version pairs. + Each rule is {"head": ""} or {"head-prefix": ""} plus {"base": ""}. + The merge method follows the base, so a rule carries none. + required: false + type: string + default: '[]' + delete-branch: + description: Pass --delete-branch to gh pr merge, for a repo that keeps the repository-wide auto-delete off and still wants bot branches gone. + required: false + type: boolean + default: false + secrets: + CODEGEN_APP_CLIENT_ID: + required: true + CODEGEN_APP_PRIVATE_KEY: + required: true + +jobs: + + merge-dependabot: + name: Merge dependabot pull request job + runs-on: ubuntu-latest + # Dependabot PRs from this repo rather than from forks. + # Only on opened or reopened, so the disable job stays sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + # Auto-merge every tier, semver-major included: the required checks are the gate, not the bump magnitude. + - name: Merge pull request step + run: | + set -Eeuo pipefail + case "$BASE_REF" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: $BASE_REF" + exit 1 + ;; + esac + args=(--auto "$method") + if [[ "$DELETE_BRANCH" == "true" ]]; then args+=(--delete-branch); fi + gh pr merge "${args[@]}" "$PR_URL" + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + DELETE_BRANCH: ${{ inputs.delete-branch }} + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + merge-app: + name: Merge App pull request job + runs-on: ubuntu-latest + # App PRs from this repo, on opened or reopened so the disable job stays sticky. + # Which head and base pairs merge is data: the built-in codegen and upstream-version pairs plus the caller's rules. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == inputs.app-login && + github.event.pull_request.head.repo.full_name == github.repository + + steps: + + # Validate the rules input at entry and resolve the match once, so a malformed rule fails naming itself rather than as a silent non-merge. + # An App PR matching no rule is annotated rather than skipped silently, so a renamed tracker branch is visible. + - name: Resolve merge rule step + id: rule + run: | + set -Eeuo pipefail + if ! jq -e 'type == "array" and all(.[]; type == "object" and (.base | type) == "string" and ((.head | type) == "string" or (."head-prefix" | type) == "string"))' <<<"$RULES" >/dev/null 2>&1; then + echo "::error::inputs.rules is not a JSON array of {head|head-prefix, base} objects: $RULES" + exit 1 + fi + builtin='[ + {"head": "codegen-main", "base": "main"}, + {"head": "codegen-develop", "base": "develop"}, + {"head": "upstream-version-main", "base": "main"}, + {"head": "upstream-version-develop", "base": "develop"} + ]' + matched=$(jq -r --argjson extra "$RULES" --arg head "$HEAD_REF" --arg base "$BASE_REF" ' + . + $extra + | map(select(.base == $base) + | select((.head // "") == $head or ((."head-prefix" // "") as $p | $p != "" and ($head | startswith($p))))) + | length > 0' <<<"$builtin") + if [[ "$matched" != "true" ]]; then + echo "::warning::App pull request $HEAD_REF -> $BASE_REF matches no merge rule, not auto-merging" + fi + echo "matched=$matched" >>"$GITHUB_OUTPUT" + env: + RULES: ${{ inputs.rules }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + + - name: Generate GitHub App token step + if: ${{ steps.rule.outputs.matched == 'true' }} + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Merge pull request step + if: ${{ steps.rule.outputs.matched == 'true' }} + run: | + set -Eeuo pipefail + case "$BASE_REF" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: $BASE_REF" + exit 1 + ;; + esac + args=(--auto "$method") + if [[ "$DELETE_BRANCH" == "true" ]]; then args+=(--delete-branch); fi + gh pr merge "${args[@]}" "$PR_URL" + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + DELETE_BRANCH: ${{ inputs.delete-branch }} + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + disable-auto-merge-on-maintainer-push: + name: Disable auto-merge on maintainer push job + runs-on: ubuntu-latest + # Fires when a maintainer pushes to a bot's branch, meaning a synchronize whose actor is not the bot. + # It disables auto-merge so the maintainer's commits do not merge with the bot's, and the maintainer re-enables it manually. + # The disable call is idempotent. + if: >- + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == inputs.app-login + ) && + github.actor != github.event.pull_request.user.login + + steps: + + - name: Generate GitHub App token step + # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Disable auto-merge step + run: gh pr merge --disable-auto "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 3a7b62b0..7a416c61 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -108,7 +108,7 @@ jobs: # Each gate here is proven by a case that reintroduces the fault it catches, wherever it lives. # A test sits with the layer that owns it rather than beside the file it names, so a read-only gate over the spec and the host tooling lives in scripts/tests/ even where its subject is shell, and the agent-safety kit carries its own because it ships as a unit. # The tests are standard library only, so uvx coverage at its unpinned latest is the one tool the step fetches. - # The audit engine self-test is offline, so it runs here rather than only on an owner sweep. + # The audit engine self-test and the workflow-reuse measurement self-test are offline, so they run here rather than only on an owner sweep. # The write-guard self-test is offline too, and it otherwise runs only when a host installs the hook, which is where a regression in it would surface as a broken machine. # Every run goes through coverage with --append, so one report covers the whole self-test surface, informational with no threshold adopted. - name: Run script self-tests step @@ -116,6 +116,7 @@ jobs: set -Eeuo pipefail uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests uvx coverage@latest run --source=scripts,spec,host-setup --append spec/audit.py --selftest + uvx coverage@latest run --source=scripts,spec,host-setup --append spec/workflow_reuse.py --selftest uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/gh-write-guard.py --selftest uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/test_install.py uvx coverage@latest report diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 5473a184..f95ff037 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -117,7 +117,7 @@ The fleet's tooling lives in the hub once and a repository runs it from there ra **A report or finding a hub tool produces names the hub commit it ran from.** The tool moves independently of the repository it measures, so a verdict carrying no hub commit cannot be re-run, and two runs that disagree cannot be attributed to the tree or to the tool. The obligation is the runner's rather than the tool's, since a tool reports on the repository it measures rather than on itself, so the commit is read from the hub checkout and written into the report beside the verdict. This is the same requirement "Verification Discipline" places on any claim that gets acted on. -**CI reaches the same tooling as a pinned action.** A runner holds no hub checkout, so a workflow consumes the hub's composite action and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". The pin is what makes a released repository's gate reproducible, since an unpinned consume lets a later hub commit fail a re-run of a change that already passed. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. +**CI reaches the same tooling as a pinned action or reusable workflow.** A runner holds no hub checkout, so a workflow consumes the hub's composite action or reusable workflow and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". A standard workflow whose job graph is identical across repos of a type is reached the same way, as a `workflow_call` task the hub hosts once, and the repository carries only the caller stub and a composite-action hook for what is genuinely its own. The pin is what makes a released repository's gate reproducible, since an unpinned consume lets a later hub commit fail a re-run of a change that already passed. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. **An unreachable hub means the tool did not run, and that is the result reported.** A carried copy still works offline and a reached one does not, which is the cost this model trades away and the reason to state the failure rather than route around it. A check that cannot run reports itself as not run, never as clean, which is the silent-narrowing failure "Verification Discipline" names. A hand-rolled substitute is not the tool either: a reconstructed gate encodes its author's reading of the rule rather than the rule, agrees with no other repository, and is the duplicated effort this model exists to end, so an agent that cannot reach the hub says so and stops. @@ -199,7 +199,7 @@ This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this sectio - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. diff --git a/TODO.md b/TODO.md index 61a03ba6..3b33f2e0 100644 --- a/TODO.md +++ b/TODO.md @@ -116,7 +116,7 @@ One pull request asking the inverse question the fleet tools never ask, whether One pull request measuring the remaining carried surface against the carry-versus-reach test and moving whatever qualifies, now that the model is settled rather than open. -**State** `decision`. **Touches** [`AUDIT.md`][audit-doc], [`spec/secrets.json`][secrets], [`spec/files.json`][files], and [`catalog/snippets/workflows/`][workflows]. **Cost** one hub edit plus a retirement per repo on its next visit. +**State** `decision`. **Touches** [`AUDIT.md`][audit-doc], [`spec/secrets.json`][secrets], and [`spec/files.json`][files]. **Cost** one hub edit plus a retirement per repo on its next visit. The workflow half of this cluster, replacing copy-pasted workflow content with cross-repo reuse, is measured and answered under "Hub-Hosted Reusable Workflows" below. - **Measure carried [`AUDIT.md`][audit-doc] and [`spec/secrets.json`][secrets] against the test.** Each is adapted per repo today and the question is how much of each is genuinely per-repo. - **Blocked by** - Nothing. @@ -127,15 +127,6 @@ One pull request measuring the remaining carried surface against the carry-versu - **Settled** - `repo-config/configure.sh` is the first file moved across, carrying the ledger's only `retire` disposition and naming six repos, NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore and LanguageTags. - **Settled** - An unreachable hub means the tool did not run, reported as not run rather than worked around, since a hand-rolled substitute is the duplicated effort the model exists to end. -- **Investigate replacing copy-pasted workflow content with cross-repo reuse.** A public repository's composite actions and reusable workflows are consumable by any other repository regardless of owner type, so the organization account this pattern was assumed to need is not needed. - - **Blocked by** - Nothing. - - **Issue** - None filed. - - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where [`.github/actions/prose-gate/action.yml`][prose-gate] is the worked example and has zero callers, in this repo or the fleet. - - **Open** - Which jobs are genuinely identical across repos against which only look similar, since a reusable workflow needing a long input list to cover per-repo variation is worse than the copy it replaces. - - **Settled** - The catalog under [`catalog/snippets/workflows/`][workflows] is copied into each repo, so a fix to a shared job is a fleet sweep rather than one edit, and it is the mechanism by which a defect in a snippet seeds itself into every repo that adopted it. - - **Settled** - The ref policy is settled rather than open, since CI reaches hub code as an action pinned to a commit SHA, which is the action-pinning rule applied unchanged. - - **Settled** - `uses:` accepts no expressions, so a per-branch ref cannot be selected in the workflow file and any branch-dependent behavior belongs inside the consumed action, which is what the prose gate action does. - ### The README Structure Rework The spec rework and its audit check shipped. What remains is the per-repo conformance the check now reports, and one section the fleet carries that the model does not name. @@ -256,20 +247,61 @@ One pull request stating that an agent never assumes a Docker image is present l - **Settled** - What is missing is the rule rather than the one-liners, since an agent composing an ad-hoc `docker run` drops the flag precisely because it believes the image is cached. - **Settled** - The honest limit stops the flag reading as the whole answer, since `docker run` against a registry tag re-pulls an absent image on its own, so the cases that break are a locally built tag with no registry to pull from, and any command that branches on the image being present such as `docker image inspect` or `docker images`. -### The Merge-Bot Token Grants +### Hub-Hosted Reusable Workflows + +One pull request per phase moving a standard workflow out of every repo and into the hub as a `workflow_call` task, with a downstream caller stub and a composite-action hook for what is genuinely repo-specific. The design, the hook contract, the pin policy and the phases are in [`docs/reusable-workflows.md`][reusable-workflows-doc], and the burn-down is [`reports/workflow-reuse.md`][workflow-reuse-report], regenerated by `python3 spec/workflow_reuse.py --report`. The merge-bot phase shipped with the design, so this cluster starts at the gates. + +**State** `ready` for the gates, `blocked` on the gates for everything after. **Touches** the hub's `.github/workflows/`, [`spec/files.json`][files], [`catalog/snippets/workflows/`][workflows], and [`WORKFLOW.md`][workflow] where a guarantee names a copied job. **Cost** one hub edit per phase plus an adoption per repo on its next visit, and no re-vendor beyond the stub each phase introduces. + +- **Host the gates: `validate-task.yml` with a `validate` hook, and `test-pull-request-task.yml` with the fixed aggregator.** The hub owns the per-type doc-lint block once, the hook carries a repo's own tests, and the stub carries the trigger shape, operational or release. This phase is where the hook fallback is first proven live, on the hub for the default and on a pilot for the override. + - **Blocked by** - Nothing. + - **Issue** - None filed. [#585][issue-585] and [#729][issue-729] are settled inside this phase, the first by the operational stub's trigger and the second by the one place the hub validate task pins or floats its `uvx` tools. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where the report counts 20 copies of `test-pull-request.yml` in 13 variants and 13 copies of `validate-task.yml` in 11, and the doc-lint block (markdownlint, cspell, actionlint, editorconfig-checker) repeats in every one. + - **Open** - Whether the per-type lint steps are selected by an input the stub sets or read from the repo's registry entry through a hub checkout at `github.job_workflow_sha`, since the second needs no per-repo input and the first needs no network read. + - **Open** - Whether a `validate` hook that runs a domain compile (an ESPHome build, a KiCad ERC) is one hook or several, given the two repos carrying such a step run it as a separate job today. + - **Settled** - Pilots are HomeAutomation-Config, the smallest operational tree, then a C# repo, so both trigger shapes are exercised before the sweep. + - **Settled** - The step gated on `hashFiles('.github/actions/validate/action.yml') != ''` runs the caller's hook from its own checkout, else the default from a hub checkout under `.hub/`, and a local composite action resolves at step time from the workspace, which is what makes the fallback expressible at all. + +- **Host the pure functions: `get-version-task.yml` and `publish-plan-task.yml`.** Neither has a repo-specific line, and the plan job is missing where D4.1 needs it. + - **Blocked by** - The gates, only for sequencing, since a repo adopts one stub per visit and the gates come first. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where 5 of 8 `get-version-task.yml` copies are identical and all 3 `publish-plan-task.yml` copies are. + - **Open** - Nothing. + - **Settled** - PlexCleaner carries no `plan` job, so its next scheduled run ships a Dependabot bump, and the hub-hosted plan job is the fix rather than a per-repo copy. + +- **Host the release chain: `build-release-task.yml` with `build-` hooks, `publish-release-task.yml`, and the Docker core.** The orchestration is generic and the target list is per repo, which the hooks express without a per-repo copy of the orchestrator. + - **Blocked by** - The pure functions, since the release task calls both. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where the report counts 10 copies of `build-release-task.yml` in 6 variants, 17 of `publish-release.yml` in 12, and 5 of `build-docker-task.yml` in 4, with the Docker core identical in every copy. + - **Open** - The `matrix` input shape for a multi-image Docker repo, and whether a base-image build is a hook or a second task the stub calls first. + - **Settled** - The three no-asset release shapes the fleet runs today collapse into `expect_release_assets`, and PhotoCleaner and PlexCleaner pilot, then the NuGet, PyPI and Docker-only repos. + - **Settled** - Vanilla Docker repos need only `image` and build-args, ESPHome-NonRoot adds a `docker-prepare` hook for its upstream pin, and NxWitness adds the matrix hook and `build-base`, in that order. -One pull request dropping the unused token grants from the highest-blast-radius workflow shape in the fleet. +- **Host the type-specific tasks: Docker Hub readme, upstream-version tracking, deploy-site, codegen, and the date badge.** Each with its hook, and the two string-command inputs the catalog carries today (`transform-run`, `resolver-command`) become hooks. + - **Blocked by** - The release chain, since the readme task replaces the in-job description push. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where each of these has one or two carriers. + - **Open** - Whether ESPHome-NonRoot's second tracker, whose bump waits for a human, is the same task with `auto-merge: false` or stays repo-local. + - **Settled** - The `operational-vs-release-workflow` skill's note that a target-agnostic target list is "intentionally not done" is retired by this phase rather than before it, since it is true until then. -**State** `ready`. **Touches** [`.github/workflows/merge-bot-pull-request.yml`][merge-bot]. **Cost** one hub edit plus a re-vendor, since every repo carries the file. +- **Land the merge-bot caller snippet in the catalog once a release names the task.** The snippet under `catalog/snippets/workflows/` is scanned by the pin gate and cannot carry a placeholder SHA, so it lands one release after the task. + - **Blocked by** - The first hub release carrying `merge-bot-task.yml`. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, where `scripts/repo_gate.py check_sha_pin` scans every `workflows/*.yml` path including the catalog. + - **Open** - Nothing, and the stub's text is already in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Adopting the Merge-Bot". -- **Drop the grants no step consumes.** Every write in the file authenticates with the App token. +- **Decide the three merge-bot inputs the design leaves open.** The `delete-branch` default, the Dependabot semver-major filter two repos carry, and a `requiredHubUses` audit contract. + - **Blocked by** - Nothing, and each is a maintainer call rather than a finding. + - **Issue** - None filed. + - **Checked** - `develop` at `7c67328` on 2026-08-15, against the 16 downstream copies read for the design. + - **Open** - All three, stated in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Open Decisions". + - **Settled** - Neither blocks adoption: `delete-branch: false` is the hub's behavior and seven repos opt in, and the semver filter is a D8.1 conformance question for the two repos that carry it. + +- **Bring the Docker repos onto one multi-stage Dockerfile shape.** The build stages are inconsistent across the five Docker repos, and that is Dockerfile content rather than workflow content, so it rides beside the workflow migration rather than inside it. - **Blocked by** - Nothing. - - **Issue** - [#521][issue-521]. - - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where three jobs carry both `contents: write` and `pull-requests: write` and the fourth carries `pull-requests: write` alone, so the issue's claim that all four carry both is one job wide. - - **Open** - Whether to drop the job-level blocks or set an empty workflow-level permissions map. - - **Open** - Whether the audit compares permissions at all, given the file is `interface` fidelity with only a required-job-keys contract. - - **Settled** - The finding is least privilege on a `pull_request_target` workflow holding an App private key, where the grant is not exploitable today only because no step consumes it. - - **Settled** - [`spec/files.json`][files] declares this workflow at `appliesTo: "*"`, which closes the separate gap [#456][issue-456] raised, that the audit graded a file the file spec never required. + - **Issue** - None filed. + - **Checked** - Not measured. Raised by the maintainer on 2026-08-15 while reviewing the Docker family design, and the first action is a read of the five Dockerfiles. + - **Open** - Whether the shape is a `CODESTYLE.md` section, a `docker` type check, or both. ### Review Cost and the Local Review Pass @@ -504,6 +536,15 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w - **Detail** - The [#578][issue-578] widening is one of the two parts of this sweep that propagate a rule rather than refreshing a hash, the runbook correction above being the other, so a repo left on the old copy is wrong on the rule and not merely on the bytes, which makes the pair the half to carry first. It touches three `verbatim` [`GOVERNANCE.md`][governance] sections, "Branching Model", "Communicating with the User" and "Operational Repositories", and the third of those matters most on the two `operational` repos that can act on it. [`WORKFLOW.md`][workflow] took a cross-reference in the same change and is `intent`, so nothing reports it. - **Detail** - [`WORKFLOW.md`][workflow] is the seventh file and `repo-config/README.md` joins [`CODESTYLE.md`][codestyle] and [`.github/copilot-instructions.md`][copilot-instructions] at `intent`, where a punctuation-only edit produces no hash and therefore no audit finding at all. Nothing reports these, which is why they are recorded here rather than left to the run. `HISTORY.md` is `presence` and is each repo's own changelog, so its one fix owes nothing downstream. +- **Adopt the merge-bot caller stub, which is one file per repo replacing the copied job bodies.** The audit reports the missing `merge-bot` caller job on every copy until the repo adopts, which is the work list. + - **Hub state** - Done on `develop`, where `.github/workflows/merge-bot-task.yml` is the task and the hub's own `merge-bot-pull-request.yml` is the stub. The stub a repo copies is in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Adopting the Merge-Bot", and its pin is the first hub release carrying the task, so no repo can adopt before that release. + - **Outstanding** - Every repo carrying the file, 16 today, pilot on HomeAutomation-Config, then homeassistant-purpleair for the `rules` input. + - **Issue** - [#521][issue-521], whose hub half is done and whose sweep half this is. + - **Rides with** - The `verbatim` re-vendor above. + - **Detail** - The unused `GITHUB_TOKEN` grants #521 names are gone with the copy, since the task declares none and the stub sets `permissions: {}`. + - **Detail** - Two repos filter Dependabot by ecosystem and semver tier, and per D8.1 the filter drops on adoption unless the open decision in the cluster above lands first. + - **Detail** - The pilot records what the hub cannot prove, cross-repository resolution of the pin, the first Dependabot bump of it, and the `rules` input end to end, in its own audit report. + - **Carry the `Local Verification` heading into every repository's `OPERATIONS.md`.** The heading leads the file and states what verifying a change there requires, naming the part of the repo's contract CI structurally cannot exercise, and a repo whose gates are entirely in CI says that under it rather than omitting it. - **Hub state** - Done, verified `develop` at `8e10a2c` on 2026-08-06, where [`spec/section-model.md`][section-model] and [`STANDUP.md`][standup] declare six headings and this repo's own [`OPERATIONS.md`][operations] leads with the section. - **Outstanding** - Every repo carrying an `OPERATIONS.md`, which is every repo, since none holds the heading yet. @@ -594,6 +635,7 @@ Nothing is awaiting close today. [#578][issue-578] was the last entry here and c [issue-671]: https://github.com/ptr727/ProjectTemplate/issues/671 [issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 [issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 +[issue-729]: https://github.com/ptr727/ProjectTemplate/issues/729 @@ -627,7 +669,6 @@ Nothing is awaiting close today. [#578][issue-578] was the last entry here and c [install-tools]: ./host-setup/linux/install-tools.sh [markdownlint]: ./.markdownlint-cli2.jsonc [matrix]: ./reports/conformance-matrix.md -[merge-bot]: ./.github/workflows/merge-bot-pull-request.yml [operations]: ./OPERATIONS.md [peer-messaging-doc]: ./docs/peer-messaging.md [project-types]: ./spec/project-types.json @@ -637,6 +678,7 @@ Nothing is awaiting close today. [#578][issue-578] was the last entry here and c [repo-gate]: ./scripts/repo_gate.py [reports]: ./reports/ [repos]: ./registry/repos.json +[reusable-workflows-doc]: ./docs/reusable-workflows.md [scripts]: ./scripts/README.md [secrets]: ./spec/secrets.json [secrets-schema]: ./spec/secrets.schema.json @@ -647,5 +689,6 @@ Nothing is awaiting close today. [#578][issue-578] was the last entry here and c [type-model]: ./spec/type-model.md [validate-task]: ./.github/workflows/validate-task.yml [workflow]: ./WORKFLOW.md +[workflow-reuse-report]: ./reports/workflow-reuse.md [workflows]: ./catalog/snippets/workflows/ [write-guard]: ./host-setup/agent-safety/gh-write-guard.py diff --git a/WORKFLOW.md b/WORKFLOW.md index b9418e7d..42b6293a 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -202,7 +202,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* - **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. -- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base pairs, or auto-merge silently never fires. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. - **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. ### D9 - Style / Static (See Section 2) @@ -230,7 +230,7 @@ Read the workflow files plus `version.json` and assert the structural fact behin - **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. - **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`). Branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding). Artifact names are branch-suffixed. The target set is consistent across the release task and the paths-filter. - **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`. Reusable jobs declare permissions. Boolean `if:` uses both forms. -- **D8/D9:** merge-bot concurrency keys on PR number. The upstream tracker's branch prefix matches the merge-bot's head-ref pairs (wrapper repos). Actions are SHA-pinned. Names/shells/conditionals follow section 2. +- **D8/D9:** merge-bot concurrency keys on PR number. The upstream tracker's branch prefix matches a merge-bot rule (wrapper repos). Actions are SHA-pinned. Names/shells/conditionals follow section 2. **Per-type addenda (apply only the ones present):** diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index b4120282..6fc0095b 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -1,6 +1,6 @@ # Workflow snippets -The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here**: this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees. The audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. +The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here**: this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`, plus the hub-hosted reusable tasks a downstream repo reaches rather than carries (`merge-bot-task`, per [`docs/reusable-workflows.md`][reusable-workflows]). Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees. The audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | @@ -18,3 +18,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are ** | `check-upstream-version-task.yml` | Upstream-version tracker for wrapper repos | D3.5, D8.3 | | `run-codegen-pull-request-task.yml` | Deterministic codegen executor (per-branch PR) | D8.2 | | `run-periodic-codegen-pull-request.yml` | Scheduled codegen trigger over both branches | D8.2 | + + + +[reusable-workflows]: ../../../docs/reusable-workflows.md diff --git a/catalog/snippets/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml index 62486520..af63a5e9 100644 --- a/catalog/snippets/workflows/check-upstream-version-task.yml +++ b/catalog/snippets/workflows/check-upstream-version-task.yml @@ -28,9 +28,9 @@ on: type: string default: '["main", "develop"]' bump-branch-prefix: - # The merge-bot-pull-request.yml workflow hard-codes the head refs upstream-version-main/-develop. - # Keep this default unless you also update the merge-upstream-version job, or auto-merge will not fire. - description: Head-branch prefix; must match the merge-bot's upstream-version- head refs. + # The hub's merge-bot task carries upstream-version-main/-develop as built-in rules. + # Keep this default unless the caller passes a matching rules entry, or auto-merge will not fire. + description: Head-branch prefix, which must match a merge-bot rule such as the built-in upstream-version- head refs. required: false type: string default: upstream-version diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md new file mode 100644 index 00000000..37ed0307 --- /dev/null +++ b/docs/reusable-workflows.md @@ -0,0 +1,166 @@ +# Hub-Hosted Reusable Workflows (Hub-Only) + +The design for moving the fleet's standard GitHub Actions workflows out of every repo and into the hub, once, as reusable workflows a downstream repo reaches rather than carries. This doc is **hub-only** and is not carried downstream. It states the target model, the hook contract, the pin and secrets policy, the migration phases, and the measurement that tracks the burn-down. [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling] is the law this doc applies to workflows. [WORKFLOW.md][workflow] keeps the CI/CD contract every hosted workflow still has to satisfy. + +## Table of Contents + +- [Why](#why) +- [Measured State](#measured-state) +- [Target Model](#target-model) + - [Layers](#layers) + - [The Hook Contract](#the-hook-contract) + - [Pinning](#pinning) + - [Secrets and Permissions](#secrets-and-permissions) + - [The Hub Is Its Own First Consumer](#the-hub-is-its-own-first-consumer) +- [Hook Catalog](#hook-catalog) +- [The Docker Family](#the-docker-family) +- [Migration Phases](#migration-phases) +- [Adopting the Merge-Bot](#adopting-the-merge-bot) +- [What a Pilot Proves](#what-a-pilot-proves) +- [Open Decisions](#open-decisions) + +## Why + +A standard workflow copied into twenty repos is twenty files that go stale at twenty different rates. A fix to a shared job is a fleet sweep rather than one edit, and a defect in a snippet seeds itself into every repo that adopted it. The carry-versus-reach test in [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling] already decides this for scripts: a file holding no per-repo content is a copy whose only future is to go stale. A workflow whose job graph is identical across repos of a type is that file. + +The audit today grades a carried workflow at `interface` fidelity, meaning it checks the job keys and the artifact seam and leaves the body owned. That was the strongest contract the schema could express for a copied file. Two of the most-copied files, `publish-release.yml` and `validate-task.yml`, could not even take that, because their job lists come in six and twelve shapes across the fleet. Hosting the job graph in the hub is the contract the schema could not express. + +## Measured State + +`python3 spec/workflow_reuse.py --report` reads every cataloged repo's `.github/workflows/` from its ground-truth branch. It compares each file against the hub canonical of the same name, after the normalization the verbatim engine applies. It clusters the copies of each canonical into variants and writes [reports/workflow-reuse.md][workflow-reuse-report]. That report is the burn-down. Its fleet total names the files, the lines, the share of lines byte-identical to a canonical, and the files that already reach a hub reusable workflow or composite action. The migration is done when the copies of each standard workflow reach zero and the callers reach the number of repos that need it. + +The first run, at hub `7c67328` on 2026-08-15, read 108 workflow files and 10,964 lines across 20 downstream repos. 5,385 of those lines, 49 percent, are byte-identical to a hub canonical, and the rest is mostly a per-repo edit of the same canonical rather than independent code. One file reaches the hub, PhotoCleaner's `validate-task.yml` consuming the `prose-gate` action. Every other copy carries its job bodies. + +## Target Model + +A workflow whose job graph is identical across repos of a type is reached, not carried. The hub hosts it as a `workflow_call` reusable workflow. A downstream repo carries a caller stub, meaning the trigger, the concurrency group, one pinned `uses:`, an explicit `secrets:` map, and a `with:` block for the inputs it sets. Where a repo has behavior of its own, it carries a composite action at the conventional hook path. Everything else a repo carries today for that workflow is deleted. + +### Layers + +1. **The hub reusable workflow**, at `.github/workflows/-task.yml` in the hub. It follows [GOVERNANCE.md "Workflow YAML Conventions"][governance-workflow-yaml-conventions], so the file ends `-task.yml` and its `name:` ends "task". It owns the job graph, the permissions each job needs, the validate-at-entry step, the artifact seam, retention, and the ruleset-bound aggregator name. It checks out the caller's repo by default. When it needs its own defaults or scripts, it checks out the hub at `${{ github.job_workflow_sha }}` under `.hub/`, which is the commit the caller pinned. +2. **The hook**, a composite action at `.github/actions//action.yml` in the caller's repo. A hub job resolves it in one order: the caller's path when `hashFiles('.github/actions//action.yml')` is non-empty, else the hub default at the same name under `.hub/`. A required hook with no default fails its job with `::error::` naming the missing path. +3. **The caller stub**, downstream, under thirty lines. The audit grades it at `interface` fidelity: the caller job key, the hub task the `uses:` names, and the secrets it maps are the contract, and the `with:` block is the repo's own. +4. **The hub's own use.** The hub calls its own task files by `./` path, so every hub pull request exercises the reusable file at least at parse level, and fully for the workflows the hub itself runs. + +### The Hook Contract + +A hook receives the fixed inputs [WORKFLOW.md "Reusable-Task Parameter Contract"][workflow-reusable-task-parameter-contract] gives a leaf today. Those are `ref`, `branch`, `smoke` where relevant, and the NBGV version outputs where a build needs them. It reports back through step outputs and, for a release target, through the existing artifact seam, `release-asset--`, which the hub's `github-release` job collects by pattern. A hook may use marketplace actions, which is the reason a hook is a composite action rather than a script. A toolchain setup, a Docker build, or a coverage upload is a `uses:` step, and a shell script cannot carry one. Action pins inside a hook follow the SHA-pinning rule like any other workflow content, and Dependabot bumps them in the repo that carries the hook. + +### Pinning + +A downstream `uses:` reads `ptr727/ProjectTemplate/.github/workflows/-task.yml@ # `. The SHA is the hub `main` commit a release tag points at, and the comment is that tag. Hub tags carry no `v`, so the comment reads `# 2.0.334` rather than `# v2.0.334`. A `develop` SHA is not bumpable, because Dependabot compares the pinned commit against tags and a commit no tag names has no version to bump from, which PhotoCleaner's `prose-gate` pin documents in place. Dependabot's `github-actions` ecosystem keeps reusable-workflow references current the same way it keeps action pins current, so a released pin bumps on the same schedule as every other action in the repo. The first such bump in the fleet is the live proof of that sentence. + +A downstream pull request may pin a hub feature-branch SHA to test a hub change that is still in flight, and re-pins to a released `main` SHA before it merges. `scripts/repo_gate.py check_sha_pin` reads the owner and repository out of the reference and confirms the SHA resolves, for a reusable workflow exactly as for an action. + +The sequencing consequence is that a hub task lands on `develop`, promotes to `main`, and is released before any downstream carries a bumpable pin. The catalog snippet for a caller stub therefore lands one release after the task it names, since a snippet under `catalog/snippets/workflows/` is scanned by the same pin gate and cannot carry a placeholder SHA. + +### Secrets and Permissions + +Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, each `required: true`, and a caller maps each one explicitly. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. + +A hub task declares no job-level `permissions:` where every write goes through the App token, and the caller sets `permissions: {}`. A called workflow can only keep or reduce the caller's grant. A callee job naming a scope the caller did not grant fails at startup even when its `if:` is false. Declaring nothing in the callee is therefore the shape that cannot fail against any caller, and it gives `GITHUB_TOKEN` no scope. A task whose job genuinely writes with `GITHUB_TOKEN`, such as a release upload, declares that scope in the callee job and documents it in the stub's comment so the caller grants it. + +### The Hub Is Its Own First Consumer + +The hub's own `.github/workflows/` carries the caller stubs it needs, each calling its task by `./` path. The stub is byte-shaped like a downstream stub apart from that one line, so a change to a task is felt in the hub's own CI first. This is also what makes the hook fallback path run on every hub pull request. The hub carries no hook of its own for a job with a default, so the default runs here on every change. + +## Hook Catalog + +The target set. A row exists once its hub task ships, and until then the row is the plan. + +| Hub task | Hooks, at `.github/actions/` in the caller | Hub default | +| --- | --- | --- | +| `merge-bot-task.yml` | none, extra bot rules are a `with:` input | not applicable | +| `validate-task.yml` | `validate` (repo tests and lint beyond the fleet doc-lint block) | no-op | +| `test-pull-request-task.yml` | none, wires validate, smoke and the aggregator, `smoke` is a boolean input | not applicable | +| `get-version-task.yml`, `publish-plan-task.yml` | none | not applicable | +| `build-release-task.yml` | `build-executable`, `build-nuget`, `build-pypi`, `release-assets` (extra files) | executable, nuget and pypi defaults from today's snippets | +| `build-docker-task.yml` | `docker-prepare` (extra tags, build-args, matrix), `docker-build-base` | vanilla single-target from `image`, base build required when `build-base` | +| `publish-docker-readme-task.yml` | `docker-readme-transform` | publish `Docker/README.md` or `README.md` as-is | +| `publish-release-task.yml` | none, trigger policy stays in the caller stub and reaches the plan job as `event_name`, `actor` and `ref_name` | not applicable | +| `check-upstream-version-task.yml` | `resolve-upstream` | none, required | +| `deploy-site-task.yml`, `codegen-task.yml` | `deploy`, `codegen` | none, required | + +## The Docker Family + +The five live `build-docker-task.yml` copies share an identical core. It is QEMU and Buildx setup, a Docker Hub login on every build for the higher rate limit, and `docker/build-push-action` with a `type=registry` `buildcache-` cache. It tags `latest` or `develop` plus `SemVer2`, passes a `LABEL_VERSION` build-arg, and pushes the Docker Hub description on a `main` publish. What varies is data or a pre-step, never the core. + +- **Vanilla single-target** repos differ only in the image name and the build-arg list. That is data, carried in the stub's `with:`. +- **Upstream-pinned** repos read a committed upstream version file before the build and add a `:` tag and version build-args. That is a `docker-prepare` hook. +- **Multi-image** repos read a matrix file, optionally build base images first, then build each image with its own tags, args and cache repository. That is a `docker-prepare` hook emitting the matrix, plus a `docker-build-base` hook the task calls when `build-base` is set. + +The hub task takes `push`, `ref`, `branch`, `smoke`, the NBGV version outputs, `image`, an optional `matrix` (a JSON list of `{name, tags, build-args, context, dockerfile, cache-repo}`, defaulting to the single entry `image` implies), and `build-base`. The core job body stays hub-owned, so the cache policy, the multi-arch platform selection (`linux/amd64,linux/arm64` on a non-smoke `main` build), the login-on-smoke, and the description push are decided once. + +Docker Hub README publishing is a hub task of its own, `publish-docker-readme-task.yml`, with the size-limited overview, the repository list, and a `docker-readme-transform` hook in place of today's `transform-run` string input. The in-job description push in the build task is dropped in its favor, so the readme publishes once per release rather than once per image build. Upstream dependency monitoring is one hub task, `check-upstream-version-task.yml`, with a `resolve-upstream` hook in place of today's `resolver-command` string input and an `auto-merge` input. A tracker whose bump must wait for a human sets `auto-merge: false`, which gives the pull request a head prefix the merge-bot rules do not match. The rebuild-on-upstream-change trigger stays in the caller stub as a `push` filtered to the state file. Multi-stage Dockerfile builds are inconsistent across the Docker repos, and that is Dockerfile content rather than workflow content, so it is tracked as a type-level improvement beside this work rather than inside it. + +## Migration Phases + +Each phase is one hub pull request, followed by a per-repo adoption on that repo's next visit. The exit metric per phase comes from [reports/workflow-reuse.md][workflow-reuse-report]. Downstream copies of the phase's files fall to zero. Callers rise to the number of repos that need the workflow. Downstream workflow lines fall from 10,964 toward the stubs plus the genuinely repo-specific hooks. + +1. **Merge-bot** (this phase). `merge-bot-task.yml` is hosted, the hub's own `merge-bot-pull-request.yml` becomes the caller stub, and the manifest contract for `merge-bot-pull-request.yml` becomes the caller job, the hub task token, and the two mapped secrets. The 16 downstream copies report the missing caller job until each adopts, and the [Adopting the Merge-Bot](#adopting-the-merge-bot) section is the adoption. +2. **Gates.** `validate-task.yml` hosts the per-type doc-lint block once and calls the `validate` hook for a repo's own tests. `test-pull-request-task.yml` wires validate, smoke and the fixed aggregator name, with the operational trigger shape in the stub. This phase is where the hook fallback is first proven live, on the hub for the default and on a pilot for the override. +3. **Pure functions.** `get-version-task.yml` and `publish-plan-task.yml` are hosted and the downstream copies deleted. +4. **The release chain.** `build-release-task.yml` with `build-` hooks, `publish-release-task.yml`, and the Docker core per [The Docker Family](#the-docker-family). The three no-asset release shapes the fleet runs today collapse into `expect_release_assets`. +5. **Type-specific tasks.** Docker Hub readme, upstream-version tracking, deploy-site, codegen, and the date badge, each with its hook. + +## Adopting the Merge-Bot + +A downstream repo replaces the whole of its `.github/workflows/merge-bot-pull-request.yml` with the stub below, pinned to a released hub commit, and deletes nothing else. Its App-signed pull requests keep merging by the built-in rules (`codegen-main` to `main`, `codegen-develop` to `develop`, `upstream-version-main` to `main`, `upstream-version-develop` to `develop`). A repo with a tracker outside those pairs adds one `rules` entry per pair, and a repo that keeps its repository-wide branch auto-delete off and still wants bot branches gone sets `delete-branch: true`. + +```yaml +name: Merge bot pull request action + +# Thin caller: the merge-bot is the hub's reusable merge-bot-task.yml, which every fleet repo reaches rather than carries. +# The trigger is pull_request_target so the called workflow resolves from the trusted base rather than the PR head, and no job checks out PR code. +on: + pull_request_target: + types: [opened, reopened, synchronize] + +# Concurrency keys on the PR number rather than on github.ref, which under pull_request_target is the base branch and would serialize every bot PR against it, so each PR queues independently. +# The cancel-in-progress setting is false so a follow-up synchronize does not cancel an in-flight opened run before it enables auto-merge. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +# Every write in the called workflow uses the App token, so GITHUB_TOKEN gets no scope. +permissions: {} + +jobs: + + merge-bot: + name: Merge bot pull request job + uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@ # + secrets: + CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + # Only where the repo has a tracker outside the built-in pairs, or keeps auto-delete off and wants bot branches gone. + with: + rules: '[{"head-prefix": "ha-version-bump/", "base": "develop"}]' + delete-branch: true +``` + +The task's inputs are `app-login` (default `ptr727-codegen[bot]`), `rules` (a JSON array of `{"head": ""}` or `{"head-prefix": ""}` plus `"base"`, default `[]`), and `delete-branch` (default `false`). The merge method follows the base, `develop` squashes and `main` merges, so a rule carries none. An App pull request that matches no rule is annotated with a warning rather than merged, so a renamed tracker branch is visible in the run rather than silent. + +Two copies today filter Dependabot by ecosystem and semver tier before merging. [WORKFLOW.md D8.1][workflow-d8] says every Dependabot tier auto-merges and the required checks are the gate, so those two repos drop the filter on adoption unless the [Open Decisions](#open-decisions) below settle otherwise. + +## What a Pilot Proves + +The hub's own stub proves most of the mechanics on the first Dependabot pull request after the task lands on `develop`. That run shows the callee reading the caller's `github.event.*` under `pull_request_target`. It shows an explicit `secrets:` map reaching the callee and the App token minting inside one. It shows `permissions: {}` at the caller not failing the callee at startup, and `--squash` running on `develop`. A Dependabot pull request against `main` after promotion proves `--merge`, and a maintainer push to a bot branch proves the disable job. A hub feature branch cannot test itself, since under `pull_request_target` the callee resolves from the base branch, so the proof follows the merge rather than preceding it. + +Four things the hub cannot prove fall to the first downstream adopter. They are cross-repository resolution of the owner-scoped `uses:` reference, Dependabot bumping a `# ` pin on a reusable workflow, and the `rules` input end to end on a repo with a tracker. The fourth is `merge-app` itself, since nothing opens App pull requests against the hub. A pilot records each of those as observed in its own audit report rather than assumed here. + +## Open Decisions + +- **`delete-branch` default.** `false` matches the hub's behavior, and seven repos opt in today. A fleet default of `true` is one edit to the task and removes seven `with:` blocks. The repository setting that protects `develop` from a promotion is unaffected either way, since a bot branch is never `develop`. +- **The Dependabot semver-major filter.** Two repos skip a nuget semver-major bump. Either it drops on adoption per D8.1, or the task grows a `skip-semver-major-ecosystems` input with a `dependabot/fetch-metadata` step run under the App token. Decide before those two repos adopt, everything else adopts unaffected. +- **A `requiredHubUses` audit contract.** The interface check today asserts the task filename token in the caller job. A field asserting the full owner-scoped form on a downstream copy and the `./` form on the hub is a small schema extension. It waits for the first adoption to show whether the token check misses anything. + + + +[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling +[governance-workflow-yaml-conventions]: ../GOVERNANCE.md#workflow-yaml-conventions +[secrets]: ../spec/secrets.json +[workflow]: ../WORKFLOW.md +[workflow-d8]: ../WORKFLOW.md#d8---bots--automation +[workflow-reusable-task-parameter-contract]: ../WORKFLOW.md#reusable-task-parameter-contract +[workflow-reuse-report]: ../reports/workflow-reuse.md diff --git a/reports/workflow-reuse.md b/reports/workflow-reuse.md new file mode 100644 index 00000000..d0b90c2a --- /dev/null +++ b/reports/workflow-reuse.md @@ -0,0 +1,161 @@ +# Fleet workflow reuse report + +Generated by `python3 spec/workflow_reuse.py --report` at hub `7c67328` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. + +## Fleet Total + +- **108 workflow files, 10,964 lines** across 20 downstream repos, 103 of them named for a hub canonical. No workflow at all in EspDinIoT. +- **4,201 lines (38%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code. +- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: 1.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands. + +## Per Workflow + +Downstream copies of each hub canonical. A variant is a cluster of copies at or above 0.85 pairwise similarity, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies. + +| File | Copies | Lines | Identical to hub | Variants | Callers | +| --- | --- | --- | --- | --- | --- | +| `build-release-task.yml` | 10 | 1,934 | 1,011 | 6 | 0 | +| `merge-bot-pull-request.yml` | 16 | 1,694 | 191 | 8 | 0 | +| `test-pull-request.yml` | 20 | 1,652 | 486 | 13 | 0 | +| `publish-release.yml` | 17 | 1,345 | 539 | 12 | 0 | +| `validate-task.yml` | 13 | 1,289 | 452 | 11 | 1 | +| `build-docker-task.yml` | 5 | 625 | 298 | 4 | 0 | +| `get-version-task.yml` | 8 | 495 | 364 | 4 | 0 | +| `publish-plan-task.yml` | 3 | 252 | 207 | 1 | 0 | +| `build-executable-task.yml` | 2 | 214 | 154 | 1 | 0 | +| `deploy-site-task.yml` | 1 | 191 | 119 | 1 | 0 | +| `run-codegen-pull-request-task.yml` | 2 | 161 | 140 | 1 | 0 | +| `check-upstream-version-task.yml` | 1 | 133 | 109 | 1 | 0 | +| `deploy-site.yml` | 1 | 55 | 41 | 1 | 0 | +| `run-periodic-codegen-pull-request.yml` | 2 | 48 | 40 | 2 | 0 | +| `build-datebadge-task.yml` | 1 | 37 | 27 | 1 | 0 | +| `publish-docker-readme-task.yml` | 1 | 34 | 23 | 1 | 0 | + +### Variant Members + +Each variant names the repos whose copies cluster together, so a hub task's inputs and hooks can be designed against the shapes that exist rather than against the canonical alone. + +- `build-release-task.yml` + - 2: ESPHome-NonRoot, VSCode-Server-DotNetCore + - 1: KiCadLibrary + - 3: LanguageTags, MediaTools, Utilities + - 2: PhotoCleaner, PlexCleaner + - 1: aiopurpleair + - 1: homeassistant-purpleair +- `merge-bot-pull-request.yml` + - 1: Blog + - 6: ESPHome-Config, HomeAssistant-Config, HomeAutomation-Config, PlexCleaner, Utilities, Vantage-Config + - 3: ESPHome-NonRoot, NxWitness, homeassistant-purpleair + - 1: KiCadLibrary + - 1: LanguageTags + - 2: MediaTools, aiopurpleair + - 1: PhotoCleaner + - 1: VSCode-Server-DotNetCore +- `test-pull-request.yml` + - 2: AudioCleaner, Financial-Modeling + - 1: Blog + - 2: DevKitCIoT, HolidayLights + - 1: ESPHome-Config + - 1: ESPHome-NonRoot + - 2: HomeAssistant-Config, Vantage-Config + - 1: HomeAutomation-Config + - 1: KiCadLibrary + - 4: LanguageTags, MediaTools, Utilities, aiopurpleair + - 1: NxWitness + - 1: PhotoCleaner + - 2: PlexCleaner, VSCode-Server-DotNetCore + - 1: homeassistant-purpleair +- `publish-release.yml` + - 1: Blog + - 3: ESPHome-Config, HomeAssistant-Config, Vantage-Config + - 1: ESPHome-NonRoot + - 2: Financial-Modeling, HomeAutomation-Config + - 1: KiCadLibrary + - 2: LanguageTags, MediaTools + - 1: NxWitness + - 2: PhotoCleaner, PlexCleaner + - 1: Utilities + - 1: VSCode-Server-DotNetCore + - 1: aiopurpleair + - 1: homeassistant-purpleair +- `validate-task.yml` + - 3: AudioCleaner, MediaTools, Utilities + - 1: Blog + - 1: ESPHome-NonRoot + - 1: Financial-Modeling + - 1: HomeAutomation-Config + - 1: LanguageTags + - 1: NxWitness + - 1: PhotoCleaner + - 1: PlexCleaner + - 1: VSCode-Server-DotNetCore + - 1: aiopurpleair +- `build-docker-task.yml` + - 1: ESPHome-NonRoot + - 1: NxWitness + - 2: PhotoCleaner, PlexCleaner + - 1: VSCode-Server-DotNetCore +- `get-version-task.yml` + - 5: ESPHome-NonRoot, NxWitness, PhotoCleaner, PlexCleaner, VSCode-Server-DotNetCore + - 1: KiCadLibrary + - 1: aiopurpleair + - 1: homeassistant-purpleair +- `publish-plan-task.yml` + - 3: ESPHome-NonRoot, NxWitness, Utilities +- `build-executable-task.yml` + - 2: PhotoCleaner, PlexCleaner +- `deploy-site-task.yml` + - 1: Blog +- `run-codegen-pull-request-task.yml` + - 2: LanguageTags, NxWitness +- `check-upstream-version-task.yml` + - 1: ESPHome-NonRoot +- `deploy-site.yml` + - 1: Blog +- `run-periodic-codegen-pull-request.yml` + - 1: LanguageTags + - 1: NxWitness +- `build-datebadge-task.yml` + - 1: KiCadLibrary +- `publish-docker-readme-task.yml` + - 1: VSCode-Server-DotNetCore + +## Per Repo + +| Repo | Files | Lines | Identical to hub | Callers | Repo-local files | +| --- | --- | --- | --- | --- | --- | +| AudioCleaner | 2 | 134 | 55 | 0 | - | +| Blog | 6 | 560 | 317 | 0 | - | +| DevKitCIoT | 1 | 58 | 25 | 0 | - | +| ESPHome-Config | 3 | 450 | 90 | 0 | - | +| ESPHome-NonRoot | 11 | 1,154 | 463 | 0 | `check-upstream-dependency.yml`, `check-upstream-version.yml` | +| Financial-Modeling | 3 | 233 | 124 | 0 | - | +| HolidayLights | 1 | 53 | 25 | 0 | - | +| HomeAssistant-Config | 3 | 229 | 88 | 0 | - | +| HomeAutomation-Config | 4 | 248 | 144 | 0 | - | +| KiCadLibrary | 6 | 772 | 221 | 0 | - | +| LanguageTags | 7 | 724 | 265 | 0 | - | +| MediaTools | 5 | 530 | 173 | 0 | - | +| NxWitness | 10 | 1,102 | 363 | 0 | `build-base-images-task.yml` | +| PhotoCleaner | 8 | 827 | 457 | 1 | - | +| PlexCleaner | 8 | 805 | 455 | 0 | - | +| Utilities | 6 | 621 | 242 | 0 | - | +| VSCode-Server-DotNetCore | 8 | 563 | 297 | 0 | - | +| Vantage-Config | 3 | 229 | 88 | 0 | - | +| aiopurpleair | 6 | 611 | 198 | 0 | - | +| homeassistant-purpleair | 7 | 1,061 | 111 | 0 | `check-ha-version.yml`, `test-release-task.yml` | + +## Repo-Local Workflows + +A workflow no hub canonical names. Each is either genuinely repo-specific, and stays, or a candidate for a hub task with a hook, and the design doc lists which. + +- **ESPHome-NonRoot** `check-upstream-dependency.yml` (111 lines) +- **ESPHome-NonRoot** `check-upstream-version.yml` (41 lines) +- **NxWitness** `build-base-images-task.yml` (89 lines) +- **homeassistant-purpleair** `check-ha-version.yml` (316 lines) +- **homeassistant-purpleair** `test-release-task.yml` (248 lines) + + + +[fidelity-model]: ../spec/fidelity-model.md +[reusable-workflows]: ../docs/reusable-workflows.md diff --git a/scripts/tests/test_repo_gate.py b/scripts/tests/test_repo_gate.py index 1b6e52fb..17d5a50f 100755 --- a/scripts/tests/test_repo_gate.py +++ b/scripts/tests/test_repo_gate.py @@ -157,6 +157,13 @@ def test_an_action_path_within_a_repository_resolves_against_the_repository(self self.assertEqual([], repo_gate.check_sha_pin(self.tmp, self.pins(ref))) stub.assert_called_once_with(f"repos/{self.OWNER}/Fleet/commits/{PINNED}") + def test_a_reusable_workflow_ref_resolves_against_its_repository(self) -> None: + """A caller stub pins `owner/repo/.github/workflows/x-task.yml@sha`, one repository again.""" + stub = self.answers({f"repos/{self.OWNER}/Fleet/commits/{PINNED}": True}) + ref = f"{self.OWNER}/Fleet/.github/workflows/merge-bot-task.yml@{PINNED}" + self.assertEqual([], repo_gate.check_sha_pin(self.tmp, self.pins(ref))) + stub.assert_called_once_with(f"repos/{self.OWNER}/Fleet/commits/{PINNED}") + def test_a_pin_under_another_owner_is_read_for_shape_and_never_fetched(self) -> None: """The scope is a decision, so a case holds it rather than leaving it to the docstring.""" stub = self.answers({}) diff --git a/spec/audit.py b/spec/audit.py index 4c069ed7..52a94d47 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -1230,7 +1230,7 @@ def check_interface(path, contract, text): findings.append( ( "DRIFT", - f"interface: {path} job '{job}' uses forbidden '{t}' (forks the verbatim github-release download - see AGENTS.md override seam)", + f"interface: {path} job '{job}' uses forbidden '{t}' (forks the verbatim github-release download, see WORKFLOW.md \"The Seam Contract\")", ) ) return findings @@ -2084,6 +2084,22 @@ def _selftest(): "requireTokensInJob": {"github-release": ["pattern:", "merge-multiple:"]}, "forbidTokensInJob": {"github-release": ["artifact-ids:"]}, } + # The merge-bot caller stub, the shape every repo carries once the merge-bot is hub-hosted. + bot_stub = ( + "jobs:\n" + " merge-bot:\n" + " name: Merge bot pull request job\n" + " uses: acme/hub/.github/workflows/merge-bot-task.yml@" + "a" * 40 + " # 2.0.1\n" + " secrets:\n" + " CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }}\n" + " CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}\n" + ) + bot_contract = { + "requiredJobKeys": ["merge-bot"], + "requireTokensInJob": { + "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] + }, + } cases = [ ("conformant PR workflow", pr_head + pr_check, pr_contract, 0), ("PR workflow missing the required job and its check name", pr_head, pr_contract, 2), @@ -2155,6 +2171,38 @@ def _selftest(): rel_contract, 1, ), + ( + "merge-bot caller stub reaching the hub task with both secrets mapped", + bot_stub, + bot_contract, + 0, + ), + ( + "merge-bot caller stub with the hub's own local uses", + bot_stub.replace( + "acme/hub/.github/workflows/merge-bot-task.yml@" + "a" * 40 + " # 2.0.1", + "./.github/workflows/merge-bot-task.yml", + ), + bot_contract, + 0, + ), + ( + "merge-bot copy still carrying the job bodies reports the missing caller job once", + ( + "jobs:\n merge-dependabot:\n runs-on: ubuntu-latest\n steps: []\n" + " disable-auto-merge-on-maintainer-push:\n runs-on: ubuntu-latest\n steps: []\n" + ), + bot_contract, + 1, + ), + ( + "merge-bot caller stub that maps only one secret", + bot_stub.replace( + " CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}\n", "" + ), + bot_contract, + 1, + ), ] ok = True for label, text, contract, want in cases: diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index e07e0956..296ba8a5 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -20,13 +20,13 @@ Fidelity is a declared field defaulting to `presence`, never inferred from `whol ## Why Each Unit Sits Where It Does - **verbatim** - `.markdownlint-cli2.jsonc` (fleet-generic, no governed divergence), the `github-release` job region of the release task (the canonical orchestration a repo must not fork), and the universal rule sections of `AGENTS.md`, which are fleet-law with no repo-specific content (no SHAs, no `ptr727/` references) and where a paraphrase or a missing rule is a defect, not an adaptation. Which `AGENTS.md` sections are verbatim and which stay intent (the few that describe one repo) is defined in [section-model.md][section-model]. -- **interface** - the release and PR workflows. Their fixed contract is the job and check names plus the artifact handoff, while the leaf build jobs are owned. See the override seam in [`GOVERNANCE.md`][governance]. +- **interface** - the release, PR, and bot workflows. Their fixed contract is the job and check names plus the artifact handoff, or for a caller stub the hub task it reaches and the secrets it maps, while the leaf build jobs are owned. See [`WORKFLOW.md`][workflow] "Two Layers: Orchestration vs Build" and [`docs/reusable-workflows.md`][reusable-workflows]. - **intent** - `.editorconfig` and `.gitattributes` (the `[*] end_of_line` default and path pins vary by platform), `cspell.json` (the words list and file scope vary), `CODESTYLE.md` / `WORKFLOW.md` / `AUDIT.md` / `.github/copilot-instructions.md` (carried docs judged by meaning), and the ruleset payloads (whose live state is diffed separately). - **presence** - `README.md`, `HISTORY.md`, `.gitignore`, and the per-repo config that only needs to exist. ## The Workflow Override Seam Contract -The fixed interface of a workflow is stated in [`GOVERNANCE.md`][governance] ("Orchestration vs. build - the override seam" and "Workflow YAML Conventions"), and the `interface` check enforces it by name and structure: the ruleset-bound required check `name: Check pull request workflow status job`, the `github-release` and `get-version` job keys, the `release-asset--` artifact-name handoff, and that `github-release` collects assets by `pattern:` / `merge-multiple:` and never by an `artifact-ids:` that names a build job's output. A repo owns the leaf `build--task` job list, its `needs` targets, and its paths-filter, and none of those are checked. +The fixed interface of a workflow is stated in [`WORKFLOW.md`][workflow] ("Two Layers: Orchestration vs Build" and "The Seam Contract") and [`GOVERNANCE.md`][governance] ("Workflow YAML Conventions"), and the `interface` check enforces it by name and structure: the ruleset-bound required check `name: Check pull request workflow status job`, the `github-release` and `get-version` job keys, the `release-asset--` artifact-name handoff, and that `github-release` collects assets by `pattern:` / `merge-multiple:` and never by an `artifact-ids:` that names a build job's output. A repo owns the leaf `build--task` job list, its `needs` targets, and its paths-filter, and none of those are checked. ## Normalization @@ -41,5 +41,7 @@ A verbatim mismatch is one of two things, told apart **by hash, not by a version [files]: ./files.json [files-schema]: ./files.schema.json [governance]: ../GOVERNANCE.md +[reusable-workflows]: ../docs/reusable-workflows.md [scope-model]: ./scope-model.md [section-model]: ./section-model.md +[workflow]: ../WORKFLOW.md diff --git a/spec/files.json b/spec/files.json index 46a61f67..11aa7dab 100644 --- a/spec/files.json +++ b/spec/files.json @@ -30,7 +30,7 @@ { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job" }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, { "path": ".github/workflows/validate-task.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke", "appliesTo": "*" }, { "path": ".github/workflows/publish-release.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] }, - { "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-dependabot", "disable-auto-merge-on-maintainer-push"] }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" }, + { "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-bot"], "requireTokensInJob": { "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] } }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" }, { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "validate-release", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "GOVERNANCE.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, { "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "secrets: inherit"] } }, "reference": "catalog/snippets/workflows/deploy-site.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, { "path": ".github/workflows/deploy-site-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-environment", "deploy"], "requireTokensInJob": { "deploy": ["environment:"] } }, "reference": "catalog/snippets/workflows/deploy-site-task.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, diff --git a/spec/workflow_reuse.py b/spec/workflow_reuse.py new file mode 100755 index 00000000..65f8a573 --- /dev/null +++ b/spec/workflow_reuse.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Workflow-reuse measurement: how much of the fleet's GitHub Actions YAML is a copy of a hub canonical. + +Read-only, owner-run, not wired into CI. Reuses spec/audit.py's fleet machinery (gh, normalize, registry). + +The hub hosts a standard workflow once, as a `workflow_call` reusable workflow a downstream repo reaches by a +pinned `uses:`, and a downstream repo carries only a caller stub plus a composite-action hook for what is +genuinely its own. This tool measures the distance from that model, so the burn-down is a number rather than +an impression: + + 1. Every `.github/workflows/*.yml` in every cataloged repo, read from its ground-truth branch, is inventoried + and compared line by line against the hub canonical of the same name (the hub's own workflow or the + catalog snippet), after the same normalization the verbatim engine applies (line endings, action pins, + job needs), so a Dependabot bump is not counted as divergence. + 2. Copies of one canonical are clustered by pairwise similarity, which answers the question that decides + whether a job is worth hosting once: how many genuinely distinct variants of it the fleet runs. + 3. A file that reaches a hub reusable workflow or a hub composite action is counted as a caller, which is + the state every carried copy converges to. + +The output is a fleet total (files, lines, the share of lines byte-identical to a canonical, callers), a +per-workflow table, a per-repo table, and the repo-invented workflows no canonical names. + +Usage: python3 spec/workflow_reuse.py [--report] [--selftest] +""" + +import base64 +import difflib +import re +import sys + +import audit # sibling, import-safe (its main is guarded) + +REPORT_PATH = "reports/workflow-reuse.md" # the generated, checked-in burn-down report (--report) +WORKFLOW_DIR = ".github/workflows" +# Two copies at or above this ratio are one variant. +# 0.85 rather than higher, because a copy that differs only in its per-repo target list or its comment wording is the same variant for the purpose of hosting the job once. +CLUSTER_THRESHOLD = 0.85 +_USES = re.compile(r"^\s*uses:\s*([^\s#]+)", re.MULTILINE) + + +def canonical_texts(): + """The hub's canonical workflows by filename, its own orchestrator set plus the catalog snippets. + + Both trees are read from the hub checkout, so the comparison is against the hub commit the tool runs from, + which the report names. + """ + canon = {} + for rel in (WORKFLOW_DIR, "catalog/snippets/workflows"): + for p in sorted((audit.ROOT / rel).glob("*.yml")): + canon.setdefault(p.name, p.read_text(encoding="utf-8", errors="replace")) + return canon + + +def fetch_dir(slug, path, ref): + """The filenames under a directory on a ref, empty where the directory is absent. + + A 404 here is a repo that carries no workflows, which is a real state rather than an unread one, so it + counts as zero files. A failure other than 404 raises inside audit.gh, so nothing is silently skipped. + """ + listing = audit.gh(f"repos/{slug}/contents/{path}?ref={ref}", ok404=True) + if not isinstance(listing, list): + return [] + return sorted(n["name"] for n in listing if n.get("type") == "file") + + +def fetch(slug, path, ref): + """Decoded downstream file content, or None if absent / not inline.""" + content = audit.gh(f"repos/{slug}/contents/{path}?ref={ref}", ok404=True) + if content is None or content.get("encoding") != "base64": + return None + return base64.b64decode(content["content"]).decode("utf-8", "replace") + + +def norm_lines(text): + return audit.normalize(text).splitlines() + + +def compare(text, canon_text): + """(lines, identical lines, similarity ratio) of a copy against its canonical, after normalization.""" + a, b = norm_lines(canon_text), norm_lines(text) + matcher = difflib.SequenceMatcher(None, a, b, autojunk=False) + identical = sum(block.size for block in matcher.get_matching_blocks()) + return len(b), identical, round(matcher.ratio(), 2) + + +def hub_reach(text, hub_slug): + """The hub paths a workflow reaches through `uses:`, reusable workflows and composite actions alike.""" + prefix = f"{hub_slug}/" + return sorted({m.group(1) for m in _USES.finditer(text) if m.group(1).startswith(prefix)}) + + +def cluster(copies): + """Greedy single-link clusters of {repo: text} at CLUSTER_THRESHOLD, each a sorted repo list. + + Greedy rather than exhaustive on purpose: the count of variants is what the report needs, and a stricter + partition would only split a borderline pair into two clusters that read the same to a maintainer. + """ + lines = {r: norm_lines(t) for r, t in copies.items()} + clusters = [] + for repo in sorted(copies): + for members in clusters: + ratio = difflib.SequenceMatcher( + None, lines[members[0]], lines[repo], autojunk=False + ).ratio() + if ratio >= CLUSTER_THRESHOLD: + members.append(repo) + break + else: + clusters.append([repo]) + return clusters + + +def measure(registry, canon, hub_slug, reader=fetch, lister=fetch_dir): + """Inventory every cataloged repo's workflows and compare each against its canonical. + + Returns per-file rows, the files that were listed but could not be read inline, and the downstream repos + carrying no workflow at all. An unreadable file is named rather than dropped, since a dropped file and an + absent one print the same, and an empty repo is named so the repo count is a count of what was measured. + """ + rows, unreadable, empty = [], [], [] + for entry in registry["repos"]: + if entry.get("status") != "cataloged": + continue + name = entry["name"] + slug = audit.repo_slug(entry) + ref = entry.get("groundTruthBranch", "main") + files = [f for f in lister(slug, WORKFLOW_DIR, ref) if f.endswith((".yml", ".yaml"))] + if not files and name != audit.HUB_NAME: + empty.append(name) + for fname in files: + text = reader(slug, f"{WORKFLOW_DIR}/{fname}", ref) + if text is None: + unreadable.append(f"{name}:{fname}") + continue + row = { + "repo": name, + "hub": name == audit.HUB_NAME, + "file": fname, + "text": text, + "reach": hub_reach(text, hub_slug), + "canonical": fname in canon, + } + if fname in canon: + row["lines"], row["identical"], row["ratio"] = compare(text, canon[fname]) + else: + row["lines"], row["identical"], row["ratio"] = len(norm_lines(text)), 0, None + rows.append(row) + return rows, unreadable, empty + + +def summarize(rows): + """Fleet totals over the downstream rows (the hub's own copies are the canonicals and are excluded).""" + down = [r for r in rows if not r["hub"]] + lines = sum(r["lines"] for r in down) + identical = sum(r["identical"] for r in down) + return { + "files": len(down), + "lines": lines, + "identical": identical, + "identical_pct": round(100 * identical / lines) if lines else 0, + "canonical_named": sum(1 for r in down if r["canonical"]), + "callers": sum(1 for r in down if r["reach"]), + "repos": len({r["repo"] for r in down}), + } + + +def per_workflow(rows): + """One row per canonical filename over the downstream copies: copies, lines, identical, clusters, callers.""" + by_name = {} + for r in rows: + if r["hub"] or not r["canonical"]: + continue + by_name.setdefault(r["file"], []).append(r) + table = [] + for fname, copies in sorted(by_name.items(), key=lambda kv: -sum(x["lines"] for x in kv[1])): + clusters = cluster({r["repo"]: r["text"] for r in copies}) + table.append( + { + "file": fname, + "copies": len(copies), + "lines": sum(r["lines"] for r in copies), + "identical": sum(r["identical"] for r in copies), + "clusters": clusters, + "callers": sorted(r["repo"] for r in copies if r["reach"]), + } + ) + return table + + +def _fmt(items): + return ", ".join(items) if items else "-" + + +def render_report(rows, unreadable, empty, hub_sha): + total = summarize(rows) + table = per_workflow(rows) + out = [] + w = out.append + w("# Fleet workflow reuse report") + w("") + w( + f'Generated by `python3 spec/workflow_reuse.py --report` at hub `{hub_sha}` - do not hand-edit. Each row reads a repo\'s ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows].' + ) + w("") + w("## Fleet Total") + w("") + w( + f"- **{total['files']} workflow files, {total['lines']:,} lines** across {total['repos']} downstream repos, {total['canonical_named']} of them named for a hub canonical." + + (f" No workflow at all in {_fmt(empty)}." if empty else "") + ) + w( + f"- **{total['identical']:,} lines ({total['identical_pct']}%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code." + ) + w( + f"- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: {total['callers']}.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands." + ) + w("") + w("## Per Workflow") + w("") + w( + f"Downstream copies of each hub canonical. A variant is a cluster of copies at or above {CLUSTER_THRESHOLD} pairwise similarity, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies." + ) + w("") + w("| File | Copies | Lines | Identical to hub | Variants | Callers |") + w("| --- | --- | --- | --- | --- | --- |") + for t in table: + w( + f"| `{t['file']}` | {t['copies']} | {t['lines']:,} | {t['identical']:,} | {len(t['clusters'])} | {len(t['callers'])} |" + ) + w("") + w("### Variant Members") + w("") + w( + "Each variant names the repos whose copies cluster together, so a hub task's inputs and hooks can be designed against the shapes that exist rather than against the canonical alone." + ) + w("") + for t in table: + w(f"- `{t['file']}`") + for c in t["clusters"]: + w(f" - {len(c)}: {_fmt(c)}") + w("") + w("## Per Repo") + w("") + w("| Repo | Files | Lines | Identical to hub | Callers | Repo-local files |") + w("| --- | --- | --- | --- | --- | --- |") + by_repo = {} + for r in rows: + if not r["hub"]: + by_repo.setdefault(r["repo"], []).append(r) + for repo, rs in sorted(by_repo.items()): + local = sorted(f"`{r['file']}`" for r in rs if not r["canonical"]) + w( + f"| {repo} | {len(rs)} | {sum(r['lines'] for r in rs):,} | {sum(r['identical'] for r in rs):,} | {sum(1 for r in rs if r['reach'])} | {_fmt(local)} |" + ) + w("") + w("## Repo-Local Workflows") + w("") + w( + "A workflow no hub canonical names. Each is either genuinely repo-specific, and stays, or a candidate for a hub task with a hook, and the design doc lists which." + ) + w("") + local_rows = [r for r in rows if not r["hub"] and not r["canonical"]] + if not local_rows: + w("_None._") + for r in sorted(local_rows, key=lambda x: (x["repo"], x["file"])): + w(f"- **{r['repo']}** `{r['file']}` ({r['lines']} lines)") + w("") + if unreadable: + w("## Unreadable") + w("") + w( + f"A listed workflow could not be read inline for {_fmt(unreadable)}, so those are named rather than counted as absent." + ) + w("") + w("") + w("") + w("[fidelity-model]: ../spec/fidelity-model.md") + w("[reusable-workflows]: ../docs/reusable-workflows.md") + return "\n".join(out).rstrip() + "\n" + + +def hub_sha(): + import subprocess + + r = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + cwd=audit.ROOT, + check=False, + ) + return r.stdout.strip() if r.returncode == 0 else "unknown" + + +def _selftest(): + """Offline proof of the pure functions, so a regression in the measurement fails CI rather than a sweep.""" + canon = ( + "name: A\non:\n workflow_call:\njobs:\n x:\n steps:\n - uses: actions/checkout@" + + "a" * 40 + + " # v7.0.0\n" + ) + same_pin_bumped = canon.replace("a" * 40 + " # v7.0.0", "b" * 40 + " # v7.0.1") + crlf = canon.replace("\n", "\r\n") + lines, identical, ratio = compare(same_pin_bumped, canon) + assert (lines, identical, ratio) == (7, 7, 1.0), (lines, identical, ratio) + assert compare(crlf, canon)[1:] == (7, 1.0) + edited = canon.replace("name: A", "name: B").replace(" x:", " y:") + l2, i2, r2 = compare(edited, canon) + assert l2 == 7 and i2 == 5 and r2 < 1.0, (l2, i2, r2) + + hub = "acme/hub" + caller = ( + f"jobs:\n a:\n uses: {hub}/.github/workflows/merge-bot-task.yml@{'c' * 40} # 2.0.1\n" + ) + assert hub_reach(caller, hub) == [f"{hub}/.github/workflows/merge-bot-task.yml@{'c' * 40}"] + assert hub_reach(canon, hub) == [] + local_uses = ( + "steps:\n - uses: ./.github/actions/x\n - uses: acme/other/.github/actions/y@" + + "d" * 40 + + "\n" + ) + assert hub_reach(local_uses, hub) == [] + + big = "\n".join(f"line {i}" for i in range(40)) + "\n" + near = big.replace("line 3\n", "line three\n") + far = "\n".join(f"other {i}" for i in range(40)) + "\n" + assert cluster({"r1": big, "r2": near, "r3": far}) == [["r1", "r2"], ["r3"]] + + registry = { + "repos": [ + { + "name": audit.HUB_NAME, + "status": "cataloged", + "url": f"https://x/acme/{audit.HUB_NAME}", + }, + {"name": "One", "status": "cataloged", "url": "https://x/acme/One"}, + { + "name": "Two", + "status": "cataloged", + "url": "https://x/acme/Two", + "groundTruthBranch": "develop", + }, + {"name": "Skip", "status": "planned", "url": "https://x/acme/Skip"}, + {"name": "Gone", "status": "cataloged", "url": "https://x/acme/Gone"}, + ] + } + canon_map = {"a.yml": canon} + trees = { + audit.HUB_NAME: ["a.yml"], + "One": ["a.yml", "local.yml", "notes.md"], + "Two": ["a.yml", "big.yml"], + } + texts = { + (audit.HUB_NAME, "a.yml"): canon, + ("One", "a.yml"): same_pin_bumped, + ("One", "local.yml"): "name: L\n", + ("Two", "a.yml"): caller, + } + seen_refs = {} + + def lister(slug, path, ref): + name = slug.split("/")[-1] + seen_refs[name] = ref + return trees.get(name, []) + + def reader(slug, path, ref): + return texts.get((slug.split("/")[-1], path.split("/")[-1])) + + rows, unreadable, empty = measure(registry, canon_map, hub, reader=reader, lister=lister) + assert unreadable == ["Two:big.yml"], unreadable + assert empty == ["Gone"], empty + assert seen_refs["Two"] == "develop" and seen_refs["One"] == "main" + total = summarize(rows) + assert total["files"] == 3 and total["canonical_named"] == 2 and total["callers"] == 1, total + assert total["repos"] == 2 and total["lines"] == 7 + 1 + 3, total + assert total["identical"] == 8, total # 7 from One plus the shared `jobs:` line of the caller + table = per_workflow(rows) + assert [t["file"] for t in table] == ["a.yml"] and table[0]["copies"] == 2 + assert table[0]["callers"] == ["Two"] and len(table[0]["clusters"]) == 2, table + report = render_report(rows, unreadable, empty, "abc1234") + assert "| `a.yml` | 2 | 10 | 8 | 2 | 1 |" in report, report + assert "- `a.yml`\n - 1: One\n - 1: Two\n" in report, report + assert "- **One** `local.yml` (1 lines)" in report + assert "Unreadable" in report and "Two:big.yml" in report + assert "No workflow at all in Gone." in report + assert "[reusable-workflows]: ../docs/reusable-workflows.md" in report + assert not any(ord(ch) > 127 for ch in report), "report is ASCII" + print("workflow_reuse selftest OK") + return 0 + + +def main(): + argv = sys.argv[1:] + if "--selftest" in argv: + return _selftest() + registry = audit.load("registry/repos.json") + hub_entry = next( + (r for r in registry["repos"] if r.get("name") == audit.HUB_NAME), + {"name": audit.HUB_NAME}, + ) + hub_slug = audit.repo_slug(hub_entry) + rows, unreadable, empty = measure(registry, canonical_texts(), hub_slug) + if "--report" in argv: + content = render_report(rows, unreadable, empty, hub_sha()) + # CRLF matches the fleet default, since reports/*.md is CRLF. + # Bytes are written so the local platform does not re-translate them. + (audit.ROOT / REPORT_PATH).write_bytes(content.replace("\n", "\r\n").encode("utf-8")) + print(f"Wrote {REPORT_PATH} ({len(content.splitlines())} lines)") + return 0 + total = summarize(rows) + print( + f"{total['files']} downstream workflow files, {total['lines']} lines, " + f"{total['identical']} ({total['identical_pct']}%) identical to a hub canonical, " + f"{total['callers']} reach the hub" + ) + for t in per_workflow(rows): + print( + f" {t['file']:40s} copies={t['copies']:2d} lines={t['lines']:5d} " + f"identical={t['identical']:5d} variants={len(t['clusters'])} callers={len(t['callers'])}" + ) + if unreadable: + print(f" unreadable: {', '.join(unreadable)}") + if empty: + print(f" no workflows: {', '.join(empty)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d08fea783cd2273b064ab3f6ed1e477838c6cf9a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 12:38:02 -0700 Subject: [PATCH 2/4] Regenerate the Reuse Report at the Committed Hub State and Fix the CI Wording --- docs/reusable-workflows.md | 4 +++- reports/workflow-reuse.md | 2 +- spec/workflow_reuse.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 37ed0307..3fee100d 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -29,7 +29,9 @@ The audit today grades a carried workflow at `interface` fidelity, meaning it ch `python3 spec/workflow_reuse.py --report` reads every cataloged repo's `.github/workflows/` from its ground-truth branch. It compares each file against the hub canonical of the same name, after the normalization the verbatim engine applies. It clusters the copies of each canonical into variants and writes [reports/workflow-reuse.md][workflow-reuse-report]. That report is the burn-down. Its fleet total names the files, the lines, the share of lines byte-identical to a canonical, and the files that already reach a hub reusable workflow or composite action. The migration is done when the copies of each standard workflow reach zero and the callers reach the number of repos that need it. -The first run, at hub `7c67328` on 2026-08-15, read 108 workflow files and 10,964 lines across 20 downstream repos. 5,385 of those lines, 49 percent, are byte-identical to a hub canonical, and the rest is mostly a per-repo edit of the same canonical rather than independent code. One file reaches the hub, PhotoCleaner's `validate-task.yml` consuming the `prose-gate` action. Every other copy carries its job bodies. +The first run, at hub `7c67328` on 2026-08-15 before the merge-bot moved, read 108 workflow files and 10,964 lines across 20 downstream repos. 5,385 of those lines, 49 percent, were byte-identical to a hub canonical, and the rest is mostly a per-repo edit of the same canonical rather than independent code. One file reaches the hub, PhotoCleaner's `validate-task.yml` consuming the `prose-gate` action. Every other copy carries its job bodies. + +The identical share is read against the hub's current canonical, so it falls twice for one workflow: once when the hub canonical becomes a caller stub, since every copy still carrying the job bodies stops matching it, and again when the copies adopt the stub and their lines leave the fleet. The committed report is the second reading for the merge-bot, at hub `47c0c28`, where the identical share is 4,201 lines, 38 percent, with the files, the lines, and the callers unchanged. The three numbers to watch across phases are therefore the files, the lines, and the callers, and the identical share is the duplication that remains inside them. ## Target Model diff --git a/reports/workflow-reuse.md b/reports/workflow-reuse.md index d0b90c2a..ae6e95a2 100644 --- a/reports/workflow-reuse.md +++ b/reports/workflow-reuse.md @@ -1,6 +1,6 @@ # Fleet workflow reuse report -Generated by `python3 spec/workflow_reuse.py --report` at hub `7c67328` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. +Generated by `python3 spec/workflow_reuse.py --report` at hub `47c0c28` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. ## Fleet Total diff --git a/spec/workflow_reuse.py b/spec/workflow_reuse.py index 65f8a573..06a1bbb6 100755 --- a/spec/workflow_reuse.py +++ b/spec/workflow_reuse.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Workflow-reuse measurement: how much of the fleet's GitHub Actions YAML is a copy of a hub canonical. -Read-only, owner-run, not wired into CI. Reuses spec/audit.py's fleet machinery (gh, normalize, registry). +Read-only and owner-run. The measurement reads the live fleet, so it is not wired into CI beyond the offline +--selftest CI runs beside the audit engine's. Reuses spec/audit.py's fleet machinery (gh, normalize, registry). The hub hosts a standard workflow once, as a `workflow_call` reusable workflow a downstream repo reaches by a pinned `uses:`, and a downstream repo carries only a caller stub plus a composite-action hook for what is From 210d88f6c2c23e229188c9db01f830bec81015b4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 12:42:59 -0700 Subject: [PATCH 3/4] Describe the Variant Clustering as Leader Clustering --- reports/workflow-reuse.md | 4 ++-- spec/workflow_reuse.py | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/reports/workflow-reuse.md b/reports/workflow-reuse.md index ae6e95a2..d765ba8b 100644 --- a/reports/workflow-reuse.md +++ b/reports/workflow-reuse.md @@ -1,6 +1,6 @@ # Fleet workflow reuse report -Generated by `python3 spec/workflow_reuse.py --report` at hub `47c0c28` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. +Generated by `python3 spec/workflow_reuse.py --report` at hub `d08fea7` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. ## Fleet Total @@ -10,7 +10,7 @@ Generated by `python3 spec/workflow_reuse.py --report` at hub `47c0c28` - do not ## Per Workflow -Downstream copies of each hub canonical. A variant is a cluster of copies at or above 0.85 pairwise similarity, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies. +Downstream copies of each hub canonical. A variant is a cluster of copies each at or above 0.85 similarity to the cluster's first member, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies. | File | Copies | Lines | Identical to hub | Variants | Callers | | --- | --- | --- | --- | --- | --- | diff --git a/spec/workflow_reuse.py b/spec/workflow_reuse.py index 06a1bbb6..c60994a6 100755 --- a/spec/workflow_reuse.py +++ b/spec/workflow_reuse.py @@ -13,8 +13,8 @@ and compared line by line against the hub canonical of the same name (the hub's own workflow or the catalog snippet), after the same normalization the verbatim engine applies (line endings, action pins, job needs), so a Dependabot bump is not counted as divergence. - 2. Copies of one canonical are clustered by pairwise similarity, which answers the question that decides - whether a job is worth hosting once: how many genuinely distinct variants of it the fleet runs. + 2. Copies of one canonical are clustered by similarity to a cluster leader, which answers the question that + decides whether a job is worth hosting once: how many genuinely distinct variants of it the fleet runs. 3. A file that reaches a hub reusable workflow or a hub composite action is counted as a caller, which is the state every carried copy converges to. @@ -91,10 +91,13 @@ def hub_reach(text, hub_slug): def cluster(copies): - """Greedy single-link clusters of {repo: text} at CLUSTER_THRESHOLD, each a sorted repo list. + """Leader clusters of {repo: text} at CLUSTER_THRESHOLD, each a sorted repo list. - Greedy rather than exhaustive on purpose: the count of variants is what the report needs, and a stricter - partition would only split a borderline pair into two clusters that read the same to a maintainer. + Each copy, in name order, joins the first cluster whose leader (its first member) it matches at the + threshold, else it starts a cluster. So a member is guaranteed similar to its leader rather than to every + other member. Leader clustering rather than an exhaustive partition on purpose: the count of variants is + what the report needs, and a stricter partition would only split a borderline pair into two clusters that + read the same to a maintainer. """ lines = {r: norm_lines(t) for r, t in copies.items()} clusters = [] @@ -219,7 +222,7 @@ def render_report(rows, unreadable, empty, hub_sha): w("## Per Workflow") w("") w( - f"Downstream copies of each hub canonical. A variant is a cluster of copies at or above {CLUSTER_THRESHOLD} pairwise similarity, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies." + f"Downstream copies of each hub canonical. A variant is a cluster of copies each at or above {CLUSTER_THRESHOLD} similarity to the cluster's first member, so the cluster count is how many distinct shapes of one workflow the fleet runs today. Callers are the copies that already reach the hub rather than carrying the job bodies." ) w("") w("| File | Copies | Lines | Identical to hub | Variants | Callers |") From b39b16f66cfee6080c6e4b19c23592fb66f92f42 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 12:47:48 -0700 Subject: [PATCH 4/4] Record the Hyphenated-Key Expression Disproof in the Review Ledger --- .github/copilot-instructions.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 05367564..96cd6921 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -351,6 +351,11 @@ After the final push, sweep-resolve stale older threads for removed code paths. - **Proved against** - the paragraph following the agent snippet in `STANDUP.md` section 0 on `develop` at `676a2bd`, unchanged since `77be3a3`. - **Delete when** - the sentence is reworded for any reason, since the entry is about this phrasing rather than about the rule it states. +- **A hyphenated key such as `inputs.app-login` or `inputs.delete-branch` cannot be read with dot notation in a GitHub Actions expression, since `-` parses as subtraction, so the merge-bot task's `if:` conditions never match and its `env:` values never resolve.** Raised as four suppressed findings against `.github/workflows/merge-bot-task.yml`, each a variant of the one claim. + - **Disproved by** - reading the expressions reference, which states that property dereference syntax needs a name that starts with a letter or `_` and contains only alphanumeric characters, `-`, or `_`, so a hyphen is inside the allowed set and index syntax is required only for a name outside it. And by the same file, whose every job reads `steps.app-token.outputs.token` with dot notation on the same hyphenated shape, the expression the fleet's merge-bot has resolved on every Dependabot merge it has performed. `actionlint` parses the file without a finding. + - **Proved against** - `.github/workflows/merge-bot-task.yml` on `feature/reusable-workflows` at `210d88f`, and the "About contexts" property dereference rule in the GitHub Actions contexts reference read on 2026-08-15. + - **Delete when** - the task stops declaring hyphenated inputs, or the expressions reference changes the allowed set. + ## When in Doubt Read [AGENTS.md](../AGENTS.md) to find the section that governs your change, and [GOVERNANCE.md](../GOVERNANCE.md) for the rule text itself. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions, and keep those focused on the change itself.