Skip to content

fix: preserve caller runner for failed prerequisites - #207

Merged
kyle-sexton merged 3 commits into
mainfrom
codex/issue-177-prerequisite-runner-contract
Jul 22, 2026
Merged

fix: preserve caller runner for failed prerequisites#207
kyle-sexton merged 3 commits into
mainfrom
codex/issue-177-prerequisite-runner-contract

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

  • Make semantic-pr, do-not-merge-gate, and pr-issue-linkage honor their runner input for every prerequisite outcome.
  • Keep the existing non-success rejection step first, preserving fail-closed behavior before title, label, or body validation.
  • Document caller-owned recovery routing and add cross-workflow regression coverage for failed, cancelled, skipped, and empty prerequisite results.

Why

The current runs-on expression replaces the caller-selected runner with ubuntu-24.04 whenever prerequisite-result is not success. That defeats a private caller's allowlisted managed-runner fallback precisely on the recovery path, causing the required reporter to request hosted capacity instead of the route the caller selected.

GitHub supports input-backed runs-on values, while dependency continuation and failure reporting remain separate concerns handled by the caller's if: always() and the reusable workflow's explicit rejection step.

Impact

  • Private callers can pass an allowlisted managed fallback and retain it after selector failure, cancellation, skip, or an empty result.
  • Direct/public callers that omit runner retain the declared ubuntu-24.04 default.
  • Check names, permissions, triggers, timeout, normal validation, and prerequisite rejection semantics are unchanged.

Rollout

This is only the upstream reusable-workflow contract portion of #177. After merge, standards must register this PR's exact merge SHA for all three workflow paths in components/runner-policy/policy.json, distribute the managed policy copies, and only then should private consumer callers bump their pins and managed fallback expressions.

No linked issue — this upstream phase intentionally creates no closing link because the standards registration and downstream rollout remain.

