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
25 changes: 15 additions & 10 deletions .github/workflows/pr-review-fix-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ on:
retry_hours:
description: Minimum hours before redispatching autofix for the same head
required: false
default: "24"
default: "1"
type: string
autofix_workflow:
description: Autofix workflow file to dispatch
Expand All @@ -44,14 +44,16 @@ on:
default: ""
type: string
canonical_ref:
description: Ref of ContextualWisdomLab/.github to use for scheduler code
description: Deprecated compatibility input; accepted and ignored because privileged source is bound to the called workflow SHA
required: false
default: "main"
default: ""
type: string
repository_dispatch:
types: [pr-review-fix-scheduler]
schedule:
- cron: "23 */2 * * *"
# Run away from minute zero, where scheduled GitHub Actions are more likely
# to be delayed, while preserving a bounded one-dispatch-per-run repair loop.
- cron: "23 * * * *"

concurrency:
group: central-pr-review-fix-scheduler-${{ github.event.client_payload.target_repository || inputs.target_repository || vars.PR_REVIEW_FIX_TARGET_REPOSITORY || github.repository }}
Expand Down Expand Up @@ -80,16 +82,19 @@ jobs:
DRY_RUN: ${{ github.event.client_payload.dry_run == true || github.event.client_payload.dry_run == 'true' || inputs.dry_run == true }}
MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '50' }}
MAX_DISPATCHES: ${{ github.event.client_payload.max_dispatches || inputs.max_dispatches || '1' }}
RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '24' }}
RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '1' }}
AUTOFIX_WORKFLOW: pr-review-autofix.yml
AUTOFIX_REPOSITORY: ContextualWisdomLab/.github
CANONICAL_REF: main
steps:
- name: Checkout canonical scheduler
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Checkout immutable called-workflow source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ContextualWisdomLab/.github
ref: ${{ env.CANONICAL_REF }}
# In a reusable workflow the ordinary github context belongs to the
# caller. The job workflow context identifies the called workflow's
# repository and immutable resolved SHA, preventing a caller input or
# mutable branch from selecting the privileged scheduler code.
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
fetch-depth: 1
persist-credentials: false

Expand Down
72 changes: 72 additions & 0 deletions docs/automation/hourly-review-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Hourly PR review-repair scheduler

The central `PR Review Fix Scheduler` provides a bounded organization-wide
review → fix → revalidate → merge support loop. It runs at minute 23 of every
hour and may dispatch at most one existing autofix workflow per run. Merge
eligibility remains owned by the separate merge scheduler, branch protection,
required checks, independent review, and unresolved-thread policy.

## Execution and compatibility contract

- The scheduled heartbeat is `23 * * * *`.
- The default same-head retry floor is one hour.
- `max_dispatches` remains one by default.
- Repository-scoped concurrency and `cancel-in-progress: true` prevent two
superseded scheduler runs from mutating the same repository concurrently.
- `canonical_ref` remains an accepted deprecated input only so callers pinned to
older workflow interfaces can upgrade without a coordinated breaking change.
It is never read and cannot choose executable scheduler code.

## Immutable reusable-workflow source

GitHub associates the ordinary `github` context in a reusable workflow with the
caller. Consequently, a called privileged workflow must not use caller-derived
`github.sha`, a caller payload, or a mutable branch such as `main` to select its
co-located implementation.

The checkout step instead uses:

```yaml
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
```

`job.workflow_repository` identifies the repository that contains the called
workflow and `job.workflow_sha` identifies its immutable resolved commit. This
keeps the scheduler implementation aligned with the exact workflow revision
selected by the caller's `uses: ...@<sha>` reference. Checkout credentials are
not persisted.

## Security and MSA boundary

The scheduler can inspect review state and dispatch the already-reviewed bounded
autofix workflow. It cannot approve its own changes, lower branch protection,
convert queued checks to success, publish releases, or bypass independent
review. Product repositories remain independently operable and consume the
central policy as a reusable module rather than copying privileged automation.

CWL repositories and naruon retain their own product tests, authorization,
release, deployment, data-governance, and runtime responsibilities. The central
workflow owns only organization-level queue inspection and bounded repair
dispatch.

## Verification

