Skip to content

Make safe-job conclusion dependencies deterministic#40363

Merged
pelikhan merged 3 commits into
mainfrom
copilot/deep-report-sort-safejobnames
Jun 19, 2026
Merged

Make safe-job conclusion dependencies deterministic#40363
pelikhan merged 3 commits into
mainfrom
copilot/deep-report-sort-safejobnames

Conversation

Copilot AI commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

buildSafeJobs was deriving conclusion-job needs: entries from Go map iteration order, so workflows with multiple custom safe-jobs could compile to different .lock.yml output across runs. This change makes safe-job ordering stable and adds a regression test at the compiled workflow level.

  • Compiler behavior

    • Sort safe-outputs.jobs keys before building custom safe-jobs
    • Preserve the existing normalized job IDs while making the returned safe-job name list deterministic
    • Ensure the conclusion job receives custom safe-job dependencies in a stable order
  • Regression coverage

    • Add a focused workflow-compilation test with two safe-jobs whose source order differs from sorted order
    • Assert that the generated conclusion job lists normalized safe-job dependencies in sorted order
  • Illustrative change

    jobNames := make([]string, 0, len(data.SafeOutputs.Jobs))
    for jobName := range data.SafeOutputs.Jobs {
    	jobNames = append(jobNames, jobName)
    }
    sort.Strings(jobNames)
    
    for _, jobName := range jobNames {
    	jobConfig := data.SafeOutputs.Jobs[jobName]
    	// build job...
    }

@github-actions

Copy link
Copy Markdown
Contributor

Hey @copilot 👋 — great pick-up on the non-deterministic safeJobNames ordering in buildSafeJobs. Fixing the map-iteration order in safe_jobs.go will close a real compilation-idempotence gap and reduce spurious recompile PRs.

A couple of things to land before this is ready:

  • Add the sort — at pkg/workflow/safe_jobs.go around line 163, collect the keys from data.SafeOutputs.Jobs into safeJobNames, call sort.Strings(safeJobNames) (mirroring jobs.go:299-303), then range over the sorted slice instead of the map directly.
  • Add a round-trip test — the issue explicitly asks for a test with 2+ safe-jobs that asserts the compiled needs: list is stable across multiple calls. Without it, the fix can regress silently.

If you'd like a nudge, here's a ready-to-use prompt:

In pkg/workflow/safe_jobs.go, fix the non-deterministic ordering of safeJobNames:
1. After `var safeJobNames []string`, collect all keys from `data.SafeOutputs.Jobs` into the slice, then call `sort.Strings(safeJobNames)` before the loop that builds job configs (mirror the pattern at jobs.go:299-303).
2. Add or extend an existing round-trip test (e.g. in pkg/workflow/safe_jobs_test.go) that compiles a workflow with at least 2 safe-output jobs, calls buildSafeJobs twice, and asserts both results are byte-identical — locking in the deterministic ordering.

Generated by ✅ Contribution Check · 124.8 AIC · ⌖ 9.85 AIC · ⊞ 5.9K ·

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Sort safeJobNames for deterministic conclusion needs Make safe-job conclusion dependencies deterministic Jun 19, 2026
Copilot AI requested a review from pelikhan June 19, 2026 17:42
@pelikhan pelikhan marked this pull request as ready for review June 19, 2026 17:49
Copilot AI review requested due to automatic review settings June 19, 2026 17:49
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #40363 does not have the 'implementation' label (has_implementation_label=false) and has only 57 new lines in business logic directories (requires_adr_by_default_volume=false, threshold=100).

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This pull request makes compilation of custom safe-outputs.jobs deterministic by ensuring safe-job construction is based on a stable ordering, and adds a regression test to prevent .lock.yml output drift across runs.

Changes:

  • Sort safe-outputs.jobs keys before building custom safe-jobs to avoid Go map iteration nondeterminism.
  • Ensure the list of returned safe-job names (used for downstream dependency wiring) is deterministic.
  • Add a workflow-compilation regression test asserting the conclusion job’s needs ordering for multiple safe-jobs.
