Skip to content

Preserve job-level permission shorthands in custom and safe jobs#47471

Merged
pelikhan merged 6 commits into
mainfrom
copilot/github-47418-fix-permissions-block
Jul 23, 2026
Merged

Preserve job-level permission shorthands in custom and safe jobs#47471
pelikhan merged 6 commits into
mainfrom
copilot/github-47418-fix-permissions-block

Conversation

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Custom job permissions were only reliably preserved in object form. Shorthand values accepted by the workflow schema, such as read-all, were being dropped during compilation for both top-level custom jobs and safe-outputs.jobs.

  • Permission parsing

    • Route top-level custom job permissions through the shared permissions parser instead of only handling map values.
    • This preserves both object-form permissions and shorthand forms already supported elsewhere in the compiler.
  • Safe-job permission handling

    • Preserve the original safe-outputs.jobs.<name>.permissions value and render it through the same shared parser.
    • This fixes shorthand permissions for safe jobs without changing existing object-form behavior.
  • Integration coverage

    • Add end-to-end workflow compilation tests for:
      • top-level custom jobs with object-form permissions
      • top-level custom jobs with shorthand permissions
      • safe jobs with object-form permissions
      • safe jobs with shorthand permissions

Example:

jobs:
  custom_job:
    runs-on: ubuntu-latest
    permissions: read-all
    steps:
      - run: echo hi

safe-outputs:
  jobs:
    publish:
      runs-on: ubuntu-latest
      permissions: read-all
      steps:
        - run: echo publish

After this change, those job-level permissions are preserved in the compiled workflow instead of being omitted.


Run URL: https://github.com/github/gh-aw/actions/runs/29984131428

Generated by 👨‍🍳 PR Sous Chef · gpt54 16.7 AIC · ⌖ 9.04 AIC · ⊞ 7K ·
Comment /souschef to run again

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix permissions block support in custom jobs Preserve job-level permission shorthands in custom and safe jobs Jul 23, 2026
Copilot AI requested a review from pelikhan July 23, 2026 05:41
@pelikhan
pelikhan marked this pull request as ready for review July 23, 2026 05:43
Copilot AI review requested due to automatic review settings July 23, 2026 05:43
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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

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

Preserves job-level permission shorthands during workflow compilation.

Changes:

  • Uses the shared permissions parser for custom and safe jobs.
  • Retains raw safe-job permission values.
  • Adds integration coverage for object and shorthand forms.
Show a summary per file
File Description
pkg/workflow/compiler_custom_jobs.go Parses custom-job permissions uniformly.
pkg/workflow/safe_jobs.go Preserves and renders raw safe-job permissions.
pkg/workflow/custom_job_permissions_integration_test.go Tests permission compilation forms.

Review details

Tip

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

  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Medium

