Skip to content
Open
82 changes: 81 additions & 1 deletion .github/workflows/codeql-scan-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ jobs:
rerun_schema: ${{ steps.validate.outputs.rerun_schema }}
producer_source_sha: ${{ steps.validate.outputs.producer_source_sha }}
dispatch_protocol: ${{ steps.validate.outputs.dispatch_protocol }}
stale: ${{ steps.validate.outputs.stale }}
steps:
- name: Exchange OpenCode app token for target repository metadata reads
id: metadata_read_app_token
Expand Down Expand Up @@ -367,7 +368,10 @@ jobs:
live_merge_commit_sha="$(jq -r '.merge_commit_sha // empty' <<<"$pull_request_json")"
live_state="$(jq -r '.state // empty' <<<"$pull_request_json")"

if [ "$live_state" != "open" ] ||
# Validate the live identity that must remain stable before deciding
# whether a superseded head can be retired. Head SHA is intentionally
# checked later because a proven descendant is the stale condition.
if { [ "$live_state" != "open" ] && [ "$live_state" != "closed" ]; } ||
[ "$live_base_repository" != "$TARGET_REPOSITORY" ] ||
[ "$live_head_repository" != "$TARGET_REPOSITORY" ] ||
! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] ||
Expand All @@ -378,6 +382,78 @@ jobs:
exit 1
fi

stable_identity_mismatches=()
[ "$SUPPLIED_BASE_REF" = "$live_base_ref" ] || stable_identity_mismatches+=("base_ref")
[ "$SUPPLIED_BASE_SHA" = "$live_base_sha" ] || stable_identity_mismatches+=("base_sha")
[ "$SUPPLIED_HEAD_REF" = "$live_head_ref" ] || stable_identity_mismatches+=("head_ref")
if [ "${#stable_identity_mismatches[@]}" -gt 0 ]; then
printf '::error::repository_dispatch metadata does not match the live pull request: %s. supplied_base=%s/%s live_base=%s/%s supplied_head=%s/%s live_head=%s/%s\n' "$(IFS=,; printf '%s' "${stable_identity_mismatches[*]}")" "${SUPPLIED_BASE_REF:-<empty>}" "${SUPPLIED_BASE_SHA:-<empty>}" "$live_base_ref" "$live_base_sha" "${SUPPLIED_HEAD_REF:-<empty>}" "${SUPPLIED_HEAD_SHA:-<empty>}" "$live_head_ref" "$live_head_sha"
exit 1
fi