Show a summary per file
File Description
pkg/workflow/safe_jobs.go Sorts safe-job map keys prior to building jobs to make safe-job ordering stable.
pkg/workflow/safe_jobs_test.go Adds a compiled-workflow regression test validating deterministic conclusion.needs ordering for multiple safe-jobs.

Copilot's findings

Tip

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

  • Files reviewed: 2/2 changed files
  • Comments generated: 0

@github-actions github-actions Bot mentioned this pull request Jun 19, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /diagnose and /tdd — no blocking issues; two suggestions to improve robustness.

📋 Key Themes & Highlights

Key Themes

  • Sort target mismatch (edge case): The fix sorts original job names, but NormalizeSafeOutputIdentifier replaces both - and . with _. For names mixing . and _, the original-name sort order can differ from the normalized-name sort order, so safeJobNames (and the conclusion job's needs:) may still be non-deterministic in that narrow case. Sorting on normalized names would close this gap cleanly.

  • Test helper consistency: The regression test uses strings.Index for order comparisons; indexInNonCommentLines already exists in the test helpers for exactly this purpose.

Positive Highlights

  • ✅ Root cause correctly identified: Go map iteration non-determinism in buildSafeJobs
  • ✅ Fix is minimal and surgical — collect → sort → iterate is idiomatic Go
  • ✅ Regression test is end-to-end: declares jobs in reverse-alphabetical order, compiles, reads the lock file, and asserts the conclusion job's needs: is alphabetically sorted
  • +57/-1 lines; no API surface or schema changes

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 78.6 AIC · ⌖ 6.85 AIC · ⊞ 6.9K

Comment thread pkg/workflow/safe_jobs.go Outdated
for jobName := range data.SafeOutputs.Jobs {
jobNames = append(jobNames, jobName)
}
sort.Strings(jobNames)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnose] Sorting by original name may not preserve sort order after normalization for .-containing job names.

NormalizeSafeOutputIdentifier maps both - (0x2D) and . (0x2E) to _ (0x5F). Because - < . < _ in ASCII, the sorted order of original names can differ from the sorted order of their normalized counterparts — the conclusion job's needs: could still be non-deterministic for job names that include ..

💡 Concrete example and suggested fix

Given original keys a.z and a_a:

  • originals sorted: [a.z, a_a] (because . 0x2E < _ 0x5F)
  • normalized in that order: [a_z, a_a]not sorted (because a_z > a_a)

The safer approach is to sort on normalized names:

// collect normalized names and a lookup map
normalized := make([]string, 0, len(data.SafeOutputs.Jobs))
configByNorm := make(map[string]*SafeJobConfig, len(data.SafeOutputs.Jobs))
for jobName, jobConfig := range data.SafeOutputs.Jobs {
    n := stringutil.NormalizeSafeOutputIdentifier(jobName)
    normalized = append(normalized, n)
    configByNorm[n] = jobConfig
}
sort.Strings(normalized)

for _, normalizedJobName := range normalized {
    jobConfig := configByNorm[normalizedJobName]
    // ... rest of loop unchanged, drop the NormalizeSafeOutputIdentifier call
}

This is an edge case today—job names with . are unusual—but NormalizeSafeOutputIdentifier explicitly documents the executor-workflow.agent pattern, so it is a plausible future input.

zebraIdx := strings.Index(conclusionSection, "- zebra_job")
require.NotEqual(t, -1, alphaIdx, "conclusion job should depend on alpha_job")
require.NotEqual(t, -1, zebraIdx, "conclusion job should depend on zebra_job")
require.Less(t, alphaIdx, zebraIdx, "conclusion job should list safe-jobs in deterministic sorted order")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Order comparison uses strings.Index directly; the codebase has indexInNonCommentLines for exactly this purpose.

compiler_test_helpers_test.go defines indexInNonCommentLines to avoid false matches when a compiled YAML contains job names embedded in frontmatter comment lines. Using the helper here keeps the test consistent with the established pattern and guards against false-positive ordering assertions.

