Summary
When a create_pull_request (or push_to_pull_request_branch) safe output uses a dynamic target-repo — e.g. target-repo: ${{ inputs.owner }}/${{ inputs.repo }} for a reusable single-repo worker driven by workflow_dispatch inputs — the generated patch file is written by the producer (agent job, inside the firewalled sandbox) under a name containing the un-expanded literal ${GH_AW_INPUT_OWNER}/${GH_AW_INPUT_REPO}, while the consumer (safe_outputs job) looks for a name built from the Actions-expanded value. The names never match, so the consumer reports No patch file found and skips the PR.
Because a missing patch is a warning (correct for genuine no-ops), the whole run concludes green — the agent did and verified all the work, but no PR is ever opened, and nothing surfaces the loss.
Version: compiler v0.83.1 (latest release). Also present on main.
Reproduction
Reusable single-repo worker, dispatched with owner/repo inputs. Frontmatter:
on:
workflow_dispatch:
inputs:
owner: { required: true }
repo: { required: true }
safe-outputs:
create-pull-request:
target-repo: ${{ inputs.owner }}/${{ inputs.repo }}
max: 1
push-to-pull-request-branch:
target-repo: ${{ inputs.owner }}/${{ inputs.repo }}
The target repo is checked out to ./target; patch_workspace_path: target. The agent edits files under ./target, verifies them, and calls create_pull_request.
Observed
Producer (agent job), ingestion step:
Successfully parsed 1 valid output items → output_types: create_pull_request
Found 2 patch/bundle file(s):
aw-${gh_aw_input_owner}-${gh_aw_input_repo}-agent-<branch>.bundle
aw-${gh_aw_input_owner}-${gh_aw_input_repo}-agent-<branch>.patch
The patch file physically on disk is literally named aw-${gh_aw_input_owner}-${gh_aw_input_repo}-… — the template was slugified (lowercased, /→-) without being expanded.
Consumer (safe_outputs job), Process Safe Outputs:
Patch file path: (not set)
Apply transport mode: patch (bundle file present: false)
##[warning]No patch file found - cannot create pull request without changes
⏭ Message 1 (create_pull_request) skipped — No patch file found
Run conclusion: success.
Root cause
The single frontmatter value is compiled into two different substitution styles:
| Job |
Where |
Value in the lock |
Evaluated by |
Result |
Producer (agent) |
Generate Safe Outputs Config step, config.json written via a single-quoted heredoc (<< 'DELIM') |
${GH_AW_INPUT_OWNER}/${GH_AW_INPUT_REPO} |
nothing — quoted heredoc disables expansion, and the sandbox process reading the config has neither var set |
literal string → leaks into patch filename |
Consumer (safe_outputs) |
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG env |
${{ inputs.owner }}/${{ inputs.repo }} |
GitHub Actions |
real slug |
The producer's patch filename derives from current_checkout_repo, resolved in actions/setup/js/safe_outputs_handlers.cjs (repoSlug = repoResult.repo) from the config's target-repo / current_checkout_repo — still the literal ${GH_AW_INPUT_OWNER}/… string. pkg/workflow/safe_outputs_patch_workspace.go sets current_checkout_repo at compile time by string-comparing target-repo against the checkout's registered repo; both are the same raw expression string, so they match and the literal expression is copied through verbatim.
The two jobs disagree on the patch filename purely because one path expands the value and the other doesn't.
Impact
- Any centralized "control repo operates on a target repo" pattern (dispatch inputs → dynamic
target-repo) silently never produces PRs.
add_comment / create_issue / add_labels are unaffected — they route via the consumer config's expanded value and don't depend on a producer-written patch filename.
- The green run hides it; there's no failed check to alert on.
Suggested fix
Expand ${GH_AW_INPUT_*} when the producer writes config.json, so producer and consumer agree on the resolved slug — e.g. emit the Generate Safe Outputs Config heredoc unquoted (the step already exports GH_AW_INPUT_OWNER / GH_AW_INPUT_REPO), or resolve target-repo / current_checkout_repo to the concrete slug before writing. Alternatively, have the consumer locate the patch via the aw-*.patch glob it already uses for threat-detection prep, rather than reconstructing an exact filename independently.
Summary
When a
create_pull_request(orpush_to_pull_request_branch) safe output uses a dynamictarget-repo— e.g.target-repo: ${{ inputs.owner }}/${{ inputs.repo }}for a reusable single-repo worker driven byworkflow_dispatchinputs — the generated patch file is written by the producer (agent job, inside the firewalled sandbox) under a name containing the un-expanded literal${GH_AW_INPUT_OWNER}/${GH_AW_INPUT_REPO}, while the consumer (safe_outputsjob) looks for a name built from the Actions-expanded value. The names never match, so the consumer reportsNo patch file foundand skips the PR.Because a missing patch is a warning (correct for genuine no-ops), the whole run concludes green — the agent did and verified all the work, but no PR is ever opened, and nothing surfaces the loss.
Version: compiler
v0.83.1(latest release). Also present onmain.Reproduction
Reusable single-repo worker, dispatched with
owner/repoinputs. Frontmatter:The target repo is checked out to
./target;patch_workspace_path: target. The agent edits files under./target, verifies them, and callscreate_pull_request.Observed
Producer (
agentjob), ingestion step:The patch file physically on disk is literally named
aw-${gh_aw_input_owner}-${gh_aw_input_repo}-…— the template was slugified (lowercased,/→-) without being expanded.Consumer (
safe_outputsjob),Process Safe Outputs:Run conclusion: success.
Root cause
The single frontmatter value is compiled into two different substitution styles:
agent)Generate Safe Outputs Configstep,config.jsonwritten via a single-quoted heredoc (<< 'DELIM')${GH_AW_INPUT_OWNER}/${GH_AW_INPUT_REPO}safe_outputs)GH_AW_SAFE_OUTPUTS_HANDLER_CONFIGenv${{ inputs.owner }}/${{ inputs.repo }}The producer's patch filename derives from
current_checkout_repo, resolved inactions/setup/js/safe_outputs_handlers.cjs(repoSlug = repoResult.repo) from the config'starget-repo/current_checkout_repo— still the literal${GH_AW_INPUT_OWNER}/…string.pkg/workflow/safe_outputs_patch_workspace.gosetscurrent_checkout_repoat compile time by string-comparingtarget-repoagainst the checkout's registered repo; both are the same raw expression string, so they match and the literal expression is copied through verbatim.The two jobs disagree on the patch filename purely because one path expands the value and the other doesn't.
Impact
target-repo) silently never produces PRs.add_comment/create_issue/add_labelsare unaffected — they route via the consumer config's expanded value and don't depend on a producer-written patch filename.Suggested fix
Expand
${GH_AW_INPUT_*}when the producer writesconfig.json, so producer and consumer agree on the resolved slug — e.g. emit theGenerate Safe Outputs Configheredoc unquoted (the step already exportsGH_AW_INPUT_OWNER/GH_AW_INPUT_REPO), or resolvetarget-repo/current_checkout_repoto the concrete slug before writing. Alternatively, have the consumer locate the patch via theaw-*.patchglob it already uses for threat-detection prep, rather than reconstructing an exact filename independently.