Skip to content

feat: compile-time validation of needs: ordering on custom safe-output jobs#23486

Merged
pelikhan merged 3 commits intomainfrom
copilot/fix-custom-safe-output-ordering
Mar 29, 2026
Merged

feat: compile-time validation of needs: ordering on custom safe-output jobs#23486
pelikhan merged 3 commits intomainfrom
copilot/fix-custom-safe-output-ordering

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 29, 2026

Custom safe-output jobs (safe-outputs.jobs) had no first-class way to declare ordering relative to generated jobs like safe_outputs. Authors patching needs: directly into .lock.yml had those edits silently wiped on every recompile.

Changes

New job name constants (pkg/constants/constants.go)

  • SafeOutputsJobName, UploadAssetsJobName, ConclusionJobName, UnlockJobName

Compile-time validation (pkg/workflow/safe_jobs_needs_validation.go)

  • validateSafeJobNeeds — validates each custom safe-job's needs: entries against the jobs that will actually exist in the compiled workflow; fails with a clear error listing valid alternatives. Also rewrites each needs: entry to its canonical underscore form in-place (e.g. safe-outputssafe_outputs) so the compiled YAML always references the correct job ID.
  • computeValidSafeJobNeeds — dynamically resolves valid targets based on workflow config (threat detection state, upload-asset, lock-for-agent, peer safe-jobs)
  • consolidatedSafeOutputsJobWillExist — predicts whether the safe_outputs consolidated job will be emitted; it is only created when at least one builtin handler, custom script, custom action, or user-provided step is configured. Workflows with only safe-outputs.jobs entries do not produce a safe_outputs job, so safe_outputs is not a valid needs: target in that case.
  • detectSafeJobCycles — DFS cycle detection among custom safe-jobs

Hooked into validateWorkflowData alongside existing safe-outputs validators.

Valid needs: targets for custom safe-jobs

Target Available when
agent Always
safe_outputs At least one builtin handler, script, action, or user step is configured
detection Threat detection enabled
upload_assets upload-asset configured
unlock lock-for-agent enabled
<other safe-job name> That job exists in safe-outputs.jobs

Example

safe-outputs:
  create-issue:
  jobs:
    post-process:
      needs: safe_outputs          # ✅ runs after safe_outputs; survives recompile
      steps:
        - run: echo "post-processing"

    bad-job:
      needs: conclusion            # ❌ compile error: unknown needs target "conclusion"

    also-bad:
      needs: safe-outputs          # ✅ accepted and automatically normalized to "safe_outputs"

Self-dependencies and inter-job cycles are also caught at compile time.


⚡ Quickly spin up Copilot coding agent tasks from anywhere on your macOS or Windows machine with Raycast.

Add compile-time validation of needs: declarations on custom safe-output
jobs (defined under safe-outputs.jobs):

- Add SafeOutputsJobName, UploadAssetsJobName, ConclusionJobName, and
  UnlockJobName constants to pkg/constants/constants.go
- Add pkg/workflow/safe_jobs_needs_validation.go with:
  - validateSafeJobNeeds: validates each custom safe-job's needs: entries
    against jobs that will actually exist in the compiled workflow
  - computeValidSafeJobNeeds: computes valid job IDs from workflow config
    (threat detection, upload-asset, lock-for-agent, other safe-jobs)
  - detectSafeJobCycles: DFS-based cycle detection between custom safe-jobs
- Hook validateSafeJobNeeds into compiler.go:validateWorkflowData
- Add pkg/workflow/safe_jobs_needs_validation_test.go with comprehensive tests

Valid needs: targets: agent, safe_outputs, detection (when enabled),
upload_assets (when configured), unlock (when lock-for-agent enabled),
and other custom safe-job names. All other targets fail compilation with
a clear error listing valid alternatives. Cycles are also detected.

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/5fc1500e-7e47-4c1e-a6ce-b6bb9d465276

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix ordering of custom safe-output jobs relative to generated jobs feat: compile-time validation of needs: ordering on custom safe-output jobs Mar 29, 2026
Copilot AI requested a review from pelikhan March 29, 2026 21:18
@pelikhan pelikhan marked this pull request as ready for review March 29, 2026 21:43
Copilot AI review requested due to automatic review settings March 29, 2026 21:43
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds compile-time validation for safe-outputs.jobs[*].needs so custom safe-output jobs can declare ordering against generated jobs without edits being lost on recompile.