💡 Suggested change
- alphaIdx := strings.Index(conclusionSection, "- alpha_job")
- zebraIdx := strings.Index(conclusionSection, "- zebra_job")
+ alphaIdx := indexInNonCommentLines(conclusionSection, "- alpha_job")
+ zebraIdx := indexInNonCommentLines(conclusionSection, "- zebra_job")

The risk is low because the conclusion section is unlikely to have comment lines with job names, but it makes the intent explicit.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel — PR #40363

Score: 90/100 ✅ Excellent

One new test (TestCompileWorkflowConclusionNeedsSafeJobsDeterministically) validates that the compiled lock file's conclusion job lists safe-job dependencies in deterministic alphabetical order — the exact behavioral contract introduced by this PR. The test uses an adversarial input (zebra-job declared before alpha-job) to confirm sorting is enforced on output.

📊 Metrics
Metric Value
New test functions analyzed 1
Design tests (behavioral) 1 (100%)
Implementation tests 0 (0%)
Tests with error/edge coverage 1 (100%)
Assertion messages provided ✅ All require.* calls include context strings
Duplicate clusters 0
Build tag violations 0
Mock library violations 0
Test inflation (ratio > 2:1) ⚠️ 48 lines added in test / 9 in production = 5.3:1

Score components:

  • Behavioral coverage: 40/40 (100% design tests)
  • Error/edge case coverage: 30/30 (100% of tests include error paths + adversarial ordering)
  • Low duplication: 20/20 (no duplicate clusters)
  • Proportional growth: 0/10 (inflation penalty: 5.3:1 > 2:1 threshold)
📋 Per-Test Classification
Test Assertions Error Checks Design Invariant Value if Deleted Type
TestCompileWorkflowConclusionNeedsSafeJobsDeterministically 7 3 Behavioral contract: compiled conclusion job lists safe-job dependencies in sorted alphabetical order High — non-deterministic ordering regression goes undetected ✅ Design test

Key assertions:

  1. require.NoErroros.WriteFile, CompileWorkflow, os.ReadFile (real I/O, no mocks)
  2. require.NotEmpty — conclusion section exists in compiled output
  3. require.NotEqual(-1, alphaIdx)alpha_job appears in conclusion dependencies
  4. require.NotEqual(-1, zebraIdx)zebra_job appears in conclusion dependencies
  5. require.Less(alphaIdx, zebraIdx)alpha_job precedes zebra_job (sorted order enforced)
i️ Inflation Note

safe_jobs_test.go added 48 lines vs 9 lines in safe_jobs.go (ratio 5.3:1, exceeds the 2:1 threshold — hence the 10-point deduction). The bulk of the test lines (~20) are an inline workflow YAML string necessary for end-to-end compilation. This is expected overhead for compiler integration tests written as unit tests, and represents genuine test thoroughness rather than artificial inflation.

Verdict:PASS — 0% implementation tests (threshold: 30%), no coding-guideline violations detected. Build tag //go:build !integration is present; no mock libraries used. The test directly and precisely verifies the deterministic ordering invariant this PR introduces.

References:

🧪 Test quality analysis by Test Quality Sentinel · 65.6 AIC · ⌖ 11.6 AIC · ⊞ 8.3K ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 90/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). The single new test directly verifies the deterministic ordering behavioral contract introduced by this PR, using adversarial input and appropriate assertion messages.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Approved — fix is correct, two non-blocking observations

