detached 9a7971c

feat(executor): print the GITHUB_TOKEN permissions GitHub grants

The group in Set up job was printed only without a permissions key. It is now
worked out from the job's effective permissions (the job's replace the
workflow's) with the rules recorded in ermite-conformance's permissions-display
scenario: names as GitHub prints them (PullRequests, SecurityEvents), sorted,
Metadata always read, none scopes and id-token not listed, {} lists Metadata
only, read-all and write-all list every scope (models and vulnerability-alerts
stay read under write-all, copilot-requests only under write-all).
commit
9a7971c8fbd28d8403deb92f88ce489f38bb678e
author
brice · · 2026-10-12 23:43 UTC
parent
0cc0cf3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +235 -39
M internal/executor/corpus_test.go +71 -2
@@ -23,11 +23,20 @@ import (
2323 // they skip without it.
2424
2525 func conformanceDir(t *testing.T) string {
26+ t.Helper()
27+ return conformanceDirFor(t, "")
28+}
29+
30+// conformanceDirFor is conformanceDir for a checkout that has the recording of
31+// scenario (any checkout with a corpus when scenario is empty): the scenario
32+// may be on a branch that is not merged yet.
33+func conformanceDirFor(t *testing.T, scenario string) string {
2634 t.Helper()
2735 cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")}
2836 wd, _ := os.Getwd()
2937 for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {
3038 cands = append(cands,
39+ filepath.Join(d, "ermite-conformance", ".worktrees", "permissions"),
3140 filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"),
3241 filepath.Join(d, "ermite-conformance"))
3342 }
@@ -35,9 +44,15 @@ func conformanceDir(t *testing.T) string {
3544 if c == "" {
3645 continue
3746 }
38- if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() {
39- return c
47+ if st, err := os.Stat(filepath.Join(c, "corpus")); err != nil || !st.IsDir() {
48+ continue
4049 }
50+ if scenario != "" {
51+ if _, err := os.Stat(filepath.Join(c, "recordings", scenario+".json")); err != nil {
52+ continue
53+ }
54+ }
55+ return c
4156 }
4257 t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)")
4358 return ""
@@ -163,6 +178,7 @@ func readFile(t *testing.T, p string) string {
163178 type recording struct {
164179 Pushes []struct {
165180 Runs []struct {
181+ Workflow string `json:"workflow"`
166182 Conclusion string `json:"conclusion"`
167183 Jobs []struct {
168184 Name string `json:"name"`
@@ -324,3 +340,56 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) {
324340 t.Fatalf("%+v %v", res, rec.names())
325341 }
326342 }
343+
344+// TestCorpusPermissionsDisplay runs every job of every workflow of the
345+// permissions-display scenario and compares the GITHUB_TOKEN Permissions group
346+// of Set up job with the recording. The scenario has one workflow file per
347+// case, so compareWithRecording does not apply. Workflows GitHub rejects have
348+// no jobs in the recording and are not run.
349+func TestCorpusPermissionsDisplay(t *testing.T) {
350+ needTools(t, "bash")
351+ dir := conformanceDirFor(t, "permissions-display")
352+ var rec recording
353+ if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil {
354+ t.Fatal(err)
355+ }
356+ n := 0
357+ for _, run := range rec.Pushes[0].Runs {
358+ if len(run.Jobs) == 0 {
359+ continue
360+ }
361+ file := run.Workflow
362+ yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file)))
363+ for _, jr := range run.Jobs {
364+ t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) {
365+ want := permissionsGroup(jr.Steps[0].Log)
366+ if want == nil {
367+ t.Fatalf("no group in the recording: %v", jr.Steps[0].Log)
368+ }
369+ got, _ := runLocal(t, yaml, jr.Name, nil)
370+ if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") {
371+ t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n"))
372+ }
373+ })
374+ n++
375+ }
376+ }
377+ if n < 20 {
378+ t.Fatalf("only %d jobs in the recording", n)
379+ }
380+}
381+
382+// permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group.
383+func permissionsGroup(log []string) []string {
384+ for i, l := range log {
385+ if l != "##[group]GITHUB_TOKEN Permissions" {
386+ continue
387+ }
388+ for k := i + 1; k < len(log); k++ {
389+ if log[k] == "##[endgroup]" {
390+ return log[i+1 : k]
391+ }
392+ }
393+ }
394+ return nil
395+}
@@ -23,11 +23,20 @@ import (
23 // they skip without it.23 // they skip without it.
24 24
25 func conformanceDir(t *testing.T) string {25 func conformanceDir(t *testing.T) string {
26+ t.Helper()
27+ return conformanceDirFor(t, "")
28+}
29+
30+// conformanceDirFor is conformanceDir for a checkout that has the recording of
31+// scenario (any checkout with a corpus when scenario is empty): the scenario
32+// may be on a branch that is not merged yet.
33+func conformanceDirFor(t *testing.T, scenario string) string {
26 t.Helper()34 t.Helper()
27 cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")}35 cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")}
28 wd, _ := os.Getwd()36 wd, _ := os.Getwd()
29 for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {37 for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {
30 cands = append(cands,38 cands = append(cands,
39+ filepath.Join(d, "ermite-conformance", ".worktrees", "permissions"),
31 filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"),40 filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"),
32 filepath.Join(d, "ermite-conformance"))41 filepath.Join(d, "ermite-conformance"))
33 }42 }
@@ -35,9 +44,15 @@ func conformanceDir(t *testing.T) string {
35 if c == "" {44 if c == "" {
36 continue45 continue
37 }46 }
38- if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() {47+ if st, err := os.Stat(filepath.Join(c, "corpus")); err != nil || !st.IsDir() {
39- return c48+ continue
40 }49 }
50+ if scenario != "" {
51+ if _, err := os.Stat(filepath.Join(c, "recordings", scenario+".json")); err != nil {
52+ continue
53+ }
54+ }
55+ return c
41 }56 }
42 t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)")57 t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)")
43 return ""58 return ""
@@ -163,6 +178,7 @@ func readFile(t *testing.T, p string) string {
163 type recording struct {178 type recording struct {
164 Pushes []struct {179 Pushes []struct {
165 Runs []struct {180 Runs []struct {
181+ Workflow string `json:"workflow"`
166 Conclusion string `json:"conclusion"`182 Conclusion string `json:"conclusion"`
167 Jobs []struct {183 Jobs []struct {
168 Name string `json:"name"`184 Name string `json:"name"`
@@ -324,3 +340,56 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) {
324 t.Fatalf("%+v %v", res, rec.names())340 t.Fatalf("%+v %v", res, rec.names())
325 }341 }
326 }342 }
343+
344+// TestCorpusPermissionsDisplay runs every job of every workflow of the
345+// permissions-display scenario and compares the GITHUB_TOKEN Permissions group
346+// of Set up job with the recording. The scenario has one workflow file per
347+// case, so compareWithRecording does not apply. Workflows GitHub rejects have
348+// no jobs in the recording and are not run.
349+func TestCorpusPermissionsDisplay(t *testing.T) {
350+ needTools(t, "bash")
351+ dir := conformanceDirFor(t, "permissions-display")
352+ var rec recording
353+ if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil {
354+ t.Fatal(err)
355+ }
356+ n := 0
357+ for _, run := range rec.Pushes[0].Runs {
358+ if len(run.Jobs) == 0 {
359+ continue
360+ }
361+ file := run.Workflow
362+ yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file)))
363+ for _, jr := range run.Jobs {
364+ t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) {
365+ want := permissionsGroup(jr.Steps[0].Log)
366+ if want == nil {
367+ t.Fatalf("no group in the recording: %v", jr.Steps[0].Log)
368+ }
369+ got, _ := runLocal(t, yaml, jr.Name, nil)
370+ if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") {
371+ t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n"))
372+ }
373+ })
374+ n++
375+ }
376+ }
377+ if n < 20 {
378+ t.Fatalf("only %d jobs in the recording", n)
379+ }
380+}
381+
382+// permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group.
383+func permissionsGroup(log []string) []string {
384+ for i, l := range log {
385+ if l != "##[group]GITHUB_TOKEN Permissions" {
386+ continue
387+ }
388+ for k := i + 1; k < len(log); k++ {
389+ if log[k] == "##[endgroup]" {
390+ return log[i+1 : k]
391+ }
392+ }
393+ }
394+ return nil
395+}
M internal/executor/job.go +9 -26
@@ -580,37 +580,20 @@ const (
580580 defaultCacheMode = "write"
581581 )
582582
583-// defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a
584-// "permissions" key, as recorded (JobExtension.cs:157-166 prints the
585-// permissions in the order of the server's JSON).
586-var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
587-
588583 // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
589584 // and the cache mode.
590585 //
591-// Without a "permissions" key the group is the recorded default. With one
592-// (including {} and read-all/write-all) the group is omitted, on purpose: the
593-// runner only prints what the server gives it (system.github.token.permissions,
594-// a JSON object, JobExtension.cs:154-166), and the sources at hand do not say
595-// what the server gives for those cases: the display names of the scopes
596-// ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the
597-// levels the server grants on a push (Metadata is always read; write-all and
598-// id-token depend on the repository's policy, the Permissions class of the
599-// runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and
600-// Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only
601-// the default is in the recordings (221 of 221 jobs; no corpus scenario sets
602-// permissions). A group made up from the workflow would be a guess; add the
603-// mapping once a recording of a scenario with permissions exists. The
604-// complete fix is the server sending the effective permissions in the Job
605-// message.
586+// GitHub's runner prints what the server sends as
587+// system.github.token.permissions (JobExtension.cs:154-166); ermite's Job
588+// message does not carry it, so the group is worked out from the workflow
589+// with the rules recorded on github.com (tokenPermissionLines). It is always
590+// printed: Metadata is listed even for "permissions: {}".
606591 func (j *job) printTokenInfo() {
607- if j.wf.Permissions == nil && j.jb.Permissions == nil {
608- j.out(1, "##[group]GITHUB_TOKEN Permissions")
609- for _, p := range defaultTokenPermissions {
610- j.out(1, p[0]+": "+p[1])
611- }
612- j.out(1, "##[endgroup]")
592+ j.out(1, "##[group]GITHUB_TOKEN Permissions")
593+ for _, p := range tokenPermissionLines(j.wf, j.jb) {
594+ j.out(1, p.name+": "+p.level)
613595 }
596+ j.out(1, "##[endgroup]")
614597 // secret_source is a github context key (JobExtension.cs:172); the context
615598 // of a job from ermite lacks it, so it is added with the recorded value.
616599 src := j.state.GitHub("secret_source")
@@ -580,37 +580,20 @@ const (
580 defaultCacheMode = "write"580 defaultCacheMode = "write"
581 )581 )
582 582
583-// defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a
584-// "permissions" key, as recorded (JobExtension.cs:157-166 prints the
585-// permissions in the order of the server's JSON).
586-var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
587-
588 // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source583 // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
589 // and the cache mode.584 // and the cache mode.
590 //585 //
591-// Without a "permissions" key the group is the recorded default. With one586+// GitHub's runner prints what the server sends as
592-// (including {} and read-all/write-all) the group is omitted, on purpose: the587+// system.github.token.permissions (JobExtension.cs:154-166); ermite's Job
593-// runner only prints what the server gives it (system.github.token.permissions,588+// message does not carry it, so the group is worked out from the workflow
594-// a JSON object, JobExtension.cs:154-166), and the sources at hand do not say589+// with the rules recorded on github.com (tokenPermissionLines). It is always
595-// what the server gives for those cases: the display names of the scopes590+// printed: Metadata is listed even for "permissions: {}".
596-// ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the
597-// levels the server grants on a push (Metadata is always read; write-all and
598-// id-token depend on the repository's policy, the Permissions class of the
599-// runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and
600-// Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only
601-// the default is in the recordings (221 of 221 jobs; no corpus scenario sets
602-// permissions). A group made up from the workflow would be a guess; add the
603-// mapping once a recording of a scenario with permissions exists. The
604-// complete fix is the server sending the effective permissions in the Job
605-// message.
606 func (j *job) printTokenInfo() {591 func (j *job) printTokenInfo() {
607- if j.wf.Permissions == nil && j.jb.Permissions == nil {592+ j.out(1, "##[group]GITHUB_TOKEN Permissions")
608- j.out(1, "##[group]GITHUB_TOKEN Permissions")593+ for _, p := range tokenPermissionLines(j.wf, j.jb) {
609- for _, p := range defaultTokenPermissions {594+ j.out(1, p.name+": "+p.level)
610- j.out(1, p[0]+": "+p[1])
611- }
612- j.out(1, "##[endgroup]")
613 }595 }
596+ j.out(1, "##[endgroup]")
614 // secret_source is a github context key (JobExtension.cs:172); the context597 // secret_source is a github context key (JobExtension.cs:172); the context
615 // of a job from ermite lacks it, so it is added with the recorded value.598 // of a job from ermite lacks it, so it is added with the recorded value.
616 src := j.state.GitHub("secret_source")599 src := j.state.GitHub("secret_source")
M internal/executor/job_test.go +48 -11
@@ -696,19 +696,56 @@ func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) {
696696 }
697697 }
698698
699-// Permissions the workflow sets are not the default ones: no group is printed
700-// rather than a wrong one.
701-func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) {
699+// The GITHUB_TOKEN Permissions group follows the effective permissions of the
700+// job (the job's replace the workflow's), as recorded on github.com
701+// (recordings/permissions-display.json).
702+func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) {
702703 needTools(t, "bash")
703- for name, yaml := range map[string]string{
704- "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n",
705- "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n",
706- "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n",
707- "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n",
704+ steps := " steps:\n - run: echo hi\n"
705+ for _, tc := range []struct {
706+ name, yaml, want string
707+ }{
708+ {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"},
709+ {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"},
710+ {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"},
711+ {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps,
712+ "Contents: write\nMetadata: read\nPackages: read"},
713+ {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps,
714+ "Checks: read\nMetadata: read"},
715+ {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps,
716+ "Metadata: read\nPullRequests: read"},
717+ {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, "read-all"},
718+ {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps,
719+ "Contents: read\nMetadata: read"},
720+ {"write-all", "permissions: write-all\n" + wfHead + steps, "write-all"},
721+ {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps,
722+ "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"},
708723 } {
709- got := setupLog(t, yaml, "j", nil)
710- if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") {
711- t.Errorf("%s:\n%s", name, got)
724+ got := setupLog(t, tc.yaml, "j", nil)
725+ start := strings.Index(got, "##[group]GITHUB_TOKEN Permissions\n")
726+ end := strings.Index(got, "\n##[endgroup]")
727+ if start < 0 || end < 0 || !strings.Contains(got, "##[endgroup]\nSecret source: Actions\nCache mode: write") {
728+ t.Errorf("%s:\n%s", tc.name, got)
729+ continue
730+ }
731+ have := strings.Split(got[start+len("##[group]GITHUB_TOKEN Permissions\n"):end], "\n")
732+ switch tc.want {
733+ case "read-all":
734+ if len(have) != 19 || have[0] != "Actions: read" || strings.Contains(strings.Join(have, ","), "write") || strings.Contains(strings.Join(have, ","), "CopilotRequests") {
735+ t.Errorf("%s: %v", tc.name, have)
736+ }
737+ case "write-all":
738+ // recordings: p-write-all
739+ wantAll := "Actions: write,ArtifactMetadata: write,Attestations: write,Checks: write,CodeQuality: write,Contents: write,CopilotRequests: write," +
740+ "Deployments: write,Discussions: write,Drives: write,Issues: write,Metadata: read,Models: read,Packages: write,Pages: write,PullRequests: write," +
741+ "RepositoryProjects: write,SecurityEvents: write,Statuses: write,VulnerabilityAlerts: read"
742+ if strings.Join(have, ",") != wantAll {
743+ t.Errorf("%s: %v", tc.name, have)
744+ }
745+ default:
746+ if strings.Join(have, "\n") != tc.want {
747+ t.Errorf("%s:\ngot %v\nwant %q", tc.name, have, tc.want)
748+ }
712749 }
713750 }
714751 }
@@ -696,19 +696,56 @@ func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) {
696 }696 }
697 }697 }
698 698
699-// Permissions the workflow sets are not the default ones: no group is printed699+// The GITHUB_TOKEN Permissions group follows the effective permissions of the
700-// rather than a wrong one.700+// job (the job's replace the workflow's), as recorded on github.com
701-func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) {701+// (recordings/permissions-display.json).
702+func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) {
702 needTools(t, "bash")703 needTools(t, "bash")
703- for name, yaml := range map[string]string{704+ steps := " steps:\n - run: echo hi\n"
704- "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n",705+ for _, tc := range []struct {
705- "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n",706+ name, yaml, want string
706- "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n",707+ }{
707- "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n",708+ {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"},
709+ {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"},
710+ {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"},
711+ {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps,
712+ "Contents: write\nMetadata: read\nPackages: read"},
713+ {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps,
714+ "Checks: read\nMetadata: read"},
715+ {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps,
716+ "Metadata: read\nPullRequests: read"},
717+ {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, "read-all"},
718+ {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps,
719+ "Contents: read\nMetadata: read"},
720+ {"write-all", "permissions: write-all\n" + wfHead + steps, "write-all"},
721+ {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps,
722+ "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"},
708 } {723 } {
709- got := setupLog(t, yaml, "j", nil)724+ got := setupLog(t, tc.yaml, "j", nil)
710- if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") {725+ start := strings.Index(got, "##[group]GITHUB_TOKEN Permissions\n")
711- t.Errorf("%s:\n%s", name, got)726+ end := strings.Index(got, "\n##[endgroup]")
727+ if start < 0 || end < 0 || !strings.Contains(got, "##[endgroup]\nSecret source: Actions\nCache mode: write") {
728+ t.Errorf("%s:\n%s", tc.name, got)
729+ continue
730+ }
731+ have := strings.Split(got[start+len("##[group]GITHUB_TOKEN Permissions\n"):end], "\n")
732+ switch tc.want {
733+ case "read-all":
734+ if len(have) != 19 || have[0] != "Actions: read" || strings.Contains(strings.Join(have, ","), "write") || strings.Contains(strings.Join(have, ","), "CopilotRequests") {
735+ t.Errorf("%s: %v", tc.name, have)
736+ }
737+ case "write-all":
738+ // recordings: p-write-all
739+ wantAll := "Actions: write,ArtifactMetadata: write,Attestations: write,Checks: write,CodeQuality: write,Contents: write,CopilotRequests: write," +
740+ "Deployments: write,Discussions: write,Drives: write,Issues: write,Metadata: read,Models: read,Packages: write,Pages: write,PullRequests: write," +
741+ "RepositoryProjects: write,SecurityEvents: write,Statuses: write,VulnerabilityAlerts: read"
742+ if strings.Join(have, ",") != wantAll {
743+ t.Errorf("%s: %v", tc.name, have)
744+ }
745+ default:
746+ if strings.Join(have, "\n") != tc.want {
747+ t.Errorf("%s:\ngot %v\nwant %q", tc.name, have, tc.want)
748+ }
712 }749 }
713 }750 }
714 }751 }
A internal/executor/token_permissions.go +107 -0
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "sort"
7+ "strings"
8+
9+ "bnema.dev/ermite-actions/workflow"
10+)
11+
12+// tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it.
13+type tokenScope struct {
14+ // key is the name in a workflow's "permissions" mapping.
15+ key string
16+ // display is the name "Set up job" prints.
17+ display string
18+ // writeAll is the level "write-all" grants: GitHub keeps models and
19+ // vulnerability-alerts at read, whatever the workflow asks.
20+ writeAll string
21+}
22+
23+// tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for
24+// "read-all" and "write-all" (recordings/permissions-display.json: p-read-all,
25+// p-write-all). "id-token" is not here: it is never listed, with any level
26+// (p-job-mixed asks for id-token: write). "copilot-requests" is listed for
27+// write-all only (read-all grants nothing for it, and "read" is rejected).
28+// "metadata" is not a scope a workflow can set (rejected); it is always read.
29+var tokenScopes = []tokenScope{
30+ {"actions", "Actions", "write"},
31+ {"artifact-metadata", "ArtifactMetadata", "write"},
32+ {"attestations", "Attestations", "write"},
33+ {"checks", "Checks", "write"},
34+ {"code-quality", "CodeQuality", "write"},
35+ {"contents", "Contents", "write"},
36+ {"copilot-requests", "CopilotRequests", "write"},
37+ {"deployments", "Deployments", "write"},
38+ {"discussions", "Discussions", "write"},
39+ {"drives", "Drives", "write"},
40+ {"issues", "Issues", "write"},
41+ {"models", "Models", "read"},
42+ {"packages", "Packages", "write"},
43+ {"pages", "Pages", "write"},
44+ {"pull-requests", "PullRequests", "write"},
45+ {"repository-projects", "RepositoryProjects", "write"},
46+ {"security-events", "SecurityEvents", "write"},
47+ {"statuses", "Statuses", "write"},
48+ {"vulnerability-alerts", "VulnerabilityAlerts", "read"},
49+}
50+
51+// permissionLine is one line of the group: "<Display>: <level>".
52+type permissionLine struct{ name, level string }
53+
54+// tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions"
55+// group for the effective permissions of a job, as GitHub prints them on a
56+// push by a repository owner (the recorded case):
57+//
58+// - the permissions of the job replace those of the workflow (no merge);
59+// without either key the repository default applies;
60+// - lines are sorted by display name (ordinal), Metadata included;
61+// - Metadata is always listed, as read, whatever the workflow says;
62+// - a scope set to "none" or left out is not listed, nor is id-token;
63+// - "permissions: {}" lists Metadata only;
64+// - "read-all" lists every scope but copilot-requests as read;
65+// - "write-all" lists every scope as write except models and
66+// vulnerability-alerts (read);
67+// - a mapping lists its scopes with the given level; a scope that GitHub
68+// does not know is left out (it rejects the workflow before this point
69+// for the invalid ones it was shown).
70+//
71+// Not recorded: a private repository, a fork pull request, an organisation
72+// whose default token is restricted differently, a "pull_request_target"
73+// event. They change what the server grants; the workflow side is the same.
74+func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine {
75+ p := jb.Permissions
76+ if p == nil {
77+ p = wf.Permissions
78+ }
79+ if p == nil {
80+ return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
81+ }
82+ lines := []permissionLine{{"Metadata", "read"}}
83+ switch strings.ToLower(p.All) {
84+ case "read-all":
85+ for _, s := range tokenScopes {
86+ if s.key != "copilot-requests" {
87+ lines = append(lines, permissionLine{s.display, "read"})
88+ }
89+ }
90+ case "write-all":
91+ for _, s := range tokenScopes {
92+ lines = append(lines, permissionLine{s.display, s.writeAll})
93+ }
94+ default:
95+ level := map[string]string{}
96+ for _, sc := range p.Scopes {
97+ level[strings.ToLower(sc.Scope)] = strings.ToLower(sc.Level)
98+ }
99+ for _, s := range tokenScopes {
100+ if l := level[s.key]; l == "read" || l == "write" {
101+ lines = append(lines, permissionLine{s.display, l})
102+ }
103+ }
104+ }
105+ sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
106+ return lines
107+}
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "sort"
7+ "strings"
8+
9+ "bnema.dev/ermite-actions/workflow"
10+)
11+
12+// tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it.
13+type tokenScope struct {
14+ // key is the name in a workflow's "permissions" mapping.
15+ key string
16+ // display is the name "Set up job" prints.
17+ display string
18+ // writeAll is the level "write-all" grants: GitHub keeps models and
19+ // vulnerability-alerts at read, whatever the workflow asks.
20+ writeAll string
21+}
22+
23+// tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for
24+// "read-all" and "write-all" (recordings/permissions-display.json: p-read-all,
25+// p-write-all). "id-token" is not here: it is never listed, with any level
26+// (p-job-mixed asks for id-token: write). "copilot-requests" is listed for
27+// write-all only (read-all grants nothing for it, and "read" is rejected).
28+// "metadata" is not a scope a workflow can set (rejected); it is always read.
29+var tokenScopes = []tokenScope{
30+ {"actions", "Actions", "write"},
31+ {"artifact-metadata", "ArtifactMetadata", "write"},
32+ {"attestations", "Attestations", "write"},
33+ {"checks", "Checks", "write"},
34+ {"code-quality", "CodeQuality", "write"},
35+ {"contents", "Contents", "write"},
36+ {"copilot-requests", "CopilotRequests", "write"},
37+ {"deployments", "Deployments", "write"},
38+ {"discussions", "Discussions", "write"},
39+ {"drives", "Drives", "write"},
40+ {"issues", "Issues", "write"},
41+ {"models", "Models", "read"},
42+ {"packages", "Packages", "write"},
43+ {"pages", "Pages", "write"},
44+ {"pull-requests", "PullRequests", "write"},
45+ {"repository-projects", "RepositoryProjects", "write"},
46+ {"security-events", "SecurityEvents", "write"},
47+ {"statuses", "Statuses", "write"},
48+ {"vulnerability-alerts", "VulnerabilityAlerts", "read"},
49+}
50+
51+// permissionLine is one line of the group: "<Display>: <level>".
52+type permissionLine struct{ name, level string }
53+
54+// tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions"
55+// group for the effective permissions of a job, as GitHub prints them on a
56+// push by a repository owner (the recorded case):
57+//
58+// - the permissions of the job replace those of the workflow (no merge);
59+// without either key the repository default applies;
60+// - lines are sorted by display name (ordinal), Metadata included;
61+// - Metadata is always listed, as read, whatever the workflow says;
62+// - a scope set to "none" or left out is not listed, nor is id-token;
63+// - "permissions: {}" lists Metadata only;
64+// - "read-all" lists every scope but copilot-requests as read;
65+// - "write-all" lists every scope as write except models and
66+// vulnerability-alerts (read);
67+// - a mapping lists its scopes with the given level; a scope that GitHub
68+// does not know is left out (it rejects the workflow before this point
69+// for the invalid ones it was shown).
70+//
71+// Not recorded: a private repository, a fork pull request, an organisation
72+// whose default token is restricted differently, a "pull_request_target"
73+// event. They change what the server grants; the workflow side is the same.
74+func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine {
75+ p := jb.Permissions
76+ if p == nil {
77+ p = wf.Permissions
78+ }
79+ if p == nil {
80+ return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
81+ }
82+ lines := []permissionLine{{"Metadata", "read"}}
83+ switch strings.ToLower(p.All) {
84+ case "read-all":
85+ for _, s := range tokenScopes {
86+ if s.key != "copilot-requests" {
87+ lines = append(lines, permissionLine{s.display, "read"})
88+ }
89+ }
90+ case "write-all":
91+ for _, s := range tokenScopes {
92+ lines = append(lines, permissionLine{s.display, s.writeAll})
93+ }
94+ default:
95+ level := map[string]string{}
96+ for _, sc := range p.Scopes {
97+ level[strings.ToLower(sc.Scope)] = strings.ToLower(sc.Level)
98+ }
99+ for _, s := range tokenScopes {
100+ if l := level[s.key]; l == "read" || l == "write" {
101+ lines = append(lines, permissionLine{s.display, l})
102+ }
103+ }
104+ }
105+ sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
106+ return lines
107+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close