Changes:

  • Introduces job name constants for generated jobs (e.g., safe_outputs, upload_assets, unlock).
  • Adds validateSafeJobNeeds + helpers to validate needs targets and detect cycles among custom safe-jobs.
  • Adds unit tests covering valid/invalid needs targets and cycle detection.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
pkg/constants/constants.go Adds job-name constants intended to be used when reasoning about generated job IDs.
pkg/workflow/compiler.go Hooks new safe-job needs validation into compile-time workflow validation.
pkg/workflow/safe_jobs_needs_validation.go Implements validation of custom safe-job needs and DFS-based cycle detection.
pkg/workflow/safe_jobs_needs_validation_test.go Adds unit tests for needs validation, valid target computation, and cycle detection.
Comments suppressed due to low confidence (2)

pkg/workflow/safe_jobs_needs_validation.go:134

  • detectSafeJobCycles builds a normalized map keyed by NormalizeSafeOutputIdentifier(name), which will silently overwrite entries if two different custom job keys normalize to the same ID (e.g. my-job and my_job). That can hide real cycles and/or produce confusing cycle reports. Please add an explicit collision check when populating normalized/originalNames and return a clear error indicating the conflicting job names after normalization.
	// Build normalized name mapping
	normalized := make(map[string]*SafeJobConfig, len(jobs))
	originalNames := make(map[string]string, len(jobs))
	for name, cfg := range jobs {
		n := stringutil.NormalizeSafeOutputIdentifier(name)
		normalized[n] = cfg
		originalNames[n] = name
	}