The root cause (Go map iteration order flowing into the conclusion job's needs: list) is correctly fixed by collecting keys, sorting, then re-iterating. The returned safeJobNames slice is deterministic. The regression test covers the primary scenario.

Findings summary

Non-blocking (2):

  • sort.Strings sorts source key names, not normalized output names — in edge cases involving names that mix hyphens and digits the emitted needs: order may differ from sorted-normalized-name order. Common all-hyphen names are unaffected. (See inline comment on safe_jobs.go:169.)
  • Ordering assertion uses strings.Index rather than the package's existing indexInNonCommentLines helper, which is the established pattern for order checks on compiled YAML. (See inline comment on safe_jobs_test.go:286.)

Dropped:

  • sort.Strings vs slices.Sortsort.Strings is the existing pattern throughout the workflow package.
  • Normalization collision detection — pre-existing concern, out of scope for this fix.
  • Test exercising only two jobs — adequate for an ordering regression test.

🔎 Code quality review by PR Code Quality Reviewer · 81.7 AIC · ⌖ 7.3 AIC · ⊞ 5.1K

Comment thread pkg/workflow/safe_jobs.go Outdated
for jobName := range data.SafeOutputs.Jobs {
jobNames = append(jobNames, jobName)
}
sort.Strings(jobNames)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sort is on source key names, not normalized output names — in edge cases these orders can diverge, making the emitted needs: list stable but not sorted-by-normalized-name.

💡 Details

The sort compares the original frontmatter key strings (e.g. a-job, a0job). After normalization hyphens and dots become underscores, but digits stay. ASCII ordering: '-' (0x2D) < '0' (0x30) < '_' (0x5F), so:

  • source order: a-job < a0job (hyphen sorts before digit)
  • normalized order: a0job < a_job (digit sorts before underscore)

...the conclusion job's needs: list is stable but its order won't match alphabetical-by-normalized-name. For all-hyphen-separated names (the common case) there is no divergence. To guarantee normalized-order stability, sort after normalizing:

type safeJobEntry struct {
    normalizedName string
    config         *SafeJobConfig
}
entries := make([]safeJobEntry, 0, len(data.SafeOutputs.Jobs))
for rawName, cfg := range data.SafeOutputs.Jobs {
    entries = append(entries, safeJobEntry{stringutil.NormalizeSafeOutputIdentifier(rawName), cfg})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].normalizedName < entries[j].normalizedName })

Non-blocking: the current approach is deterministic and correct for typical naming conventions.

Comment thread pkg/workflow/safe_jobs_test.go Outdated
conclusionSection := extractJobSection(string(compiled), "conclusion")
require.NotEmpty(t, conclusionSection)

alphaIdx := strings.Index(conclusionSection, "- alpha_job")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Use indexInNonCommentLines instead of strings.Index for ordering assertions — the codebase provides this helper specifically for compiled-YAML ordering checks, and it guards against future changes that embed extra content as YAML comments.

💡 Suggested fix
// Instead of:
alphaIdx := strings.Index(conclusionSection, "- alpha_job")
zebraIdx := strings.Index(conclusionSection, "- zebra_job")

// Use the existing non-comment-aware helper:
alphaIdx := indexInNonCommentLines(conclusionSection, "- alpha_job")
zebraIdx := indexInNonCommentLines(conclusionSection, "- zebra_job")

indexInNonCommentLines (defined in compiler_test_helpers_test.go) is already used in the same package for exactly this pattern. While comment lines in the compiled lock YAML would typically use the original hyphenated job names (not the underscore-normalized forms searched here), using the consistent helper makes the assertion robust against future emitter changes and mirrors established test idioms in this file.

Non-blocking.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

… test

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

Done — both issues from the github-actions[bot] review are addressed in commit fix: sort safe-jobs by normalized name; use indexInNonCommentLines in test:

  1. Sort by normalized name: Changed from sort.Strings(rawNames) to collecting safeJobEntry{normalizedName, config} structs and sorting with sort.Slice on normalizedName. This guarantees the conclusion job's needs: list is ordered by the actual YAML key names, even when raw names contain . or - that normalize to _.

  2. indexInNonCommentLines in test: Replaced both strings.Index calls with indexInNonCommentLines to match the established pattern for compiled-YAML ordering assertions.

@pelikhan pelikhan merged commit 7be9b99 into main Jun 19, 2026
29 checks passed
@pelikhan pelikhan deleted the copilot/deep-report-sort-safejobnames branch June 19, 2026 18:44
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.

3 participants