Skip to content
Open
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
71 changes: 69 additions & 2 deletions .github/workflows/actions-queue-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
permissions:
contents: read
actions: read
id-token: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
Expand All @@ -31,12 +32,78 @@ jobs:
with:
persist-credentials: false

- name: Exchange OpenCode app token for cross-repo reads
id: queue_read_app_token
env:
OIDC_AUDIENCE: opencode-github-action
OPENCODE_API_BASE_URL: https://api.opencode.ai
run: |
set -euo pipefail

mark_unavailable() {
echo "available=false" >>"$GITHUB_OUTPUT"
}

if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "OpenCode app token exchange unavailable: OIDC request environment is missing."
mark_unavailable
exit 0
fi

request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}"
separator="&"
case "$request_url" in
*\?*) ;;
*) separator="?" ;;
esac

if ! oidc_response="$(
curl -fsS \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${request_url}${separator}audience=${OIDC_AUDIENCE}"
)"; then
echo "OpenCode app token exchange unavailable: OIDC token request did not complete."
mark_unavailable
exit 0
fi

oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")"
if [ -z "$oidc_token" ]; then
echo "OpenCode app token exchange unavailable: OIDC token response was empty."
mark_unavailable
exit 0
fi

if ! token_response="$(
curl -fsS \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
"${OPENCODE_API_BASE_URL}/exchange_github_app_token"
)"; then
echo "OpenCode app token exchange unavailable: app token request did not complete."
mark_unavailable
exit 0
fi

app_token="$(jq -r '.token // empty' <<<"$token_response")"
if [ -z "$app_token" ]; then
echo "OpenCode app token exchange unavailable: app token response was empty."
mark_unavailable
exit 0
fi

echo "::add-mask::$app_token"
{
echo "available=true"
echo "token=$app_token"
} >>"$GITHUB_OUTPUT"

- name: Collect read-only repository and runner evidence
env:
GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }}
GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.queue_read_app_token.outputs.token }}
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN is required for cross-repository queue reads."
echo "::error::PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the exchanged OpenCode app token is required for cross-repository queue reads."
exit 1
fi
echo "::add-mask::$GH_TOKEN"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ name: CodeQL PR

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, closed]
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]
# Do not restrict the base ref: the org required-workflow ruleset already
# scopes this to each repository's actual default branch via
# ref_name: ["~DEFAULT_BRANCH"], whatever it is named. A hardcoded
Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
detect-languages:
name: Detect CodeQL languages
# Draft PRs get no runner; ready_for_review re-runs this on the same head.
if: (github.event.action != 'closed') && (github.event.pull_request.draft != true || github.event.action == 'converted_to_draft' || github.event.action == 'closed')
if: github.event.action != 'closed' && github.event.pull_request.draft != true
runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }}
timeout-minutes: 5
permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ jobs:
github.event_name != 'repository_dispatch' ||
github.event.client_payload.org_sweep != true
)
) && (github.event.pull_request.draft != true || github.event.action == 'converted_to_draft' || github.event.action == 'closed')
) && github.event.pull_request.draft != true
# The group admits only trusted central main workflows, including reusable
# callers. Keep admission/dispatch off pools occupied by model execution.
runs-on:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/python-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ name: Python Security

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, closed]
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]
branches: [main, master, develop]
push:
branches: [main, master, develop]
Expand All @@ -45,7 +45,7 @@ permissions:
jobs:
detect-python:
name: Detect Python
if: github.event.action != 'closed'
if: github.event.action != 'closed' && github.event.pull_request.draft != true
runs-on: ubuntu-24.04
outputs:
has_python: ${{ steps.detect.outputs.has_python }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/sast-semgrep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ name: SAST Semgrep

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, closed]
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]
# Scan every PR base ref, including feature branches used by stacked PRs.
push:
branches: [main, master, develop]
Expand Down Expand Up @@ -49,7 +49,7 @@ jobs:
# docs/doctoring/required-workflow-path-filter-boundary.md.
# Fails OPEN: an unreadable, empty, or truncated file list scans everything.
# The gate lives inside this job as a step-level guard (one runner, not two).
if: github.event.action != 'closed'
if: github.event.action != 'closed' && github.event.pull_request.draft != true
runs-on: ubuntu-24.04
permissions:
contents: read
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ name: Security Scan

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, closed]
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]
# Do not restrict the base ref: stacked PRs must receive the same
# diff-scoped OSV/dependency and repo-wide Trivy gate as default-branch PRs.

Expand Down Expand Up @@ -70,7 +70,7 @@ jobs:
# here and consumed through `needs`. See
# docs/doctoring/required-workflow-path-filter-boundary.md.
# Fails OPEN: an unreadable, empty, or truncated file list scans everything.
if: github.event.action != 'closed'
if: github.event.action != 'closed' && github.event.pull_request.draft != true
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
Expand Down Expand Up @@ -435,7 +435,7 @@ jobs:
# push, schedule, and manual backstops remain in secret-scan.yml.
gitleaks:
name: gitleaks (secret scan)
if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github'
if: github.event.action != 'closed' && github.event.pull_request.draft != true && github.repository == 'ContextualWisdomLab/.github'
runs-on: ubuntu-24.04
permissions:
contents: read
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/20260930-draft-pr-runner-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@
conclude that a draft needs no verdict; marking the pull request ready runs
them again on the same head. Noema is unchanged because it reads the live
draft state.
- A `converted_to_draft` event now enters the existing per-PR concurrency
group for CodeQL PR, SAST Semgrep, Security Scan, and Python Security. The
event cancels an older queued same-PR run, while every entry job skips before
runner admission. This closes the queue-retention gap without weakening any
Ready-head security check.
54 changes: 54 additions & 0 deletions docs/doctoring/draft-transition-queue-retirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Draft-transition queue retirement

