Skip to content

Commit 58dc289

Browse files
authored
Fix write-to-read codemod incorrectly converting id-token and copilot-requests permissions (#25604)
1 parent 40260ba commit 58dc289

2 files changed

Lines changed: 143 additions & 1 deletion

File tree

pkg/cli/codemod_permissions_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,124 @@ This workflow needs permissions.`
428428
assert.Contains(t, result, "# Test Workflow")
429429
assert.Contains(t, result, "This workflow needs permissions.")
430430
}
431+
432+
func TestWritePermissionsCodemod_SkipsIdToken(t *testing.T) {
433+
codemod := getMigrateWritePermissionsToReadCodemod()
434+
435+
content := `---
436+
on: workflow_dispatch
437+
permissions:
438+
contents: read
439+
id-token: write
440+
---
441+
442+
# Test`
443+
444+
frontmatter := map[string]any{
445+
"on": "workflow_dispatch",
446+
"permissions": map[string]any{
447+
"contents": "read",
448+
"id-token": "write",
449+
},
450+
}
451+
452+
result, applied, err := codemod.Apply(content, frontmatter)
453+
454+
require.NoError(t, err, "codemod should not return an error")
455+
assert.False(t, applied, "Should not be applied when only write-only permissions have write")
456+
assert.Equal(t, content, result, "codemod result should be unchanged when only write-only permissions have write")
457+
assert.Contains(t, result, "id-token: write", "id-token permission should remain write after codemod")
458+
assert.NotContains(t, result, "id-token: read", "id-token should never be converted to read")
459+
}
460+
461+
func TestWritePermissionsCodemod_SkipsCopilotRequests(t *testing.T) {
462+
codemod := getMigrateWritePermissionsToReadCodemod()
463+
464+
content := `---
465+
on: workflow_dispatch
466+
permissions:
467+
contents: read
468+
copilot-requests: write
469+
---
470+
471+
# Test`
472+
473+
frontmatter := map[string]any{
474+
"on": "workflow_dispatch",
475+
"permissions": map[string]any{
476+
"contents": "read",
477+
"copilot-requests": "write",
478+
},
479+
}
480+
481+
result, applied, err := codemod.Apply(content, frontmatter)
482+
483+
require.NoError(t, err, "codemod should not return an error")
484+
assert.False(t, applied, "Should not be applied when only write-only permissions have write")
485+
assert.Equal(t, content, result, "codemod result should be unchanged when only write-only permissions have write")
486+
assert.Contains(t, result, "copilot-requests: write", "copilot-requests permission should remain write after codemod")
487+
assert.NotContains(t, result, "copilot-requests: read", "copilot-requests should never be converted to read")
488+
}
489+
490+
func TestWritePermissionsCodemod_MixedWithIdToken(t *testing.T) {
491+
codemod := getMigrateWritePermissionsToReadCodemod()
492+
493+
content := `---
494+
on: workflow_dispatch
495+
permissions:
496+
contents: write
497+
issues: write
498+
id-token: write
499+
---
500+
501+
# Test`
502+
503+
frontmatter := map[string]any{
504+
"on": "workflow_dispatch",
505+
"permissions": map[string]any{
506+
"contents": "write",
507+
"issues": "write",
508+
"id-token": "write",
509+
},
510+
}
511+
512+
result, applied, err := codemod.Apply(content, frontmatter)
513+
514+
require.NoError(t, err, "codemod should not return an error")
515+
assert.True(t, applied, "codemod should be applied when non-write-only permissions have write")
516+
assert.Contains(t, result, "contents: read", "contents permission should be downgraded from write to read")
517+
assert.Contains(t, result, "issues: read", "issues permission should be downgraded from write to read")
518+
// id-token must remain write — "read" is not a valid value for it
519+
assert.Contains(t, result, "id-token: write", "id-token permission should remain write after codemod")
520+
assert.NotContains(t, result, "id-token: read", "id-token should never be converted to read")
521+
}
522+
523+
func TestWritePermissionsCodemod_MixedWithCopilotRequests(t *testing.T) {
524+
codemod := getMigrateWritePermissionsToReadCodemod()
525+
526+
content := `---
527+
on: workflow_dispatch
528+
permissions:
529+
contents: write
530+
copilot-requests: write
531+
---
532+
533+
# Test`
534+
535+
frontmatter := map[string]any{
536+
"on": "workflow_dispatch",
537+
"permissions": map[string]any{
538+
"contents": "write",
539+
"copilot-requests": "write",
540+
},
541+
}
542+
543+
result, applied, err := codemod.Apply(content, frontmatter)
544+
545+
require.NoError(t, err, "codemod should not return an error")
546+
assert.True(t, applied, "codemod should be applied when non-write-only permissions have write")
547+
assert.Contains(t, result, "contents: read", "contents permission should be downgraded from write to read")
548+
// copilot-requests must remain write — "read" is not a valid value for it
549+
assert.Contains(t, result, "copilot-requests: write", "copilot-requests permission should remain write after codemod")
550+
assert.NotContains(t, result, "copilot-requests: read", "copilot-requests should never be converted to read")
551+
}

pkg/cli/codemod_permissions_write.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import (
99

1010
var writePermissionsCodemodLog = logger.New("cli:codemod_permissions")
1111

12+
// writeOnlyPermissions are permission scopes that only accept "write" or "none" as valid values.
13+
// These must never be converted to "read" since "read" is not a valid value for them.
14+
var writeOnlyPermissions = map[string]bool{
15+
"id-token": true, // OIDC token: only write or none
16+
"copilot-requests": true, // Copilot authentication token: only write or none
17+
}
18+
1219
// getMigrateWritePermissionsToReadCodemod creates a codemod for converting write permissions to read
1320
func getMigrateWritePermissionsToReadCodemod() Codemod {
1421
return Codemod{
@@ -35,7 +42,12 @@ func getMigrateWritePermissionsToReadCodemod() Codemod {
3542

3643
// Handle map format
3744
if mapValue, ok := permissionsValue.(map[string]any); ok {
38-
for _, value := range mapValue {
45+
for key, value := range mapValue {
46+
// Skip write-only permissions (e.g. id-token, copilot-requests) since
47+
// "read" is not a valid value for them — they only accept "write" or "none"
48+
if writeOnlyPermissions[key] {
49+
continue
50+
}
3951
if strValue, ok := value.(string); ok && strValue == "write" {
4052
hasWritePermissions = true
4153
break
@@ -91,8 +103,17 @@ func getMigrateWritePermissionsToReadCodemod() Codemod {
91103
parts := strings.SplitN(line, ":", 2)
92104
if len(parts) >= 2 {
93105
key := parts[0]
106+
permKey := strings.TrimSpace(key)
94107
valueAndComment := parts[1]
95108

109+
// Skip write-only permissions (e.g. id-token, copilot-requests) since
110+
// "read" is not a valid value for them — they only accept "write" or "none"
111+
if writeOnlyPermissions[permKey] {
112+
result[i] = line
113+
writePermissionsCodemodLog.Printf("Skipping write-only permission %q on line %d", permKey, i+1)
114+
continue
115+
}
116+
96117
// Replace "write" with "read" in the value part
97118
newValueAndComment := strings.Replace(valueAndComment, " write", " read", 1)
98119
result[i] = fmt.Sprintf("%s:%s", key, newValueAndComment)

0 commit comments

Comments
 (0)