Dependency-free static tests pin the hourly cron, one-hour retry default,
one-dispatch budget, single-flight concurrency, immutable called-workflow
checkout, ignored compatibility input, and least-privilege token boundary. The
exact PR head must also pass all central security, coverage, workflow-contract,
and independent-review gates before merge.

## References (APA 7th edition)

GitHub. (2026). *Contexts reference: Job context*. GitHub Docs. Retrieved August
4, 2026, from
https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#job-context

GitHub. (2026). *Reusing workflow configurations*. GitHub Docs. Retrieved August
4, 2026, from
https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations

GitHub. (2026). *Reusing workflows*. GitHub Docs. Retrieved August 4, 2026, from
https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows
45 changes: 45 additions & 0 deletions tests/test_pr_review_fix_hourly_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Static contract for the central hourly PR review-fix scheduler."""

from __future__ import annotations

from pathlib import Path


_WORKFLOW = Path(".github/workflows/pr-review-fix-scheduler.yml")


def _workflow_text() -> str:
"""Return the canonical scheduler workflow text."""
return _WORKFLOW.read_text(encoding="utf-8")


def test_review_fix_scheduler_runs_once_each_hour() -> None:
"""The bounded repair dispatcher uses the requested hourly heartbeat."""
text = _workflow_text()

assert 'cron: "23 * * * *"' in text
assert 'cron: "23 */2 * * *"' not in text


def test_review_fix_scheduler_retries_same_head_after_one_hour() -> None:
"""A blocked head can be retried on the next hourly cycle, not a day later."""
text = _workflow_text()

retry_block = text.split("retry_hours:", maxsplit=1)[1].split(
"autofix_workflow:", maxsplit=1
)[0]
assert 'default: "1"' in retry_block
assert "inputs.retry_hours || '1'" in text
assert "inputs.retry_hours || '24'" not in text


def test_review_fix_scheduler_remains_bounded_and_single_flight() -> None:
"""Higher cadence never expands mutation volume or parallel execution."""
text = _workflow_text()

dispatch_block = text.split("max_dispatches:", maxsplit=1)[1].split(
"target_repository:", maxsplit=1
)[0]
assert 'default: "1"' in dispatch_block
assert "cancel-in-progress: true" in text
assert "MAX_DISPATCHES" in text
54 changes: 54 additions & 0 deletions tests/test_pr_review_fix_scheduler_source_pin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Supply-chain contract for the reusable PR-review autofix scheduler."""

from __future__ import annotations

from pathlib import Path


_REPO_ROOT = Path(__file__).resolve().parents[1]
_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "pr-review-fix-scheduler.yml"


def _workflow_text() -> str:
"""Read the reusable scheduler workflow as UTF-8 text."""
return _WORKFLOW.read_text(encoding="utf-8")


def test_reusable_scheduler_checks_out_the_called_workflow_sha() -> None:
"""Privileged scheduler code comes from the immutable called-workflow revision."""
workflow = _workflow_text()
assert "repository: ${{ job.workflow_repository }}" in workflow
assert "ref: ${{ job.workflow_sha }}" in workflow
assert "persist-credentials: false" in workflow


def test_reusable_scheduler_source_is_not_caller_input_controlled() -> None:
"""No caller-supplied ref or ordinary caller GitHub SHA selects trusted code."""
workflow = _workflow_text()
assert "inputs.canonical_ref" not in workflow
assert "github.event.client_payload.canonical_ref" not in workflow
assert "ref: ${{ env.CANONICAL_REF }}" not in workflow
assert "ref: ${{ github.sha }}" not in workflow


def test_deprecated_canonical_ref_input_is_accepted_but_never_consumed() -> None:
"""Existing callers can upgrade pins without controlling privileged source."""
workflow = _workflow_text()
declaration = workflow.split("canonical_ref:", 1)[1].split(
"repository_dispatch:", 1
)[0]

assert "Deprecated compatibility input" in declaration
assert "ignored" in declaration
assert 'default: ""' in declaration
assert workflow.count("canonical_ref") == 1


def test_reusable_scheduler_retains_least_privilege_and_bounded_dispatch() -> None:
"""Source pinning does not broaden token scope or queue fan-out."""
workflow = _workflow_text()
assert "contents: write" not in workflow
assert "pull-requests: write" not in workflow
assert "MAX_DISPATCHES:" in workflow
assert "RETRY_HOURS:" in workflow
assert "cancel-in-progress: true" in workflow
Loading