detached 3418cfa

Merge pull request 'feat(executor): print the GITHUB_TOKEN permissions GitHub grants' (#6) from feat/token-permissions-display into main

commit
3418cfa0850e12cbb423caf5f3b490ecf1feb119
author
bnema · · 2026-10-12 23:55 UTC
merge of
0cc0cf3 + bdd5903 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
4 files · +284 -49
M internal/executor/corpus_test.go +94 -12
@@ -20,26 +20,48 @@ import (
2020 // conclusions and their logs with the recordings made on github.com. They need
2121 // a checkout of ermite-conformance (the corpus and recordings directories),
2222 // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository;
23-// they skip without it.
23+// they skip without it (and fail when ERMITE_CONFORMANCE_DIR is wrong).
2424
2525 func conformanceDir(t *testing.T) string {
2626 t.Helper()
27- cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")}
28- wd, _ := os.Getwd()
29- for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {
30- cands = append(cands,
31- filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"),
32- filepath.Join(d, "ermite-conformance"))
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 checkout is
32+// ERMITE_CONFORMANCE_DIR when set (the test fails if it lacks the corpus or the
33+// recording: a wrong path must not look like a pass), else the main checkout
34+// ermite-conformance next to this repository (skipped when it has none).
35+func conformanceDirFor(t *testing.T, scenario string) string {
36+ t.Helper()
37+ has := func(dir string) (corpus, rec bool) {
38+ if st, err := os.Stat(filepath.Join(dir, "corpus")); err == nil && st.IsDir() {
39+ corpus = true
40+ }
41+ if scenario == "" {
42+ return corpus, true
43+ }
44+ _, err := os.Stat(filepath.Join(dir, "recordings", scenario+".json"))
45+ return corpus, err == nil
3346 }
34- for _, c := range cands {
35- if c == "" {
36- continue
47+ if dir := os.Getenv("ERMITE_CONFORMANCE_DIR"); dir != "" {
48+ corpus, rec := has(dir)
49+ if !corpus {
50+ t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no corpus directory", dir)
51+ }
52+ if !rec {
53+ t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no recordings/%s.json", dir, scenario)
3754 }
38- if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() {
55+ return dir
56+ }
57+ wd, _ := os.Getwd()
58+ for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {
59+ c := filepath.Join(d, "ermite-conformance")
60+ if corpus, rec := has(c); corpus && rec {
3961 return c
4062 }
4163 }
42- t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)")
64+ t.Skip("ermite-conformance checkout not found next to this repository (set ERMITE_CONFORMANCE_DIR)")
4365 return ""
4466 }
4567
@@ -163,6 +185,7 @@ func readFile(t *testing.T, p string) string {
163185 type recording struct {
164186 Pushes []struct {
165187 Runs []struct {
188+ Workflow string `json:"workflow"`
166189 Conclusion string `json:"conclusion"`
167190 Jobs []struct {
168191 Name string `json:"name"`
@@ -324,3 +347,62 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) {
324347 t.Fatalf("%+v %v", res, rec.names())
325348 }
326349 }
350+
351+// TestCorpusPermissionsDisplay runs every job of every workflow of the
352+// permissions-display scenario and compares the GITHUB_TOKEN Permissions group
353+// of Set up job with the recording. The scenario has one workflow file per
354+// case, so compareWithRecording does not apply. Workflows GitHub rejects have
355+// no jobs in the recording and are not run.
356+func TestCorpusPermissionsDisplay(t *testing.T) {
357+ needTools(t, "bash")
358+ dir := conformanceDirFor(t, "permissions-display")
359+ var rec recording
360+ if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil {
361+ t.Fatal(err)
362+ }
363+ n := 0
364+ for _, run := range rec.Pushes[0].Runs {
365+ if len(run.Jobs) == 0 {
366+ continue
367+ }
368+ file := run.Workflow
369+ yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file)))
370+ for _, jr := range run.Jobs {
371+ t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) {
372+ if len(jr.Steps) == 0 {
373+ t.Fatal("no steps in the recording")
374+ }
375+ want := permissionsGroup(jr.Steps[0].Log)
376+ if want == nil {
377+ t.Fatalf("no group in the recording: %v", jr.Steps[0].Log)
378+ }
379+ got, res := runLocal(t, yaml, jr.Name, nil)
380+ if res.Conclusion != ConclusionSuccess || len(got.steps) == 0 {
381+ t.Fatalf("job did not run: %+v %v", res, got.names())
382+ }
383+ if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") {
384+ t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n"))
385+ }
386+ })
387+ n++
388+ }
389+ }
390+ if n < 20 {
391+ t.Fatalf("only %d jobs in the recording", n)
392+ }
393+}
394+
395+// permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group.
396+func permissionsGroup(log []string) []string {
397+ for i, l := range log {
398+ if l != "##[group]GITHUB_TOKEN Permissions" {
399+ continue
400+ }
401+ for k := i + 1; k < len(log); k++ {
402+ if log[k] == "##[endgroup]" {
403+ return log[i+1 : k]
404+ }
405+ }
406+ }
407+ return nil
408+}
@@ -20,26 +20,48 @@ import (
20 // conclusions and their logs with the recordings made on github.com. They need20 // conclusions and their logs with the recordings made on github.com. They need
21 // a checkout of ermite-conformance (the corpus and recordings directories),21 // a checkout of ermite-conformance (the corpus and recordings directories),
22 // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository;22 // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository;
23-// they skip without it.23+// they skip without it (and fail when ERMITE_CONFORMANCE_DIR is wrong).
24 24
25 func conformanceDir(t *testing.T) string {25 func conformanceDir(t *testing.T) string {
26 t.Helper()26 t.Helper()
27- cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")}27+ return conformanceDirFor(t, "")
28- wd, _ := os.Getwd()28+}
29- for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {29+
30- cands = append(cands,30+// conformanceDirFor is conformanceDir for a checkout that has the recording of
31- filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"),31+// scenario (any checkout with a corpus when scenario is empty). The checkout is
32- filepath.Join(d, "ermite-conformance"))32+// ERMITE_CONFORMANCE_DIR when set (the test fails if it lacks the corpus or the
33+// recording: a wrong path must not look like a pass), else the main checkout
34+// ermite-conformance next to this repository (skipped when it has none).
35+func conformanceDirFor(t *testing.T, scenario string) string {
36+ t.Helper()
37+ has := func(dir string) (corpus, rec bool) {
38+ if st, err := os.Stat(filepath.Join(dir, "corpus")); err == nil && st.IsDir() {
39+ corpus = true
40+ }
41+ if scenario == "" {
42+ return corpus, true
43+ }
44+ _, err := os.Stat(filepath.Join(dir, "recordings", scenario+".json"))
45+ return corpus, err == nil
33 }46 }
34- for _, c := range cands {47+ if dir := os.Getenv("ERMITE_CONFORMANCE_DIR"); dir != "" {
35- if c == "" {48+ corpus, rec := has(dir)
36- continue49+ if !corpus {
50+ t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no corpus directory", dir)
51+ }
52+ if !rec {
53+ t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no recordings/%s.json", dir, scenario)
37 }54 }
38- if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() {55+ return dir
56+ }
57+ wd, _ := os.Getwd()
58+ for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {
59+ c := filepath.Join(d, "ermite-conformance")
60+ if corpus, rec := has(c); corpus && rec {
39 return c61 return c
40 }62 }
41 }63 }
42- t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)")64+ t.Skip("ermite-conformance checkout not found next to this repository (set ERMITE_CONFORMANCE_DIR)")
43 return ""65 return ""
44 }66 }
45 67
@@ -163,6 +185,7 @@ func readFile(t *testing.T, p string) string {
163 type recording struct {185 type recording struct {
164 Pushes []struct {186 Pushes []struct {
165 Runs []struct {187 Runs []struct {
188+ Workflow string `json:"workflow"`
166 Conclusion string `json:"conclusion"`189 Conclusion string `json:"conclusion"`
167 Jobs []struct {190 Jobs []struct {
168 Name string `json:"name"`191 Name string `json:"name"`
@@ -324,3 +347,62 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) {
324 t.Fatalf("%+v %v", res, rec.names())347 t.Fatalf("%+v %v", res, rec.names())
325 }348 }
326 }349 }
350+
351+// TestCorpusPermissionsDisplay runs every job of every workflow of the
352+// permissions-display scenario and compares the GITHUB_TOKEN Permissions group
353+// of Set up job with the recording. The scenario has one workflow file per
354+// case, so compareWithRecording does not apply. Workflows GitHub rejects have
355+// no jobs in the recording and are not run.
356+func TestCorpusPermissionsDisplay(t *testing.T) {
357+ needTools(t, "bash")
358+ dir := conformanceDirFor(t, "permissions-display")
359+ var rec recording
360+ if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil {
361+ t.Fatal(err)
362+ }
363+ n := 0
364+ for _, run := range rec.Pushes[0].Runs {
365+ if len(run.Jobs) == 0 {
366+ continue
367+ }
368+ file := run.Workflow
369+ yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file)))
370+ for _, jr := range run.Jobs {
371+ t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) {
372+ if len(jr.Steps) == 0 {
373+ t.Fatal("no steps in the recording")
374+ }
375+ want := permissionsGroup(jr.Steps[0].Log)
376+ if want == nil {
377+ t.Fatalf("no group in the recording: %v", jr.Steps[0].Log)
378+ }
379+ got, res := runLocal(t, yaml, jr.Name, nil)
380+ if res.Conclusion != ConclusionSuccess || len(got.steps) == 0 {
381+ t.Fatalf("job did not run: %+v %v", res, got.names())
382+ }
383+ if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") {
384+ t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n"))
385+ }
386+ })
387+ n++
388+ }
389+ }
390+ if n < 20 {
391+ t.Fatalf("only %d jobs in the recording", n)
392+ }
393+}
394+
395+// permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group.
396+func permissionsGroup(log []string) []string {
397+ for i, l := range log {
398+ if l != "##[group]GITHUB_TOKEN Permissions" {
399+ continue
400+ }
401+ for k := i + 1; k < len(log); k++ {
402+ if log[k] == "##[endgroup]" {
403+ return log[i+1 : k]
404+ }
405+ }
406+ }
407+ return nil
408+}
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 +73 -11
@@ -696,19 +696,81 @@ 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 expected groups of the whole-token cases, as recorded on github.com
700+// (recordings/permissions-display.json: p-read-all, p-write-all).
701+const (
702+ recordedReadAll = `Actions: read
703+ArtifactMetadata: read
704+Attestations: read
705+Checks: read
706+CodeQuality: read
707+Contents: read
708+Deployments: read
709+Discussions: read
710+Drives: read
711+Issues: read
712+Metadata: read
713+Models: read
714+Packages: read
715+Pages: read
716+PullRequests: read
717+RepositoryProjects: read
718+SecurityEvents: read
719+Statuses: read
720+VulnerabilityAlerts: read`
721+ recordedWriteAll = `Actions: write
722+ArtifactMetadata: write
723+Attestations: write
724+Checks: write
725+CodeQuality: write
726+Contents: write
727+CopilotRequests: write
728+Deployments: write
729+Discussions: write
730+Drives: write
731+Issues: write
732+Metadata: read
733+Models: read
734+Packages: write
735+Pages: write
736+PullRequests: write
737+RepositoryProjects: write
738+SecurityEvents: write
739+Statuses: write
740+VulnerabilityAlerts: read`
741+)
742+
743+// The GITHUB_TOKEN Permissions group follows the effective permissions of the
744+// job (the job's replace the workflow's), as recorded on github.com
745+// (recordings/permissions-display.json).
746+func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) {
702747 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",
748+ steps := " steps:\n - run: echo hi\n"
749+ for _, tc := range []struct {
750+ name, yaml, want string
751+ }{
752+ {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"},
753+ {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"},
754+ {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"},
755+ {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps,
756+ "Contents: write\nMetadata: read\nPackages: read"},
757+ {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps,
758+ "Checks: read\nMetadata: read"},
759+ {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps,
760+ "Metadata: read\nPullRequests: read"},
761+ {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, recordedReadAll},
762+ {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps,
763+ "Contents: read\nMetadata: read"},
764+ {"read-all", "permissions: read-all\n" + wfHead + steps, recordedReadAll},
765+ {"write-all", "permissions: write-all\n" + wfHead + steps, recordedWriteAll},
766+ {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps,
767+ "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"},
708768 } {
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)
769+ want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", tc.want,
770+ "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
771+ "Prepare all required actions", "Complete job name: j"}, "\n")
772+ if got := setupLog(t, tc.yaml, "j", nil); got != want {
773+ t.Errorf("%s:\ngot\n%s\nwant\n%s", tc.name, got, want)
712774 }
713775 }
714776 }
@@ -696,19 +696,81 @@ 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 expected groups of the whole-token cases, as recorded on github.com
700-// rather than a wrong one.700+// (recordings/permissions-display.json: p-read-all, p-write-all).
701-func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) {701+const (
702+ recordedReadAll = `Actions: read
703+ArtifactMetadata: read
704+Attestations: read
705+Checks: read
706+CodeQuality: read
707+Contents: read
708+Deployments: read
709+Discussions: read
710+Drives: read
711+Issues: read
712+Metadata: read
713+Models: read
714+Packages: read
715+Pages: read
716+PullRequests: read
717+RepositoryProjects: read
718+SecurityEvents: read
719+Statuses: read
720+VulnerabilityAlerts: read`
721+ recordedWriteAll = `Actions: write
722+ArtifactMetadata: write
723+Attestations: write
724+Checks: write
725+CodeQuality: write
726+Contents: write
727+CopilotRequests: write
728+Deployments: write
729+Discussions: write
730+Drives: write
731+Issues: write
732+Metadata: read
733+Models: read
734+Packages: write
735+Pages: write
736+PullRequests: write
737+RepositoryProjects: write
738+SecurityEvents: write
739+Statuses: write
740+VulnerabilityAlerts: read`
741+)
742+
743+// The GITHUB_TOKEN Permissions group follows the effective permissions of the
744+// job (the job's replace the workflow's), as recorded on github.com
745+// (recordings/permissions-display.json).
746+func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) {
702 needTools(t, "bash")747 needTools(t, "bash")
703- for name, yaml := range map[string]string{748+ steps := " steps:\n - run: echo hi\n"
704- "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n",749+ for _, tc := range []struct {
705- "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n",750+ name, yaml, want string
706- "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n",751+ }{
707- "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n",752+ {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"},
753+ {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"},
754+ {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"},
755+ {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps,
756+ "Contents: write\nMetadata: read\nPackages: read"},
757+ {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps,
758+ "Checks: read\nMetadata: read"},
759+ {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps,
760+ "Metadata: read\nPullRequests: read"},
761+ {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, recordedReadAll},
762+ {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps,
763+ "Contents: read\nMetadata: read"},
764+ {"read-all", "permissions: read-all\n" + wfHead + steps, recordedReadAll},
765+ {"write-all", "permissions: write-all\n" + wfHead + steps, recordedWriteAll},
766+ {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps,
767+ "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"},
708 } {768 } {
709- got := setupLog(t, yaml, "j", nil)769+ want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", tc.want,
710- if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") {770+ "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
711- t.Errorf("%s:\n%s", name, got)771+ "Prepare all required actions", "Complete job name: j"}, "\n")
772+ if got := setupLog(t, tc.yaml, "j", nil); got != want {
773+ t.Errorf("%s:\ngot\n%s\nwant\n%s", tc.name, got, want)
712 }774 }
713 }775 }
714 }776 }
A internal/executor/token_permissions.go +108 -0
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "sort"
7+
8+ "bnema.dev/ermite-actions/workflow"
9+)
10+
11+// tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it.
12+type tokenScope struct {
13+ // key is the name in a workflow's "permissions" mapping.
14+ key string
15+ // display is the name "Set up job" prints.
16+ display string
17+ // writeAll is the level "write-all" grants: GitHub keeps models and
18+ // vulnerability-alerts at read, whatever the workflow asks.
19+ writeAll string
20+ // readAll is the level "read-all" grants, "" when it grants nothing.
21+ readAll string
22+}
23+
24+// tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for
25+// "read-all" and "write-all" (recordings/permissions-display.json: p-read-all,
26+// p-write-all). "id-token" is not here: it is never listed, with any level
27+// (p-job-mixed asks for id-token: write). "copilot-requests" is listed for
28+// write-all only (read-all grants nothing for it, and "read" is rejected).
29+// "metadata" is not a scope a workflow can set (rejected); it is always read.
30+var tokenScopes = []tokenScope{
31+ {"actions", "Actions", "write", "read"},
32+ {"artifact-metadata", "ArtifactMetadata", "write", "read"},
33+ {"attestations", "Attestations", "write", "read"},
34+ {"checks", "Checks", "write", "read"},
35+ {"code-quality", "CodeQuality", "write", "read"},
36+ {"contents", "Contents", "write", "read"},
37+ {"copilot-requests", "CopilotRequests", "write", ""},
38+ {"deployments", "Deployments", "write", "read"},
39+ {"discussions", "Discussions", "write", "read"},
40+ {"drives", "Drives", "write", "read"},
41+ {"issues", "Issues", "write", "read"},
42+ {"models", "Models", "read", "read"},
43+ {"packages", "Packages", "write", "read"},
44+ {"pages", "Pages", "write", "read"},
45+ {"pull-requests", "PullRequests", "write", "read"},
46+ {"repository-projects", "RepositoryProjects", "write", "read"},
47+ {"security-events", "SecurityEvents", "write", "read"},
48+ {"statuses", "Statuses", "write", "read"},
49+ {"vulnerability-alerts", "VulnerabilityAlerts", "read", "read"},
50+}
51+
52+// permissionLine is one line of the group: "<Display>: <level>".
53+type permissionLine struct{ name, level string }
54+
55+// tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions"
56+// group for the effective permissions of a job, as GitHub prints them on a
57+// push by a repository owner (the recorded case):
58+//
59+// - the permissions of the job replace those of the workflow (no merge);
60+// without either key the repository default applies;
61+// - lines are sorted by display name (ordinal), Metadata included;
62+// - Metadata is always listed, as read, whatever the workflow says;
63+// - a scope set to "none" or left out is not listed, nor is id-token;
64+// - "permissions: {}" lists Metadata only;
65+// - "read-all" lists every scope but copilot-requests as read;
66+// - "write-all" lists every scope as write except models and
67+// vulnerability-alerts (read);
68+// - a mapping lists its scopes with the given level; a scope that GitHub
69+// does not know is left out (it rejects the workflow before this point
70+// for the invalid ones it was shown).
71+//
72+// Not recorded: a private repository, a fork pull request, an organisation
73+// whose default token is restricted differently, a "pull_request_target"
74+// event. They change what the server grants; the workflow side is the same.
75+func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine {
76+ p := jb.Permissions
77+ if p == nil {
78+ p = wf.Permissions
79+ }
80+ if p == nil {
81+ return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
82+ }
83+ lines := []permissionLine{{"Metadata", "read"}}
84+ switch p.All {
85+ case "read-all":
86+ for _, s := range tokenScopes {
87+ if s.readAll != "" {
88+ lines = append(lines, permissionLine{s.display, s.readAll})
89+ }
90+ }
91+ case "write-all":
92+ for _, s := range tokenScopes {
93+ lines = append(lines, permissionLine{s.display, s.writeAll})
94+ }
95+ default:
96+ level := map[string]string{}
97+ for _, sc := range p.Scopes {
98+ level[sc.Scope] = sc.Level
99+ }
100+ for _, s := range tokenScopes {
101+ if l := level[s.key]; l == "read" || l == "write" {
102+ lines = append(lines, permissionLine{s.display, l})
103+ }
104+ }
105+ }
106+ sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
107+ return lines
108+}
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "sort"
7+
8+ "bnema.dev/ermite-actions/workflow"
9+)
10+
11+// tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it.
12+type tokenScope struct {
13+ // key is the name in a workflow's "permissions" mapping.
14+ key string
15+ // display is the name "Set up job" prints.
16+ display string
17+ // writeAll is the level "write-all" grants: GitHub keeps models and
18+ // vulnerability-alerts at read, whatever the workflow asks.
19+ writeAll string
20+ // readAll is the level "read-all" grants, "" when it grants nothing.
21+ readAll string
22+}
23+
24+// tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for
25+// "read-all" and "write-all" (recordings/permissions-display.json: p-read-all,
26+// p-write-all). "id-token" is not here: it is never listed, with any level
27+// (p-job-mixed asks for id-token: write). "copilot-requests" is listed for
28+// write-all only (read-all grants nothing for it, and "read" is rejected).
29+// "metadata" is not a scope a workflow can set (rejected); it is always read.
30+var tokenScopes = []tokenScope{
31+ {"actions", "Actions", "write", "read"},
32+ {"artifact-metadata", "ArtifactMetadata", "write", "read"},
33+ {"attestations", "Attestations", "write", "read"},
34+ {"checks", "Checks", "write", "read"},
35+ {"code-quality", "CodeQuality", "write", "read"},
36+ {"contents", "Contents", "write", "read"},
37+ {"copilot-requests", "CopilotRequests", "write", ""},
38+ {"deployments", "Deployments", "write", "read"},
39+ {"discussions", "Discussions", "write", "read"},
40+ {"drives", "Drives", "write", "read"},
41+ {"issues", "Issues", "write", "read"},
42+ {"models", "Models", "read", "read"},
43+ {"packages", "Packages", "write", "read"},
44+ {"pages", "Pages", "write", "read"},
45+ {"pull-requests", "PullRequests", "write", "read"},
46+ {"repository-projects", "RepositoryProjects", "write", "read"},
47+ {"security-events", "SecurityEvents", "write", "read"},
48+ {"statuses", "Statuses", "write", "read"},
49+ {"vulnerability-alerts", "VulnerabilityAlerts", "read", "read"},
50+}
51+
52+// permissionLine is one line of the group: "<Display>: <level>".
53+type permissionLine struct{ name, level string }
54+
55+// tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions"
56+// group for the effective permissions of a job, as GitHub prints them on a
57+// push by a repository owner (the recorded case):
58+//
59+// - the permissions of the job replace those of the workflow (no merge);
60+// without either key the repository default applies;
61+// - lines are sorted by display name (ordinal), Metadata included;
62+// - Metadata is always listed, as read, whatever the workflow says;
63+// - a scope set to "none" or left out is not listed, nor is id-token;
64+// - "permissions: {}" lists Metadata only;
65+// - "read-all" lists every scope but copilot-requests as read;
66+// - "write-all" lists every scope as write except models and
67+// vulnerability-alerts (read);
68+// - a mapping lists its scopes with the given level; a scope that GitHub
69+// does not know is left out (it rejects the workflow before this point
70+// for the invalid ones it was shown).
71+//
72+// Not recorded: a private repository, a fork pull request, an organisation
73+// whose default token is restricted differently, a "pull_request_target"
74+// event. They change what the server grants; the workflow side is the same.
75+func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine {
76+ p := jb.Permissions
77+ if p == nil {
78+ p = wf.Permissions
79+ }
80+ if p == nil {
81+ return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
82+ }
83+ lines := []permissionLine{{"Metadata", "read"}}
84+ switch p.All {
85+ case "read-all":
86+ for _, s := range tokenScopes {
87+ if s.readAll != "" {
88+ lines = append(lines, permissionLine{s.display, s.readAll})
89+ }
90+ }
91+ case "write-all":
92+ for _, s := range tokenScopes {
93+ lines = append(lines, permissionLine{s.display, s.writeAll})
94+ }
95+ default:
96+ level := map[string]string{}
97+ for _, sc := range p.Scopes {
98+ level[sc.Scope] = sc.Level
99+ }
100+ for _, s := range tokenScopes {
101+ if l := level[s.key]; l == "read" || l == "write" {
102+ lines = append(lines, permissionLine{s.display, l})
103+ }
104+ }
105+ }
106+ sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
107+ return lines
108+}

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