# Stale-dispatch retirement. Under runner-queue saturation this run can
# start hours after the dispatched head was superseded or the pull
# request closed; the newer dispatch is itself still queued, so the
# workflow-level cancel-in-progress group cannot retire this run first.
# Only a well-formed live answer proving the dispatched head is no
# longer current ends the run as a notice. A failed `gh api` lookup has
# already exited non-zero above, and every other disagreement below
# (base, head ref, fork, malformed metadata) stays fail-closed.
# No EVENT_NAME guard is needed here, unlike opencode-review-dispatch.yml:
# this workflow is triggered only by repository_dispatch and the
# actor/sender authorization above is unconditional, so every run that
# reaches this point is an authorized dispatch. The OpenCode guard exists
# because its authorization block is itself nested under that event check.
stale_reason=""
if [ "$live_state" = "closed" ]; then
stale_reason="pull request is closed"
elif [ "$live_state" = "open" ] &&
[[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]] &&
[[ "$SUPPLIED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] &&
[ "${SUPPLIED_HEAD_SHA,,}" != "${live_head_sha,,}" ]; then
# A mismatch alone is not proof: right after a push the API can
# briefly serve the previous head, which would retire a brand-new
# dispatch. Retire only when the compare API proves the dispatched
# head is a strict ancestor of the live head (status "ahead",
# behind_by 0), i.e. the live head is newer and gets its own
# dispatch. Behind, diverged (force-push), or a failed/unexpected
# compare falls through to the fail-closed head_sha mismatch below.
head_compare_status=""
if head_compare_json="$(gh api "repos/${TARGET_REPOSITORY}/compare/${SUPPLIED_HEAD_SHA}...${live_head_sha}" 2>/dev/null)"; then
head_compare_status="$(jq -r '
if .status == "ahead" and .behind_by == 0 and ((.ahead_by | type) == "number") and .ahead_by >= 1
then "proven-ancestor" else ((.status // "unknown") | tostring) end
' <<<"$head_compare_json" 2>/dev/null || true)"
fi
if [ "$head_compare_status" = "proven-ancestor" ]; then
stale_reason="pull request head moved ahead of the dispatched head"
else
printf 'Not retiring CodeQL dispatch for %s#%s as stale: dispatched head is not a proven ancestor of the live head (compare=%s).\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "${head_compare_status:-unavailable}"
fi
fi
if [ -n "$stale_reason" ]; then
printf '::notice::Skipping stale CodeQL dispatch for %s#%s: %s (dispatched head=%s, live head=%s, state=%s).\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "$stale_reason" "$SUPPLIED_HEAD_SHA" "${live_head_sha:-<missing>}" "$live_state"
# Backticks are literal Markdown code spans in the step summary.
# shellcheck disable=SC2016
printf -- '- Skipped stale CodeQL dispatch for %s#%s: %s (dispatched head `%s`, live head `%s`, state `%s`).\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "$stale_reason" "$SUPPLIED_HEAD_SHA" "${live_head_sha:-<missing>}" "$live_state" >>"${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf 'stale=true\n'
printf 'target_repository=%s\n' "$TARGET_REPOSITORY"
printf 'pr_number=%s\n' "$PR_NUMBER"
# Keep the matrix well-formed so the skipped scan job's
# strategy never evaluates fromJSON on an empty string.
echo "matrix<<EOF"
printf '%s\n' "$matrix_json"
echo "EOF"
} >>"$GITHUB_OUTPUT"
exit 0
fi

if [ "$live_state" != "open" ]; then
printf '::error::PR metadata validation rejected closed, missing, cross-fork, or malformed live metadata. target=%s#%s state=%s base_repo=%s head_repo=%s base=%s head=%s\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "${live_state:-<missing>}" "${live_base_repository:-<missing>}" "${live_head_repository:-<missing>}" "${live_base_sha:-<missing>}" "${live_head_sha:-<missing>}"
exit 1
fi

mismatches=()
[ "$SUPPLIED_BASE_REF" = "$live_base_ref" ] || mismatches+=("base_ref")
[ "$SUPPLIED_BASE_SHA" = "$live_base_sha" ] || mismatches+=("base_sha")
Expand Down Expand Up @@ -409,6 +485,7 @@ jobs:
fi

{
printf 'stale=false\n'
printf 'target_repository=%s\n' "$TARGET_REPOSITORY"
printf 'pr_number=%s\n' "$PR_NUMBER"
printf 'base_ref=%s\n' "$live_base_ref"
Expand All @@ -432,6 +509,9 @@ jobs:
scan:
name: CodeQL dispatch scan (${{ matrix.language }})
needs: validate-dispatch
# A stale dispatch (closed PR or moved head) ends validate-dispatch with
# a notice; skipping here also skips settle-required-run via needs.
if: needs.validate-dispatch.outputs.stale != 'true'
runs-on:
group: CWL central CodeQL
labels: [self-hosted, linux, x64]
Expand Down
82 changes: 81 additions & 1 deletion .github/workflows/opencode-review-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
head_ref: ${{ steps.validate.outputs.head_ref }}
head_sha: ${{ steps.validate.outputs.head_sha }}
is_private: ${{ steps.validate.outputs.is_private }}
stale: ${{ steps.validate.outputs.stale }}
steps:
- name: Exchange OpenCode app token for target repository metadata reads
id: metadata_read_app_token
Expand Down Expand Up @@ -223,7 +224,10 @@ jobs:
*) live_is_private="" ;;
esac

if [ "$live_state" != "open" ] ||
# Validate the live identity that must remain stable before deciding
# whether a superseded head can be retired. Head SHA is intentionally
# checked later because a proven descendant is the stale condition.
if { [ "$live_state" != "open" ] && [ "$live_state" != "closed" ]; } ||
[ "$live_base_repository" != "$TARGET_REPOSITORY" ] ||
! [[ "$live_head_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] ||
! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] ||
Expand All @@ -235,6 +239,76 @@ jobs:
exit 1
fi

if [ "$EVENT_NAME" = "repository_dispatch" ]; then
stable_identity_mismatches=()
[ "$SUPPLIED_BASE_REF" = "$live_base_ref" ] || stable_identity_mismatches+=("base_ref")
[ "$SUPPLIED_BASE_SHA" = "$live_base_sha" ] || stable_identity_mismatches+=("base_sha")
[ "$SUPPLIED_HEAD_REF" = "$live_head_ref" ] || stable_identity_mismatches+=("head_ref")
if [ "${#stable_identity_mismatches[@]}" -gt 0 ]; then
printf '::error::repository_dispatch metadata does not match the live pull request: %s. supplied_base=%s/%s live_base=%s/%s supplied_head=%s/%s live_head=%s/%s\n' "$(IFS=,; printf '%s' "${stable_identity_mismatches[*]}")" "${SUPPLIED_BASE_REF:-<empty>}" "${SUPPLIED_BASE_SHA:-<empty>}" "$live_base_ref" "$live_base_sha" "${SUPPLIED_HEAD_REF:-<empty>}" "${SUPPLIED_HEAD_SHA:-<empty>}" "$live_head_ref" "$live_head_sha"
exit 1
fi
fi

# Stale-dispatch retirement. Under runner-queue saturation this run can
# start hours after the dispatched head was superseded or the pull
# request closed; the newer dispatch is itself still queued, so the
# workflow-level cancel-in-progress group cannot retire this run first.
# Only a well-formed live answer proving the dispatched head is no
# longer current ends the run as a notice. A failed `gh api` lookup has
# already exited non-zero above, and every other disagreement below
# (base, head ref, fork, malformed metadata) stays fail-closed.
# The EVENT_NAME guard mirrors this script's shape: the actor/sender/
# target authorization above and the supplied-head binding below run
# only for repository_dispatch (other events bind to the live head), so
# retirement is reachable only on the authorized dispatch path.
stale_reason=""
if [ "$EVENT_NAME" != "repository_dispatch" ]; then
:
elif [ "$live_state" = "closed" ]; then
stale_reason="pull request is closed"
elif [ "$live_state" = "open" ] &&
[[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]] &&
[[ "$SUPPLIED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] &&
[ "${SUPPLIED_HEAD_SHA,,}" != "${live_head_sha,,}" ]; then
# A mismatch alone is not proof: right after a push the API can
# briefly serve the previous head, which would retire a brand-new
# dispatch. Retire only when the compare API proves the dispatched
# head is a strict ancestor of the live head (status "ahead",
# behind_by 0), i.e. the live head is newer and gets its own
# dispatch. Behind, diverged (force-push), or a failed/unexpected
# compare falls through to the fail-closed head_sha mismatch below.
head_compare_status=""
if head_compare_json="$(gh api "repos/${TARGET_REPOSITORY}/compare/${SUPPLIED_HEAD_SHA}...${live_head_sha}" 2>/dev/null)"; then
head_compare_status="$(jq -r '
if .status == "ahead" and .behind_by == 0 and ((.ahead_by | type) == "number") and .ahead_by >= 1
then "proven-ancestor" else ((.status // "unknown") | tostring) end
' <<<"$head_compare_json" 2>/dev/null || true)"
fi
if [ "$head_compare_status" = "proven-ancestor" ]; then
stale_reason="pull request head moved ahead of the dispatched head"
else
printf 'Not retiring OpenCode review dispatch for %s#%s as stale: dispatched head is not a proven ancestor of the live head (compare=%s).\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "${head_compare_status:-unavailable}"
fi
fi
if [ -n "$stale_reason" ]; then
printf '::notice::Skipping stale OpenCode review dispatch for %s#%s: %s (dispatched head=%s, live head=%s, state=%s).\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "$stale_reason" "$SUPPLIED_HEAD_SHA" "${live_head_sha:-<missing>}" "$live_state"
# Backticks are literal Markdown code spans in the step summary.
# shellcheck disable=SC2016
printf -- '- Skipped stale OpenCode review dispatch for %s#%s: %s (dispatched head `%s`, live head `%s`, state `%s`).\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "$stale_reason" "$SUPPLIED_HEAD_SHA" "${live_head_sha:-<missing>}" "$live_state" >>"${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf 'stale=true\n'
printf 'target_repository=%s\n' "$TARGET_REPOSITORY"
printf 'pr_number=%s\n' "$PR_NUMBER"
} >>"$GITHUB_OUTPUT"
exit 0
fi

if [ "$live_state" != "open" ]; then
printf '::error::PR metadata validation rejected closed, missing, or malformed live metadata. target=%s#%s state=%s base_repo=%s head_repo=%s base=%s head=%s\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "${live_state:-<missing>}" "${live_base_repository:-<missing>}" "${live_head_repository:-<missing>}" "${live_base_sha:-<missing>}" "${live_head_sha:-<missing>}"
exit 1
fi

if [ "$EVENT_NAME" = "repository_dispatch" ]; then
mismatches=()
[ "$SUPPLIED_BASE_REF" = "$live_base_ref" ] || mismatches+=("base_ref")
Expand All @@ -248,6 +322,7 @@ jobs:
fi

{
printf 'stale=false\n'
printf 'target_repository=%s\n' "$TARGET_REPOSITORY"
printf 'pr_number=%s\n' "$PR_NUMBER"
printf 'base_ref=%s\n' "$live_base_ref"
Expand All @@ -262,6 +337,7 @@ jobs:
id: coverage_read_app_token
if: >-
github.event_name == 'repository_dispatch'
&& steps.validate.outputs.stale != 'true'
&& steps.validate.outputs.target_repository != ''
&& steps.validate.outputs.target_repository != github.repository
env:
Expand Down Expand Up @@ -329,6 +405,7 @@ jobs:
} >>"$GITHUB_OUTPUT"

- name: Materialize pull request merge tree for coverage measurement
if: steps.validate.outputs.stale != 'true'
env:
GH_TOKEN: ${{ steps.coverage_read_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }}
TARGET_REPOSITORY: ${{ steps.validate.outputs.target_repository }}
Expand Down Expand Up @@ -383,6 +460,7 @@ jobs:
tar -cf "$COVERAGE_SOURCE_ARCHIVE" -C "$COVERAGE_SOURCE_WORKDIR" .

- name: Upload materialized pull request merge tree
if: steps.validate.outputs.stale != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: opencode-coverage-source
Expand All @@ -396,6 +474,7 @@ jobs:
if: >-
needs.validate-pr-metadata.result == 'success'
&& github.event_name == 'repository_dispatch'
&& needs.validate-pr-metadata.outputs.stale != 'true'
runs-on:
group: CWL central OpenCode
labels: [self-hosted, linux, x64]
Expand Down Expand Up @@ -2523,6 +2602,7 @@ jobs:
&& needs.validate-pr-metadata.result == 'success'
&& needs.coverage-evidence.result != 'cancelled'
&& github.event_name == 'repository_dispatch'
&& needs.validate-pr-metadata.outputs.stale != 'true'
concurrency:
group: >-
opencode-review-${{
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.d/20260926-dispatch-stale-head-skip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
### Stale CodeQL/OpenCode dispatches end as a notice instead of failing

- `codeql-scan-dispatch.yml` (`validate-dispatch`) and `opencode-review-dispatch.yml`
(`validate-pr-metadata`) now retire a dispatch whose target pull request is closed, or whose
live head has moved strictly past the dispatched head, with a `::notice::`, a step-summary
line and a `stale=true` output. A head mismatch alone is not enough: the compare API
(`repos/{target}/compare/{dispatched}...{live}`) must answer `status: ahead` with
`behind_by: 0`, proving the live head descends from the dispatched one and therefore gets
its own dispatch. A lagging API that still serves the previous head right after a push
(`behind`), a force-push (`diverged`), or a failed or malformed compare keeps the original
fail-closed `head_sha` mismatch, so a fresh head is never skipped. The CodeQL `scan` matrix (and therefore `settle-required-run`)
and the OpenCode coverage-materialization steps, `coverage-evidence` and `opencode-review`
skip on that output, so the run concludes success instead of failure. Under runner-queue
saturation the newer dispatch for the same pull request is itself queued, so workflow-level
`cancel-in-progress` could not retire the stale run before it started.
- Still fail-closed: dispatch authorization and payload validation (checked first), a failed or
malformed live pull-request lookup, base ref/SHA or head ref disagreement at the same head,
cross-fork metadata, and the later privileged re-validation steps. Draft state is not treated
as stale because ruleset-launched required workflows do not re-dispatch on `ready_for_review`.
- The OpenCode side keeps retirement behind `EVENT_NAME == repository_dispatch` because its
dispatch authorization and supplied-head binding are nested under that check; the CodeQL
side needs no such guard because it triggers only on `repository_dispatch` and authorizes
unconditionally before the live lookup.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
### Stale dispatch retirement validates stable PR identity first

- CodeQL and OpenCode dispatch validators now reject malformed live metadata,
cross-repository identity where forbidden, and base/ref disagreements before
treating a proven superseded head as stale. A newer descendant head remains
the only head-SHA mismatch that can retire an open-PR dispatch successfully;
unrelated identity drift fails closed. Regression evidence is 132 passed and
1 skipped in both local and Actions mode, and the full warnings-as-errors
suite is 5,177 passed, 11 skipped, and 40 subtests passed.

### Intel macOS native archives are bound to x86_64 bytes

- The release prescreener now requires every native member in an Intel macOS
Expand Down
Loading
Loading