Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 37 additions & 15 deletions .github/workflows/hourly-review-repair-registry-retirement.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -64,29 +65,34 @@ 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
fi
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)
Expand All @@ -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"
28 changes: 15 additions & 13 deletions docs/doctoring/hourly-review-repair-registry-retirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,43 @@

## 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

PR #1673 adds the one-shot compatibility workflow `.github/workflows/hourly-review-repair-registry-retirement.yml`. It has **no `workflow_dispatch` entrypoint**: its `actions: write` shell is executable only from reviewed source after a push to protected `main`. The job also checks `github.event_name == 'push'` and `github.ref == 'refs/heads/main'` before receiving destructive registry authority. On protected-`main` activation it:

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

Expand Down
30 changes: 26 additions & 4 deletions tests/test_hourly_review_repair_registry_retirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,24 +92,46 @@ 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 (
"gh api \"/repos/${REPOSITORY}/actions/workflows/${workflow_id}\" --jq '.state'"
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()
Expand Down
Loading