From daa699d9d76a45a71b376a825b40f7e1354aec65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:58:01 +0900 Subject: [PATCH 1/3] fix(actions): accept absent retired workflow identities --- ...urly-review-repair-registry-retirement.yml | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/.github/workflows/hourly-review-repair-registry-retirement.yml b/.github/workflows/hourly-review-repair-registry-retirement.yml index bf552f55a7..0ee6891e9e 100644 --- a/.github/workflows/hourly-review-repair-registry-retirement.yml +++ b/.github/workflows/hourly-review-repair-registry-retirement.yml @@ -1,13 +1,14 @@ name: Hourly Review Repair Registry Retirement # One-shot control-plane migration for the single-file hourly review-repair -# consolidation. GitHub keeps workflow registry identities after YAML paths are -# removed, so deleting the 18 legacy caller files without retiring their IDs -# would leave enabled orphan identities. This workflow runs only from reviewed -# source on protected main after the replacement is present, disables every -# legacy registry ID, verifies the disabled state, then disables its own -# registry identity last. After hosted success, remove this source file in a -# follow-up cleanup; its own registry identity will already be disabled. +# consolidation. GitHub may retain workflow registry identities after YAML paths +# are removed, so deleting the 18 legacy caller files alone is not retirement +# evidence. This workflow runs only from reviewed source on protected main after +# the replacement is present. For each legacy path it accepts either absence +# from the complete paginated registry (already retired) or one visible identity, +# which must be disabled and read back. It then disables its own registry identity +# last. After hosted success, remove this source file in a follow-up cleanup; its +# own registry identity will already be disabled. on: push: branches: @@ -64,10 +65,15 @@ jobs: gh api --paginate "/repos/${REPOSITORY}/actions/workflows?per_page=100" } | jq -s '[.[].workflows[]]')" - workflow_id_for_path() { + workflow_identity_count() { + local path="$1" + jq --arg path "$path" '[.[] | select(.path == $path)] | length' <<<"$workflow_inventory" + } + + require_single_workflow_id() { local path="$1" local count - count="$(jq --arg path "$path" '[.[] | select(.path == $path)] | length' <<<"$workflow_inventory")" + count="$(workflow_identity_count "$path")" if [[ "$count" != "1" ]]; then echo "::error::Expected exactly one workflow registry identity for ${path}; found ${count}." >&2 return 1 @@ -75,18 +81,18 @@ jobs: jq -r --arg path "$path" '.[] | select(.path == $path) | .id' <<<"$workflow_inventory" } - replacement_id="$(workflow_id_for_path "$REPLACEMENT_PATH")" + replacement_id="$(require_single_workflow_id "$REPLACEMENT_PATH")" replacement_state="$(gh api "/repos/${REPOSITORY}/actions/workflows/${replacement_id}" --jq '.state')" if [[ "$replacement_state" != "active" ]]; then echo "::error::Replacement workflow ${REPLACEMENT_PATH} is not active; refusing retirement." >&2 exit 1 fi - disable_and_verify() { + disable_and_verify_present() { local path="$1" local workflow_id local state - workflow_id="$(workflow_id_for_path "$path")" + workflow_id="$(require_single_workflow_id "$path")" state="$(gh api "/repos/${REPOSITORY}/actions/workflows/${workflow_id}" --jq '.state')" case "$state" in active) @@ -107,15 +113,31 @@ jobs: printf 'retired %s (%s)\n' "$path" "$workflow_id" } + retire_legacy_if_present() { + local path="$1" + local count + count="$(workflow_identity_count "$path")" + if [[ "$count" == "0" ]]; then + printf 'already absent from registry %s\n' "$path" + return 0 + fi + if [[ "$count" != "1" ]]; then + echo "::error::Expected zero or one workflow registry identity for legacy path ${path}; found ${count}." >&2 + return 1 + fi + disable_and_verify_present "$path" + } + for path in "${legacy_paths[@]}"; do - disable_and_verify "$path" + retire_legacy_if_present "$path" done # The one-shot migration identity is disabled only after every legacy - # caller has been verified disabled and the replacement remains active. + # caller is either absent from the complete registry or verified disabled, + # and the replacement remains active. replacement_state="$(gh api "/repos/${REPOSITORY}/actions/workflows/${replacement_id}" --jq '.state')" if [[ "$replacement_state" != "active" ]]; then echo "::error::Replacement workflow changed state during retirement; preserving the migration identity." >&2 exit 1 fi - disable_and_verify "$SELF_PATH" + disable_and_verify_present "$SELF_PATH" From 5eafbe7981d4fc92e4f007f641611731ab6c2631 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:58:25 +0900 Subject: [PATCH 2/3] test(actions): pin absent registry identity handling --- ...ourly_review_repair_registry_retirement.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/test_hourly_review_repair_registry_retirement.py b/tests/test_hourly_review_repair_registry_retirement.py index 2a54729500..64dfa76633 100644 --- a/tests/test_hourly_review_repair_registry_retirement.py +++ b/tests/test_hourly_review_repair_registry_retirement.py @@ -92,11 +92,25 @@ def test_replacement_is_proven_active_before_any_disable_call() -> None: assert replacement_guard in text assert disable_endpoint in text assert text.index(replacement_guard) < text.index(disable_endpoint) + assert 'require_single_workflow_id "$REPLACEMENT_PATH"' in text assert "Expected exactly one workflow registry identity" in text -def test_every_disabled_identity_is_read_back_and_verified() -> None: - """A successful mutation is not evidence until the registry state is re-read.""" +def test_absent_legacy_identity_is_already_retired_but_duplicates_fail() -> None: + """A zero-match legacy path is terminally absent while ambiguous matches fail closed.""" + text = _text() + legacy = text.split("retire_legacy_if_present() {", 1)[1].split("\n }", 1)[0] + + assert 'if [[ "$count" == "0" ]]' in legacy + assert "already absent from registry" in legacy + assert "return 0" in legacy + assert 'if [[ "$count" != "1" ]]' in legacy + assert "Expected zero or one workflow registry identity for legacy path" in legacy + assert 'disable_and_verify_present "$path"' in legacy + + +def test_every_visible_disabled_identity_is_read_back_and_verified() -> None: + """A successful mutation is not evidence until the visible registry state is re-read.""" text = _text() assert ( @@ -104,12 +118,20 @@ def test_every_disabled_identity_is_read_back_and_verified() -> None: in text ) assert 'if [[ "$state" != "disabled_manually" ]]' in text - assert 'disable_and_verify "$SELF_PATH"' in text - assert text.rindex('disable_and_verify "$SELF_PATH"') > text.rindex( + assert 'disable_and_verify_present "$SELF_PATH"' in text + assert text.rindex('disable_and_verify_present "$SELF_PATH"') > text.rindex( 'for path in "${legacy_paths[@]}"' ) +def test_self_identity_still_requires_exactly_one_visible_registry_entry() -> None: + """The migration cannot call itself complete unless its own identity is unambiguous.""" + text = _text() + + assert 'workflow_id="$(require_single_workflow_id "$path")"' in text + assert 'disable_and_verify_present "$SELF_PATH"' in text + + def test_retirement_does_not_expose_reviewer_or_provider_credentials() -> None: """Registry mutation uses only the scoped GitHub token and no model secrets.""" text = _text() From 265b5fa00c968dc0071c5a1cc14c8681654ca821 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:58:46 +0900 Subject: [PATCH 3/3] docs(actions): record absent registry identity RCA --- ...ourly-review-repair-registry-retirement.md | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/doctoring/hourly-review-repair-registry-retirement.md b/docs/doctoring/hourly-review-repair-registry-retirement.md index 1a16bddf16..16c5b2a791 100644 --- a/docs/doctoring/hourly-review-repair-registry-retirement.md +++ b/docs/doctoring/hourly-review-repair-registry-retirement.md @@ -2,13 +2,13 @@ ## Status -Prepared 2026-09-02 for the single-file hourly review-repair consolidation in `ContextualWisdomLab/.github` PR #1673. This record addresses the control-plane lifecycle gap found during current-head review: deleting a workflow YAML path does not prove that GitHub has retired the corresponding workflow registry identity. +Prepared 2026-09-02 for the single-file hourly review-repair consolidation in `ContextualWisdomLab/.github` PR #1673. This record addresses the control-plane lifecycle gap found during current-head review: deleting a workflow YAML path does not by itself prove what GitHub retained in the workflow registry. ## Problem and authority boundary -The consolidation intentionally replaces 18 scheduled caller files with `.github/workflows/hourly-review-repair.yml`. GitHub Actions, however, keeps workflow registry identities independently of the current Git tree. A source deletion can therefore leave an enabled identity that no longer has an obvious owner path. This repository already treats that as a governance defect in `docs/doctoring/review-repair-quality-workflow-identity.md` and in the read-only orphan-inventory work tracked by `ContextualWisdomLab/.github#1026`. +The consolidation intentionally replaces 18 scheduled caller files with `.github/workflows/hourly-review-repair.yml`. GitHub Actions keeps a repository workflow registry independently of the current Git tree, so source deletion and registry state must be reconciled explicitly rather than inferred. A removed path may still have a visible workflow identity requiring disablement, or it may already be absent from the complete paginated registry. This repository already treats orphan workflow identity state as a governance concern in `docs/doctoring/review-repair-quality-workflow-identity.md` and in the read-only orphan-inventory work tracked by `ContextualWisdomLab/.github#1026`. -The replacement scheduler must therefore be active before legacy identities are retired. Source-file absence is not retirement evidence. Conversely, registry retirement is control-plane lifecycle work only: it does not grant review, merge, repository-content, model-provider, or accounting authority. +The replacement scheduler must therefore be active before any visible legacy identity is retired. Source-file absence alone is not retirement evidence; the complete registry inventory is the evidence boundary. Conversely, registry retirement is control-plane lifecycle work only: it does not grant review, merge, repository-content, model-provider, or accounting authority. ## Migration contract @@ -16,27 +16,29 @@ PR #1673 adds the one-shot compatibility workflow `.github/workflows/hourly-revi 1. enumerates the complete GitHub Actions workflow registry with pagination; 2. resolves exactly one registry identity for the consolidated replacement and requires its state to be `active` before any destructive mutation; -3. resolves exactly one registry identity for each of the 18 removed per-repository callers; -4. accepts only `active` or already-`disabled_manually` legacy states, disabling `active` identities through the GitHub Actions disable endpoint; -5. reads every mutated workflow identity back and requires `disabled_manually` rather than treating a successful HTTP mutation as sufficient evidence; -6. rechecks that the replacement remains active after all legacy identities are retired; and -7. disables the one-shot migration workflow's own registry identity last. +3. evaluates each of the 18 removed per-repository caller paths against that same immutable in-run inventory; +4. treats zero matches for a legacy path as already absent from the repository registry, accepts exactly one visible identity for mutation/verification, and fails closed on duplicate/ambiguous matches; +5. for a visible legacy identity, accepts only `active` or already-`disabled_manually`, disables `active` identities through the GitHub Actions disable endpoint, then reads the state back and requires `disabled_manually`; +6. rechecks that the replacement remains active after all legacy identities are reconciled; and +7. requires exactly one visible identity for the one-shot migration workflow and disables that identity last. -The migration has repository `actions: write` plus `contents: read`, no checkout, no model/reviewer secrets, no OIDC grant, no repository-content mutation, no schedule, and no arbitrary-branch manual dispatch. It fails closed on missing, duplicate, unresolved, or unexpected registry states. A transient hosted-run failure is retried through GitHub's run/job retry controls against the same reviewed protected-main source rather than by dispatching a feature branch. The permanent consolidated scheduler retains its narrower read/OIDC dispatch permissions and does not inherit registry-mutation authority. +The migration has repository `actions: write` plus `contents: read`, no checkout, no model/reviewer secrets, no OIDC grant, no repository-content mutation, no schedule, and no arbitrary-branch manual dispatch. It fails closed on duplicate, unresolved, or unexpected visible registry states. A transient hosted-run failure is retried through GitHub's run/job retry controls against the same reviewed protected-main source rather than by dispatching a feature branch. The permanent consolidated scheduler retains its narrower read/OIDC dispatch permissions and does not inherit registry-mutation authority. ## 2026-09-02 Actions-capacity reconciliation -The first protected-main migration run remained queued on `ubuntu-24.04` while the central Actions control plane was already carrying a large standard-runner backlog. Because the purpose of this one-shot is itself to retire 18 obsolete workflow identities that contribute unnecessary Actions scheduling pressure, leaving the mutation on the saturated runner lane creates an avoidable operability dependency. The retirement job therefore uses `ubuntu-slim`, which is sufficient for the shell-only `gh`/`jq` registry transaction and does not require checkout, language toolchains, containers, or privileged build tooling. This changes only runner admission; the protected-main event boundary, `actions: write` scope, replacement-active proof, exact identity enumeration, read-after-write verification, fail-closed state handling, and self-disable-last ordering are unchanged. +The first protected-main migration run remained queued on `ubuntu-24.04` while the central Actions control plane was already carrying a large standard-runner backlog. Because the purpose of this one-shot is itself to retire obsolete workflow identities that can contribute unnecessary Actions scheduling pressure, leaving the mutation on the saturated runner lane created an avoidable operability dependency. PR #1684 moved the retirement job to `ubuntu-slim`, which is sufficient for the shell-only `gh`/`jq` registry transaction and does not require checkout, language toolchains, containers, or privileged build tooling. This changed only runner admission; the protected-main event boundary, `actions: write` scope, replacement-active proof, registry enumeration, read-after-write verification, fail-closed handling, and self-disable-last ordering remained unchanged. -`tests/test_hourly_review_repair_registry_retirement.py` pins that runner choice so this one-shot cannot silently regress onto `ubuntu-24.04` or `ubuntu-latest` while it remains needed. Once hosted evidence proves all 18 legacy identities plus this migration identity are disabled and the replacement remains active, the source workflow and this capacity-specific test assertion should be removed together in the normal post-migration cleanup. +Protected-main run `33596622523`, job `100141255712`, then proved that the capacity repair worked: the job was admitted and began the registry transaction instead of remaining queued. It failed before the first mutation because the complete paginated registry contained **zero** entries for `.github/workflows/accounting-information-platform-hourly-review-repair.yml`. The original migration incorrectly treated both zero and duplicate matches as the same fatal ambiguity. Zero is not ambiguous for a removed legacy path: there is no visible registry identity to disable, whereas two or more matches remain unsafe and fail closed. The successor repair therefore distinguishes those cases while keeping the replacement and self identities exact-one requirements. + +`tests/test_hourly_review_repair_registry_retirement.py` pins the capacity runner and the zero/one/many identity semantics so this one-shot cannot silently regress onto the saturated standard lane or treat an absent legacy identity as a failed mutation. Once hosted evidence proves every legacy path is either absent from the complete registry or `disabled_manually`, the replacement remains active, and the migration identity itself is `disabled_manually`, the source workflow and its migration-only test assertions should be removed together in the normal post-migration cleanup. ## Cleanup and evidence -The migration source must remain in protected `main` until a hosted run proves all 18 legacy identities and the migration identity itself are `disabled_manually` while `.github/workflows/hourly-review-repair.yml` remains active. After that evidence exists, remove the migration YAML in a normal protected-branch PR. Deleting it only after self-disable leaves its historical registry identity disabled rather than creating another enabled orphan. Do not claim the migration complete from PR checks alone; PR checks validate source contracts, while the registry mutation can occur only after the replacement is active on protected `main`. +The migration source must remain in protected `main` until a hosted run proves all 18 legacy paths are terminally reconciled—each either absent from the complete paginated workflow registry or represented by exactly one identity in `disabled_manually` state—while `.github/workflows/hourly-review-repair.yml` remains active and the migration identity itself reaches `disabled_manually`. After that evidence exists, remove the migration YAML in a normal protected-branch PR. Deleting it only after self-disable leaves its historical registry identity disabled rather than creating another enabled orphan. Do not claim the migration complete from PR checks alone; PR checks validate source contracts, while the registry transaction can occur only after the replacement is active on protected `main`. ## Regression contract -`tests/test_hourly_review_repair_registry_retirement.py` requires the one-shot workflow to have neither a schedule nor `workflow_dispatch`, to bind execution to protected-main push context, to name all 18 legacy paths exactly once, to prove the replacement active before the first disable request, to re-read and verify every disabled state, to disable itself last, to stay on the capacity-available `ubuntu-slim` lane while migration remains pending, and to avoid reviewer/model/provider credentials. The focused `Contextual Orchestrator Review Repair Quality CI` watches the migration workflow, this doctoring record, and the retirement contract test so a future change cannot bypass that regression. This complements `tests/test_hourly_review_repair_callers.py`, which continues to verify the 18-repository schedule/target/concurrency mapping in the single active scheduler file. +`tests/test_hourly_review_repair_registry_retirement.py` requires the one-shot workflow to have neither a schedule nor `workflow_dispatch`, to bind execution to protected-main push context, to name all 18 legacy paths exactly once, to prove the replacement active before the first disable request, to accept a zero-match legacy path as already absent while rejecting duplicates, to re-read and verify every visible disabled state, to disable itself last, to stay on the capacity-available `ubuntu-slim` lane while migration remains pending, and to avoid reviewer/model/provider credentials. The focused `Contextual Orchestrator Review Repair Quality CI` watches the migration workflow, this doctoring record, and the retirement contract test so a future change cannot bypass that regression. This complements `tests/test_hourly_review_repair_callers.py`, which continues to verify the 18-repository schedule/target/concurrency mapping in the single active scheduler file. ## References