pkg/workflow/safe_jobs_needs_validation.go:86

  • computeValidSafeJobNeeds unconditionally adds safe_outputs whenever data.SafeOutputs != nil, but the compiler can skip generating the consolidated safe_outputs job when no safe output steps are added (see buildConsolidatedSafeOutputsJob: it returns nil when safeOutputStepNames is empty). In workflows that only configure safe-outputs.jobs (custom safe-jobs) and no other safe outputs, this will cause the validator to accept needs: safe_outputs even though the compiled workflow will not contain that job ID. Please make safe_outputs a valid target only when the consolidated job will actually be emitted (or alternatively ensure the compiler always emits a safe_outputs job, e.g. with a noop step, when safe-outputs is configured).
	// safe_outputs job is always present when safe-outputs is configured
	valid[string(constants.SafeOutputsJobName)] = true


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +34 to +41
name: "needs safe_outputs – always valid",
data: &WorkflowData{
SafeOutputs: &SafeOutputsConfig{
Jobs: map[string]*SafeJobConfig{
"packaging": {
Needs: []string{"safe_outputs"},
Steps: []any{map[string]any{"run": "echo hi"}},
},
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

This test assumes needs: safe_outputs is always valid whenever safe-outputs.jobs is present, but the compiler can omit the safe_outputs job entirely when there are no consolidated safe output steps (only custom safe-jobs configured). If safe_outputs is only conditionally generated, the validator + tests should reflect that (and/or the compiler should be updated to always emit safe_outputs when safe-outputs is configured).

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +52
normalizedJobName := stringutil.NormalizeSafeOutputIdentifier(originalName)
for _, need := range jobConfig.Needs {
normalizedNeed := stringutil.NormalizeSafeOutputIdentifier(need)
if !validIDs[normalizedNeed] {
return fmt.Errorf(
"safe-outputs.jobs.%s: unknown needs target %q\n\nValid dependency targets for custom safe-jobs are:\n%s\n\n"+
"Custom safe-jobs cannot depend on workflow control jobs such as 'conclusion' or 'activation'",
originalName,
need,
formatValidNeedsTargets(validIDs),
)
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

validateSafeJobNeeds normalizes each need (dash→underscore) for membership checks, but it does not normalize the stored value in jobConfig.Needs. As a result, a config like needs: safe-outputs or needs: other-job can pass validation (because it normalizes to safe_outputs / other_job) while the compiler will still render needs: safe-outputs / other-job into YAML, which will not match the actual job IDs (the compiler normalizes safe-job IDs to underscores). Consider either (1) rejecting non-canonical needs values with an error that tells the user to use the underscore form, or (2) rewriting jobConfig.Needs entries to the normalized form during validation so the rendered workflow is correct.

This issue also appears in the following locations of the same file:

  • line 84
  • line 127

Copilot uses AI. Check for mistakes.
@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review comments

…exist; normalize needs values

- Add consolidatedSafeOutputsJobWillExist helper to correctly predict
  whether the compiled workflow will include a safe_outputs job.
  The consolidated job is only emitted when at least one builtin
  safe-output handler, custom script, custom action, or user-provided
  step is configured — custom safe-jobs (safe-outputs.jobs) compile to
  separate jobs and do NOT trigger safe_outputs creation.

- Rewrite needs entries to their canonical underscore form during
  validation so the compiled YAML references the correct job IDs
  (e.g. "safe-outputs" → "safe_outputs").

- Update tests:
  - "needs safe_outputs" test now includes a builtin type (create-issue)
    so the consolidated job actually exists
  - Add negative test: safe_outputs invalid when only custom jobs present
  - Add TestValidateSafeJobNeeds_NeedsNormalization to verify in-place
    rewrite of dash-form needs values
  - Add TestConsolidatedSafeOutputsJobWillExist for the new helper
  - Update TestComputeValidSafeJobNeeds to reflect conditional safe_outputs

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ec1306c6-ecfc-4f24-8c77-57209e6ce8ef

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 29, 2026

@copilot review comments

Both review comments addressed in f649a17:

  1. safe_outputs conditionality: Added consolidatedSafeOutputsJobWillExist helper that checks whether the compiled workflow will actually include a safe_outputs job (only when builtin handlers, scripts, actions, or user-provided steps are configured — not when only custom jobs are defined). computeValidSafeJobNeeds now uses this to conditionally include safe_outputs as a valid target. Tests updated to verify both the positive (builtin type present) and negative (custom jobs only) cases.

  2. needs value normalization: validateSafeJobNeeds now rewrites each jobConfig.Needs[i] entry to its canonical underscore form in-place after validation passes (e.g. safe-outputssafe_outputs). Added TestValidateSafeJobNeeds_NeedsNormalization to verify the rewrite.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/graphql
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw GO111MODULE 64/bin/go git rev-�� --show-toplevel go /usr/bin/git -json GO111MODULE 64/bin/go git (http block)
  • https://api.github.com/repos/actions/ai-inference/git/ref/tags/v1
    • Triggering command: /usr/bin/gh gh api /repos/actions/ai-inference/git/ref/tags/v1 --jq .object.sha -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/actions/checkout/git/ref/tags/v3
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v3 --jq .object.sha -json GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE ache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/actions/checkout/git/ref/tags/v5
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha 9168654/b428/_pkGOINSECURE GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go env shUB/shoTRO_E1XaGOINSECURE GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE 9168654/b428/impGOPROXY (http block)
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha -json GO111MODULE /usr/bin/git GOINSECURE GOMOD GOMODCACHE git rev-�� --show-toplevel GOPROXY /usr/bin/git GOSUMDB GOWORK 64/bin/go git (http block)
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha --show-toplevel go /usr/bin/git -json GO111MODULE 64/bin/go git rev-�� --show-toplevel go 0/x64/bin/node -json GO111MODULE x_amd64/compile git (http block)
  • https://api.github.com/repos/actions/checkout/git/ref/tags/v6
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq .object.sha -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq .object.sha --show-toplevel sh /usr/bin/git &#34;prettier&#34; --chegit /opt/hostedtoolcrev-parse 64/bin/go git rev-�� --show-toplevel go /usr/bin/git -json GO111MODULE 64/bin/go git (http block)
  • https://api.github.com/repos/actions/github-script/git/ref/tags/v8
    • Triggering command: /usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v8 --jq .object.sha -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
    • Triggering command: /usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v8 --jq .object.sha --show-toplevel sh /usr/bin/git &#34;prettier&#34; --chegit /opt/hostedtoolcrev-parse 64/bin/go git rev-�� --show-toplevel go /usr/bin/git -json GO111MODULE 64/bin/go git (http block)
  • https://api.github.com/repos/actions/setup-go/git/ref/tags/v4
    • Triggering command: /usr/bin/gh gh api /repos/actions/setup-go/git/ref/tags/v4 --jq .object.sha -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/actions/setup-node/git/ref/tags/v4
    • Triggering command: /usr/bin/gh gh api /repos/actions/setup-node/git/ref/tags/v4 --jq .object.sha -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/actions/upload-artifact/git/ref/tags/v4
    • Triggering command: /usr/bin/gh gh api /repos/actions/upload-artifact/git/ref/tags/v4 --jq .object.sha -json GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/github/gh-aw-actions/git/ref/tags/v0.1.2
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw-actions/git/ref/tags/v0.1.2 --jq .object.sha -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE /opt/hostedtoolcache/go/1.25.0/x64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/github/gh-aw-actions/git/ref/tags/v1.0.0
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw-actions/git/ref/tags/v1.0.0 --jq .object.sha -json GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/github/gh-aw-actions/git/ref/tags/v1.2.3
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw-actions/git/ref/tags/v1.2.3 --jq .object.sha -json GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go env -json GO111MODULE 64/bin/go GOINSECURE GOMOD GOMODCACHE go (http block)
  • https://api.github.com/repos/github/gh-aw/git/ref/tags/v0.47.4
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw/git/ref/tags/v0.47.4 --jq .object.sha --show-toplevel GOPROXY /usr/bin/git GOSUMDB GOWORK 64/bin/go git rev-�� --show-toplevel erignore /usr/bin/git e=false GO111MODULE 64/bin/go git (http block)
  • https://api.github.com/repos/github/gh-aw/git/ref/tags/v1.0.0
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw/git/ref/tags/v1.0.0 --jq .object.sha GOSUMDB GOWORK 64/bin/go GOINSECURE GOMOD GOMODCACHE ache/go/1.25.0/xGO111MODULE env 9168654/b400/_pkGOINSECURE GO111MODULE 64/bin/go GOINSECURE bagabas/go-udiffenv GOMODCACHE go (http block)
  • https://api.github.com/repos/nonexistent/action/git/ref/tags/v999.999.999
    • Triggering command: /usr/bin/gh gh api /repos/nonexistent/action/git/ref/tags/v999.999.999 --jq .object.sha GOSUMDB GOWORK 64/bin/go GOINSECURE GOMOD GOMODCACHE ache/go/1.25.0/xGO111MODULE env 9168654/b407/_pkGOINSECURE GO111MODULE 64/bin/go GOINSECURE b/gh-aw/pkg/envuenv GOMODCACHE go (http block)

If you need me to access, download, or install something from one of these locations, you can either:

@pelikhan pelikhan merged commit b92b2e4 into main Mar 29, 2026
84 checks passed
@pelikhan pelikhan deleted the copilot/fix-custom-safe-output-ordering branch March 29, 2026 23:48
github-actions bot added a commit that referenced this pull request Mar 30, 2026
Add needs: property to the Job Properties table and a new "Job Ordering"
section in the Custom Safe Outputs reference, documenting the
compile-time validation, valid targets, and dash-to-underscore
normalization introduced in #23486.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

Custom safe-output jobs cannot declare needs / ordering relative to generated jobs (e.g. safe_outputs)

3 participants