if err != nil {
return fmt.Errorf("failed to convert permissions to YAML for job '%s': %w", jobName, err)
}
formattedPerms := NewPermissionsParserFromValue(permissions).ToPermissions().RenderToYAML()
Comment thread pkg/workflow/safe_jobs.go
if len(jobConfig.Permissions) > 0 {
// Build Permissions struct from map
if jobConfig.RawPermissions != nil {
job.Permissions = NewPermissionsParserFromValue(jobConfig.RawPermissions).ToPermissions().RenderToYAML()

@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 /codebase-design — one actionable finding; no blocking issues.

📋 Key Themes & Highlights

Finding

  • Dead code in safe_jobs.go: The else if len(jobConfig.Permissions) > 0 branch (lines 347–352) is unreachable because RawPermissions is always set whenever permissions is present in the job config. The old map-building path should be removed.

Positive Highlights

  • ✅ Clean use of the shared NewPermissionsParserFromValue path — consistent with how the rest of the compiler handles permissions
  • ✅ Good integration test coverage: all four cases (object/shorthand × custom/safe) are tested
  • ✅ The fix in compiler_custom_jobs.go is minimal and correct
  • RawPermissions any yaml:"-"`` correctly avoids double-serialisation

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 21 AIC · ⌖ 4.65 AIC · ⊞ 6.7K
Comment /matt to run again

Comment thread pkg/workflow/safe_jobs.go
// Build Permissions struct from map
if jobConfig.RawPermissions != nil {
job.Permissions = NewPermissionsParserFromValue(jobConfig.RawPermissions).ToPermissions().RenderToYAML()
} else if len(jobConfig.Permissions) > 0 {

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.

[/codebase-design] Dead code: the else if len(jobConfig.Permissions) > 0 branch (lines 347–352) is unreachable — RawPermissions is always set alongside Permissions when any permissions key exists (see parseSafeJobsConfig lines 116–126).

This silently makes the old map-building path a no-op; NewPermissionsParserFromValue already handles both object and shorthand forms. Safe to delete the else if block entirely.

💡 Suggested fix
if jobConfig.RawPermissions != nil {
	job.Permissions = NewPermissionsParserFromValue(jobConfig.RawPermissions).ToPermissions().RenderToYAML()
}

The else if block duplicates logic already covered by NewPermissionsParserFromValue and will never execute.

@copilot please address this.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

⚠️ Test Quality Score: 75/100 — Acceptable

Analyzed 2 test(s): 2 design, 0 implementation, 0 violation(s).

📊 Metrics (2 tests)
Metric Value
Analyzed 2 (Go: 2, JS: 0)
✅ Design 2 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 1 (50%)
Duplicate clusters 0
Inflation ⚠️ YES (8.4:1)
🚨 Violations 0
Test File Classification Issues
TestCustomJobPermissionsIntegration pkg/workflow/custom_job_permissions_integration_test.go:34 Design test (behavioral contract) High inflation (126 LOC test : 15 LOC prod)
TestSafeJobPermissionsIntegration pkg/workflow/custom_job_permissions_integration_test.go:79 Design test (behavioral contract) Limited edge case coverage (no error paths)
⚠️ Test Inflation Warning

Test:Production Ratio: 8.4:1 (threshold: 2:1)

  • Test file: 126 lines
  • Production changes: ~15 net lines (2 in compiler_custom_jobs.go + 13 in safe_jobs.go)

Impact: Integration tests naturally have larger setup/teardown overhead, but this ratio is higher than typical. The compileWorkflowAndReadLock() helper adds ~14 lines of test infrastructure per test function.

Recommendation: If this pattern repeats, consider extracting the helper to a shared test utilities package.

Verdict

Passed. 0% implementation tests (threshold: 30%).

Strengths:

  • ✓ Both tests marked with correct //go:build integration tag
  • ✓ 100% design tests verify user-visible behavior (permissions compilation)
  • ✓ No mock library violations or forbidden patterns
  • ✓ Table-driven structure enables multiple scenarios per test
  • ✓ Clear behavioral contracts: permissions formats (object + shorthand) preserved in compiled output

Considerations:

  • ⚠️ High test inflation (8.4:1 ratio) — primarily due to integration test setup
  • ⚠️ Limited edge case coverage (no invalid format or missing permission tests)

🧪 Test quality analysis by Test Quality Sentinel · haiku45 14.2 AIC · ⌖ 7.8 AIC · ⊞ 7.1K ·
Comment /review to run again

@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: 75/100. 0% implementation tests (threshold: 30%). All tests are design-focused, verifying user-visible behavior for custom and safe job permission shorthands.

@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.

One medium-severity issue to address

The fix is correct for the bug it targets. One maintainability issue needs cleanup before this is clean.

Details

Dead branch (medium): The else if len(jobConfig.Permissions) > 0 path in buildSafeJobs is unreachable because RawPermissions is always set when permissions exists. The Permissions map[string]string field is populated but its rendering path is permanently bypassed. See inline comment.

Silent failure on unknown shorthands (low / by-design): NewPermissionsParserFromValue marks any string as isShorthand=true, but ToPermissions() returns an empty Permissions (no output) for unrecognized strings via the default case. This is consistent with the old behaviour (old code only handled map[string]any and also silently dropped unrecognized strings), so this is not a regression — but worth knowing.

Integration test coverage is good for the happy paths. No issues with the test helpers.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 37.9 AIC · ⌖ 4.66 AIC · ⊞ 5.7K
Comment /review to run again

Comment thread pkg/workflow/safe_jobs.go
// Build Permissions struct from map
if jobConfig.RawPermissions != nil {
job.Permissions = NewPermissionsParserFromValue(jobConfig.RawPermissions).ToPermissions().RenderToYAML()
} else if len(jobConfig.Permissions) > 0 {

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.

Dead else if branch — safeJob.Permissions map is populated but never used for rendering.

The else if len(jobConfig.Permissions) > 0 branch at line 347 is unreachable: RawPermissions is always set unconditionally at line 117 whenever permissions key exists, so the if jobConfig.RawPermissions != nil guard always fires first.

💡 Details and suggested fix

The safeJob.Permissions map[string]string is still parsed and stored (lines 119–124) but never used for rendering. This creates a misleading dual-path: future maintainers may trust that field as the canonical representation and produce wrong output silently.

Options:

  1. Remove the else if block and the safeJob.Permissions map-population loop entirely.
  2. Or keep it with an explicit comment: // Unreachable: RawPermissions is always set when permissions key exists.
  3. Or remove Permissions map[string]string from SafeJobConfig entirely if it has no other readers.

@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.

Review: Preserve job-level permission shorthands

The changes correctly fix the dropped permission shorthands for both custom jobs and safe jobs. The approach — routing permissions through NewPermissionsParserFromValue instead of the type-asserted map path — is consistent with how permissions are handled elsewhere in the compiler.

One non-blocking observation: In safe_jobs.go, the else if len(jobConfig.Permissions) > 0 branch (lines 347–352) is now dead code. RawPermissions is always set alongside Permissions in parseSafeJobsConfig, so the map-based fallback path can never be reached. It causes no harm but could be cleaned up to avoid confusion.

Test coverage via the new integration tests is thorough and well-structured. No blocking issues found.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 20.1 AIC · ⌖ 5.18 AIC · ⊞ 5K

…tom and safe jobs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (141 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/47471-preserve-job-level-permission-shorthands.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI couldn't infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-47471: Unify Permission Handling for Custom and Safe Jobs via Shared Parser

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 0042-use-postgresql.md for PR #42).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 41.4 AIC · ⌖ 13 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions

Copy link
Copy Markdown
Contributor

Hey @pelikhan 👋 — thanks for working on this with the coding agent! This PR looks great. ✨

The fix for preserving job-level permission shorthands (read-all, etc.) in both custom and safe jobs is comprehensive and well-tested. The implementation:

  • Routes permissions through the shared parser to handle both object and shorthand forms
  • Preserves original values in RawPermissions for safe jobs
  • Includes solid integration test coverage with 4 distinct test cases
  • Makes a minimal, focused change to the compiler

The PR is ready for review and merge. Nice work! 🚀

Generated by ✅ Contribution Check · sonnet46 73.6 AIC · ⌖ 7.67 AIC · ⊞ 6.2K ·

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address the unresolved review feedback below, update the ADR link in the PR body if needed, and rerun checks once the branch is up to date.

Unresolved review feedback:

Generated by 👨‍🍳 PR Sous Chef · gpt54 16.7 AIC · ⌖ 9.04 AIC · ⊞ 7K ·
Comment /souschef to run again

Copilot AI and others added 2 commits July 23, 2026 06:25
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
An explicit `permissions: {}` block is semantically distinct from an
omitted permissions field: it denies all token permissions rather than
inheriting from the workflow level.  Previously the shared permissions
parser converted an empty map to an ordinary empty `Permissions` whose
renderer returned `""`, silently dropping the deny-all intent.

Changes:
- Add `isExplicitEmpty bool` to `PermissionsParser` to track when an
  explicit empty map was the input.
- Set the flag in `NewPermissionsParserFromValue` when the map is
  non-nil but contains no keys.
- Return `NewPermissionsEmpty()` from `ToPermissions()` when the flag
  is set, so `RenderToYAML()` produces `"permissions: {}"`.
- Add unit test case in `TestPermissionsParser_ToPermissions` covering
  the empty-map input.
- Add integration test cases for `permissions: {}` in both
  `TestCustomJobPermissionsIntegration` and
  `TestSafeJobPermissionsIntegration`.
- Refactor `TestSafeJobPermissionsIntegration` to use per-case
  `notExpectedParts` instead of a hardcoded blanket assertion, allowing
  the new explicit-empty case to coexist cleanly.

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address the unresolved review feedback below, update the ADR link in the PR body if needed, and rerun checks once the branch is up to date.

Unresolved rev...

Fixed in 6d656bc.

Both issues raised by the reviewer are addressed:

  1. permissions: {} dropped for custom jobs (compiler_custom_jobs.go:189): Added isExplicitEmpty bool to PermissionsParser. When NewPermissionsParserFromValue receives an empty map it sets this flag and ToPermissions() returns NewPermissionsEmpty(), which renders as "permissions: {}". The existing if formattedPerms != "" guard then correctly preserves it.

  2. permissions: {} dropped for safe jobs (safe_jobs.go:346): Same shared-parser fix applies — the non-nil empty map path now produces "permissions: {}" instead of "".

Integration test cases for permissions: {} were added to both TestCustomJobPermissionsIntegration and TestSafeJobPermissionsIntegration. A unit test case was added to TestPermissionsParser_ToPermissions as well.

Copilot AI requested a review from gh-aw-bot July 23, 2026 06:37
@pelikhan
pelikhan merged commit a4b83c6 into main Jul 23, 2026
23 checks passed
@pelikhan
pelikhan deleted the copilot/github-47418-fix-permissions-block branch July 23, 2026 06:49
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.83.1

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.

Bug: Permissions block is not supported in custom jobs

4 participants