## Incident

A live organization sample on 2026-09-30 found 100 of the 100 most recently
updated Open Ready pull requests with required workflows still queued. Twenty-five
of those pull requests had a terminal workflow failure, a current
`CHANGES_REQUESTED` review, an unresolved review thread, or an explicit
predecessor/partial-implementation boundary and were moved back to Draft at
their unchanged exact heads.

The central CodeQL PR, SAST Semgrep, Security Scan, and Python Security
workflows already used per-PR `cancel-in-progress: true` concurrency. They
subscribed to `ready_for_review` but not `converted_to_draft`. Consequently,
a Draft transition could stop future product admission but could not create the
same-concurrency replacement run that retires the already queued Ready event.

## Decision

Each affected workflow subscribes to `converted_to_draft`. Every entry job
also requires `github.event.pull_request.draft != true`. GitHub therefore
applies workflow-level concurrency and cancels the older same-PR run, then skips
the replacement before assigning a runner.

The concurrency key, permissions, checkout identity, scanner configuration,
failure threshold, and Ready-head behavior remain unchanged. No workflow run is
rerun manually, no required result is synthesized, and no failed result is
converted to success.

## Alternatives

- Leaving the queue intact was rejected because Draft is an explicit admission
withdrawal and stale queued work consumes the organization job ceiling.
- Cancelling runs from the repair client was rejected because it duplicates the
canonical workflow owner and depends on a privileged external sweeper.
- Adding a new cancellation job was rejected because workflow-level concurrency
already performs the exact same-head retirement before runner admission.

## Verification

`test_converted_to_draft_retires_queued_run_without_runner` first failed for
all four workflows because the event was absent. After the repair, the focused
test reports four passes and the complete draft-control contract reports
14 passes. An affected-workflow audit also found and corrected one stale SAST
test oracle that still required the old closed-only job guard. The resulting
workflow-consumer suite reports 672 passes, and the warnings-fatal repository
suite reports 5,259 passes, five optional-platform skips, and 40 subtests.
Hosted exact-head checks remain required before protected merge.

## Follow-up

After ordinary protected merge, observe the next Draft transition and confirm
that the older CodeQL PR, SAST Semgrep, Security Scan, and Python Security runs
leave the queue without assigning a hosted or control runner.
14 changes: 12 additions & 2 deletions tests/test_actions_queue_health_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,19 @@ def test_queue_health_workflow_is_scheduled_read_only_and_pinned() -> None:
collect_permissions = workflow.split(" collect:\n", 1)[1].split(
" permissions:\n", 1
)[1].split(" steps:\n", 1)[0]
assert collect_permissions == " contents: read\n actions: read\n"
assert collect_permissions == (
" contents: read\n"
" actions: read\n"
" id-token: write\n"
)
assert "Exchange OpenCode app token for cross-repo reads" in workflow
assert "id: queue_read_app_token" in workflow
assert "OIDC_AUDIENCE: opencode-github-action" in workflow
assert "audience=${OIDC_AUDIENCE}" in workflow
assert "/exchange_github_app_token" in workflow
assert (
"GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }}"
"GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN "
"|| steps.queue_read_app_token.outputs.token }}"
in workflow
)
assert "GH_TOKEN: ${{ github.token }}" not in workflow
Expand Down
28 changes: 24 additions & 4 deletions tests/test_control_workflows_skip_draft_prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@
"codeql-pr.yml",
"pr-review-merge-scheduler.yml",
]
DRAFT_GUARD = (
"(github.event.pull_request.draft != true || github.event.action == 'converted_to_draft' "
"|| github.event.action == 'closed')"
)
DRAFT_GUARD = "github.event.pull_request.draft != true"


def _load(name: str) -> dict:
Expand Down Expand Up @@ -77,6 +74,29 @@ def test_entry_jobs_skip_draft_prs(name: str) -> None:
assert DRAFT_GUARD in str(job.get("if", "")), f"{name}:{job_name} lacks the draft guard"



QUEUE_RETIREMENT_WORKFLOWS = [
"codeql-pr.yml",
"pr-review-merge-scheduler.yml",
"sast-semgrep.yml",
"security-scan.yml",
"python-security.yml",
]


@pytest.mark.parametrize("name", QUEUE_RETIREMENT_WORKFLOWS)
def test_converted_to_draft_retires_queued_run_without_runner(name: str) -> None:
"""A draft transition cancels the same-PR queue without taking a runner."""
doc = _load(name)
assert "converted_to_draft" in _pr_types(doc)
for job_name, job in _entry_jobs(doc).items():
if _is_cancellation_job(job_name, job):
continue
condition = str(job.get("if", ""))
assert "github.event.pull_request.draft != true" in condition
assert "github.event.action == 'converted_to_draft'" not in condition


def test_opencode_verdict_gate_still_runs_on_ready_for_review() -> None:
"""The fail-closed verdict gate is untouched for ready (non-draft) events."""
doc = _load("opencode-review.yml")
Expand Down
4 changes: 3 additions & 1 deletion tests/test_docs_only_pr_runner_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,9 @@ def test_sast_semgrep_folds_the_gate_into_its_single_consumer_at_step_level():
assert not re.search(r"(?m)^ needs:", semgrep)
job_if = re.search(r"(?m)^ if: (.*)$", semgrep)
assert job_if is not None
assert job_if.group(1) == "github.event.action != 'closed'"
assert job_if.group(1) == (
"github.event.action != 'closed' && github.event.pull_request.draft != true"
)
assert "pull-requests: read" in semgrep
assert "id: scope" in semgrep
assert semgrep.count("steps.scope.outputs.code == 'true'") == 5
Expand Down
Loading