Verification

  • Changed-workflow actionlint
  • Focused semantic/hosted-runner contract tests — 7 passed
  • Full node --test .github/scripts/*.test.cjs suite — 207 passed
  • Pinned markdownlint-cli2 0.23.0 and Biome 2.5.4 checks
  • Typos, full shellcheck, shellcheck discovery tests, and CI-scope shfmt
  • All current generated-workflow render drift checks
  • Index-level EOL renormalization check
  • Exact six-file scope and contract invariants

References

Related

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 18s —— View job


Code Review

  • Gather context and read changed files
  • Review workflow changes (semantic-pr.yml, do-not-merge-gate.yml, pr-issue-linkage.yml)
  • Review test changes (hosted-runner-pins.test.cjs, semantic-pr.test.cjs)
  • Review README documentation changes
  • Evaluate security, correctness, and alignment with CLAUDE.md guidelines

Summary

The core change is a clean, well-reasoned simplification: three job-level runs-on expressions that previously fell back to a hardcoded ubuntu-24.04 on non-success prerequisite results are replaced with the unconditional ${{ inputs.runner }}, delegating the fallback entirely to the caller. The fail-closed "Reject failed prerequisite" step handles non-success rejection independently, so the two concerns are correctly separated. The logic is sound and the tests are comprehensive.

A few observations follow — one worth addressing before merge, the rest informational.


Findings

1. Empty-runner regression on the success path (low risk, behavioral change worth documenting)

Old expression${{ inputs.prerequisite-result == 'success' && inputs.runner || 'ubuntu-24.04' }} — had a hidden safety net: when inputs.runner evaluated to an empty string (e.g., a caller passing runner: ${{ steps.select.outputs.runner }} without an || fallback and the step failing to set the output), the expression fell back to ubuntu-24.04 even on the success path because an empty string is falsy in GitHub Actions expression context.

New expression${{ inputs.runner }} — passes the empty string through to runs-on. GitHub will reject the job with a runner-allocation error rather than gracefully defaulting.

This won't affect callers using the documented || 'ubuntu-24.04' pattern, and the input has default: ubuntu-24.04 for callers that omit the field entirely. But a caller that passes runner: ${{ steps.select.outputs.runner }} without an || fallback silently worked before and now fails at scheduling. Consider adding a sentence to the runner input's description noting that callers must provide a non-empty fallback:

semantic-pr.yml L22–24 · same applies to the other two.

Fix this →


2. Comment-prose assertions couple tests to comment text (maintainability concern)

semantic-pr.test.cjs L33–36 asserts that the YAML comment in semantic-pr.yml (between pr-title: and runs-on:) contains specific prose:

assert.match(
  jobContract,
  /Always honor the caller-selected runner,[\s\S]*?failed, skipped,[\s\S]*?...fails closed before title validation/u,
);

This is a tight coupling that forces a test update whenever the comment is rephrased. The structural assertions in hosted-runner-pins.test.cjs (lines 84–106) are the meaningful regression guard here; the prose check in semantic-pr.test.cjs adds friction without catching real regressions. It was present before this PR, so no need to change it in this PR, but it's worth flagging for a follow-up.

semantic-pr.test.cjs L33–36


3. runnerFor in tests models behavior, doesn't validate YAML (informational, not a bug)

Both test files define:

const runnerFor = (_result, selectedRunner = "ubuntu-24.04") => selectedRunner;

and then assert it behaves as expected. This is a JavaScript model of the new contract, not a test of the YAML expression itself. The real regression guard is:

assert.match(requiredJob, /^ {4}runs-on: \$\{\{ inputs\.runner \}\}$/mu);
assert.doesNotMatch(requiredJob, /runs-on:[^\n]*prerequisite-result/u);

Those structural assertions are solid. The runnerFor helper is documentation-as-code — acceptable, but worth knowing it doesn't catch YAML drift by itself.

hosted-runner-pins.test.cjs L64–66


What's Good

  • Fail-closed semantics are preserved. The "Reject failed prerequisite" step with if: ${{ inputs.prerequisite-result != 'success' }} and exit 1 correctly handles all non-success values before the validation step runs. The ordering is also verified by the new structural test.
  • Cross-workflow test coverage. The new test in hosted-runner-pins.test.cjs iterates all three contracts and asserts the exact runs-on expression, the absence of the old conditional, the step ordering, and the reject-step internals. This is the right level of regression testing for a structural contract.
  • SHA-pinning compliance. All uses: references remain pinned at full commit SHAs with version comments, consistent with CLAUDE.md requirements.
  • README accuracy. The updated prose correctly reflects the new contract — caller owns the fallback, reusable passes runner through unchanged. The removal of the [runner-pricing] reference is correct since the fixed-SKU justification no longer applies.
  • Input description improvements. Upgrading from a single-line description: to a >- folded scalar with explicit "Direct callers that omit it use the public hosted default" note is a clear improvement over the prior opaque descriptions.

Verdict

The change is correct and well-tested. Finding #1 is the only item worth a before-merge look — a one-sentence addition to the input description would close the gap. Findings #2 and #3 are informational and do not block merge.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 9s —— View job


Security Review

  • Gather PR diff and changed files
  • Review workflow files for security issues
  • Review test files for security issues
  • Post findings

No security issues found in the changed files.

The six files changed are three reusable workflow definitions, two test files, and the README. Here is what was checked:

runs-on input-backed expression (semantic-pr.yml:93, do-not-merge-gate.yml:44, pr-issue-linkage.yml:64)

The core change replaces ${{ inputs.prerequisite-result == 'success' && inputs.runner || 'ubuntu-24.04' }} with ${{ inputs.runner }}. The runner input is a type: string workflow_call parameter whose value determines which runner pool the job is dispatched to. A malformed or attacker-controlled label here causes the job to queue with no matching runner — it cannot execute arbitrary code via runner selection.

Callers of workflow_call reusable workflows are always workflow files in the organization's own repositories. There is no mechanism for a fork or untrusted contributor to pass an arbitrary runner value at runtime; it must be hardcoded in the caller's workflow file.

The previous conditional guaranteed a ubuntu-24.04 fallback runner for the prerequisite-rejection step on non-success results. The new design delegates that choice to the caller. If a private caller's managed runner is unavailable when a prerequisite fails, the job will queue rather than immediately report failure, which is an architectural tradeoff the PR explicitly documents and scopes to caller-owned responsibility. This is not an exploitable vulnerability within the workflow_call trust boundary.

Context injection surfaces — unchanged by this PR, no new risks

  • semantic-pr.yml:98-102: PREREQUISITE_RESULT flows from inputs.prerequisite-result into an env: block before being expanded in run:. Not spliced into the script text.
  • do-not-merge-gate.yml:142-144: BLOCKING_LABEL and MERGE_GROUP_HEAD_REF passed via env:, read as process.env.* in the JS script. Safe pattern; headRef is regex-matched, not eval'd.
  • pr-issue-linkage.yml:83-89: PR_BODY, PR_AUTHOR, and EXEMPT_AUTHORS all via env:. No untrusted content spliced into script text.

Permissions — unchanged (pull-requests: read on two workflows, {} on pr-issue-linkage.yml)

Action pins — this PR does not modify any uses: SHA pins. All referenced actions remain SHA-pinned with version comments (amannn/action-semantic-pull-request@48f256284b, actions/github-script@3a2844b7e9).

Trigger events — no changes; all three remain workflow_call only. The step-level conditions accepting pull_request_target are pre-existing design (the reusable workflows run from the caller's base branch when the caller uses pull_request_target, with no untrusted checkout and all untrusted input passed via env:).

Test files (.github/scripts/hosted-runner-pins.test.cjs, semantic-pr.test.cjs) — no executable code paths, no secrets, no network access. Changes are assertions over YAML source text.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7da36e470

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread README.md Outdated
The documented private-caller fallback reused the public
`outputs.runner || 'label'` form, but on a strict-selector failure
`select-runner` publishes the non-empty unroutable sentinel
`ci-runner-selection-failed`. A `||` fallback never replaces a non-empty
value, so the sentinel reached `runs-on` and the required reporter job
could not start — defeating fail-closed reporting.

Gate the fallback on `needs.select-runner.result == 'success'` so the
sentinel is ignored, and note the fallback label must itself be routable
when selection fails. Update the README-coupled test assertion to track
the corrected guidance.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 57s —— View job


Code Review

  • Gather PR context and changed files
  • Review workflow YAML changes (semantic-pr.yml, do-not-merge-gate.yml, pr-issue-linkage.yml)
  • Review test changes (hosted-runner-pins.test.cjs, semantic-pr.test.cjs)
  • Review README changes
  • Investigate Codex sentinel concern (select-runner.yml + ci-runner-selection-failed)
  • Post findings

On the Codex Review Comment

The Codex comment flags that a private self-hosted-only caller using outputs.runner || '<managed>' would get ci-runner-selection-failed passed through to runs-on, because the sentinel is a non-empty string and truthy in GHA expression context.

This is already addressed in commit 68c5546. The README now explicitly warns:

A private self-hosted-only caller cannot reuse the public outputs.runner || 'label' form: on a strict-selector failure select-runner publishes the non-empty unroutable sentinel ci-runner-selection-failed, which a || fallback passes straight through instead of replacing.

The correct pattern is documented immediately below (README.md:346):

runner: ${{ needs.select-runner.result == 'success' && needs.select-runner.outputs.runner || 'melodic-ubuntu-24.04-x64' }}

semantic-pr.test.cjs lines 97–106 additionally assert that the README contains this exact warning and the result == 'success' gate expression, so it's a locked regression-tested contract rather than informal prose. The Codex comment appears to have been filed before 68c5546 landed, or missed the README diff.


Remaining Gap: Input Description Lacks Non-Empty Guard Warning

The old runs-on expression had an implicit empty-string guard for both paths:

  • prerequisite-result != 'success' with runner = ''false && '' || 'ubuntu-24.04'ubuntu-24.04
  • prerequisite-result == 'success' with runner = ''true && '' || 'ubuntu-24.04'ubuntu-24.04

The new expression ${{ inputs.runner }} passes an empty string through to runs-on unchanged. GitHub will produce a runner-allocation error rather than a graceful fallback. The runner input has default: ubuntu-24.04 so callers that omit the field are fine; the risk is a caller that explicitly passes runner: ${{ some.expression }} where the expression could evaluate to empty (e.g., ${{ steps.select.outputs.runner }} when the output was never set).

The updated description says "Direct callers that omit it use the public hosted default," but omitting the field and passing an empty expression are different. A one-sentence addition would close this:

semantic-pr.yml:22-24 · same applies to do-not-merge-gate.yml and pr-issue-linkage.yml

Fix this →


What's Good

  • Fail-closed semantics fully preserved. The "Reject failed prerequisite" step with if: ${{ inputs.prerequisite-result != 'success' }} and exit 1 remains the first step, preceding validation. The conditional and the rejection are now cleanly separated concerns.
  • Step-ordering regression guard. The new test at hosted-runner-pins.test.cjs:87-106 verifies the reject step precedes validation for all three contracts. It also asserts the exact runs-on: ${{ inputs.runner }} expression and the absence of the old conditional — so structural drift is caught without requiring a lint tool.
  • Cross-workflow coverage. The loop over prerequisiteGateContracts means the same regression test applies to all three reusables without copy-paste; a fourth contract added later only needs a new entry in the array.
  • Sentinel warning is tested. semantic-pr.test.cjs lines 97–106 assert the README contains the ci-runner-selection-failed warning and the result == 'success' gated expression. That makes the documentation a tested, load-bearing contract.
  • CLAUDE.md compliance. All uses: remain pinned at full commit SHAs with version comments. Triggers, permissions, and the pull_request_target tripwire are unchanged.

Verdict

The change is correct and the Codex concern is already addressed. The one remaining item worth a before-merge look is the input description — a single sentence noting that callers must supply a non-empty expression would document the implicit guard that was removed and prevent a subtle foot-gun for callers who rely on an expression that could evaluate to empty.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 7s —— View job


Security Review

  • Get PR diff
  • Read changed workflow files
  • Analyze for security vulnerabilities
  • Post findings

No security issues found in the changed files.

The six changed files are three reusable workflow definitions, two test files, and the README. Here is what was checked:

runs-on promotion to ${{ inputs.runner }} (semantic-pr.yml:93, do-not-merge-gate.yml:44, pr-issue-linkage.yml:64)

The core change replaces the conditional ${{ inputs.prerequisite-result == 'success' && inputs.runner || 'ubuntu-24.04' }} with ${{ inputs.runner }} on all three prerequisite rejection paths. The runner input is a type: string workflow_call parameter whose value controls which runner pool the job is dispatched to — it does not execute code in the workflow definition itself. workflow_call callers are always workflow files in the organization's own trusted repositories; there is no mechanism for a fork contributor or untrusted external actor to supply an arbitrary runner value at runtime. A crafted label would either queue against a non-existent runner (job times out at 15 minutes) or route to a self-hosted runner the organization already manages, neither of which is an exploitable escalation introduced by this change.

Fail-closed semantics — unchanged

The "Reject failed prerequisite" step (semantic-pr.yml:96-102) fires before any validation step under if: ${{ inputs.prerequisite-result != 'success' }} and exits with exit 1. Ordering is verified structurally by the new test in hosted-runner-pins.test.cjs:87-106. No change to the fail-closed guarantee.

Context injection surfaces — no new risks

The PREREQUISITE_RESULT variable flows from inputs.prerequisite-result through an env: block and is referenced as ${PREREQUISITE_RESULT} inside a double-quoted string in a run: script — not spliced into the script text via ${{ }}. In shell, a double-quoted string prevents command substitution and most metacharacter expansion. A newline in the value could theoretically emit an additional ::command:: line to stdout, but the only commands injectable this way produce log annotations (::error::, ::warning::), not privilege-escalating operations; set-env and add-path workflow commands are disabled by default since 2020. The realistic value domain is the set of GitHub-controlled job result strings (success, failure, cancelled, skipped).

All three workflows continue the same safe env:-passed pattern for untrusted PR content:

Permissions — unchanged (pull-requests: read on semantic-pr and do-not-merge-gate; {} on pr-issue-linkage)

Trigger events — no changes; all three remain workflow_call only

Action SHA pins — this PR does not modify any uses: pins. All referenced actions remain SHA-pinned:

  • amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
  • actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0

Test files — pure static text analysis over YAML source files; no network access, no secrets, no eval.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Per-finding classification for the code-review comment above (reacted 👍 — finding #1 acted on):

# Finding Classification Evidence Reacted
1 Empty-runner regression on the success path — a caller passing a dynamic runner without an ` ` fallback silently worked before and now fails at scheduling; consider noting callers must supply a non-empty fallback VALID — addressed via docs, expression intentionally unchanged. The ${{ inputs.runner }} pass-through is the deliberate new contract (caller owns recovery). The input description already states omitted callers use the ubuntu-24.04 default, and this push adds README guidance making the caller-owned fallback explicit (incl. the result-gated form for private callers). No workflow-expression change — that would re-introduce a reusable-side fallback the PR intentionally removed.
2 Comment-prose assertion in semantic-pr.test.cjs L33–36 tightly couples tests to comment text; pre-existing, worth a follow-up VALID (defer). Acknowledged as pre-existing coupling; per your own note it is out of scope to remove here. This push necessarily updated the README-prose assertion (L102–105) to track corrected guidance, but did not de-couple it — full removal remains the flagged follow-up, best filed as its own issue. 👍
3 runnerFor helper models behavior in JS rather than validating the YAML expression; informational, not a bug INFORMATIONAL — no action. Agreed. The structural assertions in hosted-runner-pins.test.cjs (exact runs-on: ${{ inputs.runner }} match + doesNotMatch on the old conditional) are the real regression guard; the helper is documentation-as-code. No change. 👍

The `runner` input description covered callers that omit the field (they
get the hosted default) but not callers passing a dynamic expression.
With the reusable-side empty-string fallback removed, an expression that
resolves to empty now fails runner allocation rather than defaulting. Add
that caveat to the description of all three prerequisite gates
(semantic-pr, do-not-merge-gate, pr-issue-linkage).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 26s —— View job


Code Review

  • Gather context and read changed files
  • Review latest commit (3f7d299) — empty-runner docs warning
  • Review full PR diff holistically
  • Post findings

On the Three Commits

This review covers the final three-commit state of the PR.

Commit What it does
c7da36e Core fix — replaces the conditional runs-on with ${{ inputs.runner }} in all three reusables
68c5546 Sentinel docs — README warns private callers about ci-runner-selection-failed; semantic-pr.test.cjs locks the README prose via assertion
3f7d299 Empty-runner docs — upgrades all three runner input descriptions to a >- folded scalar warning

Finding #1 from Previous Reviews: Fully Addressed

The empty-runner concern is now documented where it matters most — on the input itself. All three workflows carry the same three-sentence description:

Runner label selected by the caller for every prerequisite outcome. Direct callers that omit it use the public hosted default. A caller passing a dynamic expression must ensure it resolves to a non-empty label; the reusable applies no implicit fallback, so an empty value fails runner allocation.

This precisely covers the three distinct caller scenarios: (a) field omitted → default: ubuntu-24.04 applies, (b) literal label → always non-empty, (c) dynamic expression → caller is now explicitly on notice. The wording is correct.

semantic-pr.yml:21-28 · identical text in the other two.


Test Changes in 3f7d299

YAML comment regex (semantic-pr.test.cjs:33-36) — Updated to match the new comment text in semantic-pr.yml. All token anchors verify correctly against the actual YAML comment:

Regex token Comment text
Always honor the caller-selected runner, ✓ exact
failed, skipped,
cancelled, or empty prerequisite result
caller owns any recovery
public \ubuntu-24.04` default`
caller's \if: always()``
fails closed before title validation

README assertion — Both updated matchers verify against the new README prose. First assert checks the runner-unchanged-for-all-outcomes sentence; second chains through the ubuntu-24.04 fallback statement, the sentinel warning, and the result == 'success' gate expression. All confirmed present in the README diff.

runnerFor and dispatch("") — The helper is correctly simplified to (_result, selectedRunner = "ubuntu-24.04") => selectedRunner (identity on the selected runner), and dispatch("") was added to verify that an empty prerequisite result is fail-closed. Both changes accurately model the new contract.

Step-name assertions in hosted-runner-pins.test.cjs — verified against the actual workflow files:

  • "Reject failed prerequisite"semantic-pr.yml:99, do-not-merge-gate.yml:50, pr-issue-linkage.yml:70
  • "Validate PR title against Conventional Commits"semantic-pr.yml:107
  • "Reject a blocking label"do-not-merge-gate.yml:58
  • "Validate PR body against the closing-keyword + Related convention"pr-issue-linkage.yml:78

One Minor Style Nit (non-blocking)

In hosted-runner-pins.test.cjs, the two loop-invariant assertions:

assert.equal(runnerFor("failure"), "ubuntu-24.04");
assert.equal(mustReject("success"), false);

are inside the for...of prerequisiteGateContracts loop, so they execute three times rather than once. They're correct and still fast; worth hoisting out if someone edits the file later, but no action required here.

hosted-runner-pins.test.cjs:123-126


What's Good

  • All prior findings resolved. Finding feat/phase1 markdown powershell #1 (empty-runner regression) is addressed in 3f7d299 with precise three-sentence input descriptions. The Codex sentinel concern is addressed in 68c5546 with README docs locked by test assertion.
  • Input descriptions are the right place for the contract. Documenting the no-implicit-fallback behavior on the runner input ensures it's visible to every consumer at the point of use, independent of README navigation.
  • Test coverage is comprehensive and cross-workflow. The prerequisiteGateContracts loop in hosted-runner-pins.test.cjs asserts the exact runs-on expression, absence of the old conditional, step ordering (reject before validation), and reject-step internals for all three reusables in one test. Adding a fourth contract requires only a new array entry.
  • CLAUDE.md compliance. All uses: remain SHA-pinned with version comments. Triggers, permissions, and the pull_request_target tripwire are untouched.
  • README accuracy. The updated prose correctly explains: caller owns the fallback; both the public || form and the omitted-input default preserve ubuntu-24.04; private self-hosted-only callers must gate on result == 'success' to avoid passing the unroutable sentinel. The [job-runs-on] reference replaces the now-irrelevant [runner-pricing] reference cleanly.

Verdict

Ready to merge. All findings from previous reviews are addressed. The three-commit arc moves the contract cleanly from the old reusable-side fallback to the new caller-owned routing model, and the documentation (input descriptions + README) correctly captures every consumer-relevant nuance.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 33s —— View job


Security Review

  • Initialize task list
  • Get PR diff and read changed files
  • Hunt for injection surfaces, auth gaps, dangerous GHA patterns
  • Post findings

No security issues found in the six changed files.

Threat surface analyzed

runs-on: ${{ inputs.runner }} (semantic-pr.yml:96, do-not-merge-gate.yml:47, pr-issue-linkage.yml:67)

The runner input is a workflow_call string parameter. Its value at runtime is set exclusively by org-owned caller workflow files — there is no mechanism for a fork contributor, PR author, or external actor to supply an arbitrary label at dispatch time. A crafted or unroutable label causes the job to queue against a non-existent runner (timing out at 15 minutes) or to route to a self-hosted pool the org already operates and explicitly configured the caller to use; neither outcome is an escalation beyond the trust boundary of the caller relationship.

PREREQUISITE_RESULT in run: blocks (semantic-pr.yml:101-105, do-not-merge-gate.yml:51-56, pr-issue-linkage.yml:71-76)

inputs.prerequisite-result is passed through env: and expanded as ${PREREQUISITE_RESULT} inside a double-quoted shell string — it is not spliced into the script text via ${{ }}. The realistic value domain is the set of GitHub-controlled job result strings (success, failure, cancelled, skipped, empty). The only parties who can supply arbitrary values here are org-owned caller workflow files, which are within the trust boundary. Even in the worst case a value containing \n::set-env:: could produce a log annotation, but set-env / add-path workflow commands have been disabled by default since 2020.

Context injection surfaces — unchanged, no new risk

  • do-not-merge-gate.yml:146-147: BLOCKING_LABEL and MERGE_GROUP_HEAD_REF passed via env:, consumed as process.env.* in a github-script step. headRef is regex-matched against a known pattern, never eval'd.
  • pr-issue-linkage.yml:87-92: PR_BODY, PR_AUTHOR, and EXEMPT_AUTHORS all via env:. PR body is not spliced into script text; it is read via process.env.PR_BODY. These patterns pre-date this PR and are not modified.

Trigger events — unchanged; all three remain workflow_call only. No new pull_request_target or workflow_run-with-secrets exposure introduced.

Permissions — unchanged; pull-requests: read on semantic-pr.yml and do-not-merge-gate.yml, {} on pr-issue-linkage.yml. No widening.

SHA pins — unmodified; amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 and actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 remain.

Test files and README — static text; no executable paths, no secrets, no network access.


@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Follow-up on code-review finding #1 (empty-runner / non-empty guard), revisiting the prior classification.

The prior reply addressed this via README guidance and left the runner input description unchanged. Re-reviewing, the reviewer's actual ask was a caveat in the input description itself (read at the call site), distinct from the README. That gap was real, branch-owned, and doc-only (no input/output contract change), so it is now fixed rather than left dispositioned:

Fixed in 3f7d299. All three prerequisite-gate reusables (semantic-pr.yml, do-not-merge-gate.yml, pr-issue-linkage.yml) now note in the runner description that a caller passing a dynamic expression must ensure it resolves to a non-empty label, because the reusable applies no implicit fallback. The ${{ inputs.runner }} pass-through expression is deliberately unchanged (caller owns recovery); only the documentation caveat was added. actionlint and the hosted-runner-pins / semantic-pr / osv-scanner-pin cjs tests pass.

@kyle-sexton
kyle-sexton merged commit 380612a into main Jul 22, 2026
37 checks passed
@kyle-sexton
kyle-sexton deleted the codex/issue-177-prerequisite-runner-contract branch July 22, 2026 04:55
kyle-sexton added a commit to melodic-software/standards that referenced this pull request Jul 23, 2026
## Summary

- register the exact merged `melodic-software/ci-workflows`
prerequisite-gate contracts at
`380612ae1d4e0cc9741efbac7b6ffb3d3da63a04`
- preserve each workflow's reviewed `runner`, `prerequisite-result`,
allowed-input, and empty-secret boundaries
- lock the production inventory in the runner-policy regression suite
and document the strict-selector sentinel caveat

## Why

ci-workflows#177 moved `semantic-pr`, `do-not-merge-gate`, and
`pr-issue-linkage` to the caller-selected runner for every prerequisite
outcome. Private consumers cannot repin to that immutable merge until
the canonical standards runner policy recognizes the exact reviewed
contracts.

The canonical `components/runner-policy/policy.json` is the source of
truth. Its managed downstream copies will flow through the normal
standards synchronization process; no generated or consumer copy is
edited here.

## Validation

- focused production-contract test: 1 passed
- complete runner-policy suite: 228 passed
- runner-policy self-audit: passed
- markdownlint: 0 errors
- pre-commit: Biome, EditorConfig, markdownlint, typos, and gitleaks
passed

No linked issue: this PR is the required standards registration
follow-up to an already completed ci-workflows issue.

## Related

- melodic-software/ci-workflows#177
- melodic-software/ci-workflows#207
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant