Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/model/github_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type GithubContext struct {
ServerURL string `json:"server_url"`
APIURL string `json:"api_url"`
GraphQLURL string `json:"graphql_url"`
Permissions map[string]string `json:"permissions"`
}

func asString(v interface{}) string {
Expand Down
40 changes: 34 additions & 6 deletions pkg/model/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import (

// Workflow is the structure of the files in .github/workflows
type Workflow struct {
File string
Name string `yaml:"name"`
RawOn yaml.Node `yaml:"on"`
Env map[string]string `yaml:"env"`
Jobs map[string]*Job `yaml:"jobs"`
Defaults Defaults `yaml:"defaults"`
File string
Name string `yaml:"name"`
RawOn yaml.Node `yaml:"on"`
Env map[string]string `yaml:"env"`
RawPermissions yaml.Node `yaml:"permissions"`
Jobs map[string]*Job `yaml:"jobs"`
Defaults Defaults `yaml:"defaults"`
}

// On events for the workflow
Expand Down Expand Up @@ -209,6 +210,7 @@ type Job struct {
Uses string `yaml:"uses"`
With map[string]interface{} `yaml:"with"`
RawSecrets yaml.Node `yaml:"secrets"`
RawPermissions yaml.Node `yaml:"permissions"`
Result string
}

Expand Down Expand Up @@ -289,6 +291,19 @@ func (j *Job) Secrets() map[string]string {
return val
}

// Permissions returns the permissions as a map if declared as a mapping for the job,
// otherwise nil (e.g. unset or using read-all/write-all shorthand).
func (j *Job) Permissions() map[string]string {
if j.RawPermissions.Kind != yaml.MappingNode {
return nil
}
var val map[string]string
if !decodeNode(j.RawPermissions, &val) {
return nil
}
return val
}

// Container details for the job
func (j *Job) Container() *ContainerSpec {
var val *ContainerSpec
Expand Down Expand Up @@ -747,6 +762,19 @@ func (w *Workflow) GetJobIDs() []string {
return ids
}

// Permissions returns the permissions as a map if declared as a mapping (e.g. contents: read),
// otherwise nil (e.g. when using read-all / write-all shorthand or unset).
func (w *Workflow) Permissions() map[string]string {
if w.RawPermissions.Kind != yaml.MappingNode {
return nil
}
var val map[string]string
if !decodeNode(w.RawPermissions, &val) {
return nil
}
return val
}

var OnDecodeNodeError = func(node yaml.Node, out interface{}, err error) {
log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
}
Expand Down
69 changes: 69 additions & 0 deletions pkg/model/workflow_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package model

import (
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -612,3 +613,71 @@ on: push #*trigger
assert.Equal(t, "actions/checkout@v5", job.Steps[0].Uses)
}
}

func TestReadWorkflow_Permissions_CodeQuality(t *testing.T) {
// This test ensures that workflows using modern permissions (including
// code-quality, as used by GitHub Code Quality / coverage uploads) do not
// cause act to refuse to run the workflow due to schema validation errors.
// See: https://docs.github.com/en/code-security/how-tos/maintain-quality-code/set-up-code-coverage
yaml := `
name: Code Coverage Example
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
code-quality: write
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
code-quality: write
steps:
- uses: actions/checkout@v4
- name: Upload coverage
uses: actions/upload-code-coverage@v1
with:
file: coverage.xml
language: Go
`

for _, strict := range []bool{false, true} {
t.Run("strict="+strconv.FormatBool(strict), func(t *testing.T) {
w, err := ReadWorkflow(strings.NewReader(yaml), strict)
require.NoError(t, err, "ReadWorkflow must not refuse on code-quality permission (strict=%v)", strict)

// Workflow-level
assert.Equal(t, map[string]string{
"contents": "read",
"code-quality": "write",
}, w.Permissions())

// Job-level
j := w.GetJob("test")
require.NotNil(t, j)
assert.Equal(t, map[string]string{
"contents": "read",
"code-quality": "write",
}, j.Permissions())
})
}
}

func TestReadWorkflow_Permissions_Shorthand(t *testing.T) {
yaml := `
on: push
permissions: read-all
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo ok
`
w, err := ReadWorkflow(strings.NewReader(yaml), true)
assert.NoError(t, err)
// Shorthand is stored in the raw node; the helper returns nil for non-mapping form
assert.Nil(t, w.Permissions())
}
10 changes: 10 additions & 0 deletions pkg/runner/run_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,16 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.GraphQLURL = rc.Config.Env["GITHUB_GRAPHQL_URL"]
}

// Populate permissions from job (preferred) or workflow level declaration (only mappings)
job := rc.Run.Job()
if job != nil {
if p := job.Permissions(); p != nil {
ghc.Permissions = p
} else if wp := rc.Run.Workflow.Permissions(); wp != nil {
ghc.Permissions = wp
}
}

return ghc
}

Expand Down
36 changes: 36 additions & 0 deletions pkg/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,39 @@ jobs:
}).UnmarshalYAML(&node)
assert.NoError(t, err)
}

func TestCodeQualityPermission(t *testing.T) {
var node yaml.Node
err := yaml.Unmarshal([]byte(`
name: Code Coverage
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
code-quality: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests with coverage
run: go test -coverprofile=cover.out ./...
- name: Upload coverage report
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: actions/upload-code-coverage@v1
with:
file: cover.out
language: Go
`), &node)
if !assert.NoError(t, err) {
return
}
err = (&Node{
Definition: "workflow-root",
Schema: GetWorkflowSchema(),
}).UnmarshalYAML(&node)
assert.NoError(t, err)
}
12 changes: 12 additions & 0 deletions pkg/schema/workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,14 @@
"type": "permission-level-any",
"description": "Check runs and check suites."
},
"artifact-metadata": {
"type": "permission-level-any",
"description": "Work with artifact metadata. For example, artifact-metadata: write permits an action to create storage records on behalf of a build artifact."
},
"code-quality": {
"type": "permission-level-any",
"description": "Work with code quality. For example, code-quality: write permits an action to upload code coverage reports."
},
"contents": {
"type": "permission-level-any",
"description": "Repository contents, commits, branches, downloads, releases, and merges."
Expand Down Expand Up @@ -1335,6 +1343,10 @@
"statuses": {
"type": "permission-level-any",
"description": "Commit statuses."
},
"vulnerability-alerts": {
"type": "permission-level-read-or-no-access",
"description": "Read Dependabot alerts."
}
}
}
Expand Down