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
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,8 @@ when it runs inside a Buildkite job.
```

The snapshot supplies compile-time context. Plans retain the event name,
repository, refs, SHA, actor, and a payload digest. They do not retain the
payload itself, so runtime expressions cannot use `github.event`.
repository, refs, SHA, actor, and a payload digest. A plan also retains the
payload when its job needs whole or runtime-selected `github.event` access.

The snapshot is compatibility data, not authorization.

Expand Down
35 changes: 19 additions & 16 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,8 @@ Conditions support computed object indexes, numeric array indexes, whole
- Projections omit missing children.
- A later wildcard flattens one collection level.
- The equivalent `[*]` spelling is unsupported by the parser.
- Whole or dynamic `github`, whole `inputs`, and `strategy` remain unsupported.
- Whole or dynamic `github`, except event-rooted access, and whole `inputs` and
`strategy` remain unsupported.

| Context | Job `if` | Step `if` |
| --- | --- | --- |
Expand All @@ -750,16 +751,15 @@ Conditions support computed object indexes, numeric array indexes, whole
| `steps.<id>.outcome`, `steps.<id>.conclusion`, `steps.<id>.outputs.<name>` | ❌ No | ✅ Yes |
| `env.<name>` | ❌ No | ✅ Yes |
| `job.services.<service>.ports[<port>]` | ❌ No | ✅ Yes |
| `github.event.*`, including `github.event.pull_request.*` | 🟡 Compile time only | 🟡 Compile time only |
| `github.event`, including direct, projected, and dynamically indexed properties | ✅ Yes | ✅ Yes |
| `secrets` and other contexts | ❌ No | ❌ No |

Before runtime validation, the compiler reduces event-backed conditions from the
immutable snapshot. Resolvable `github.event` expressions become literals;
supported runtime expressions remain for the job or step.
immutable snapshot. Resolvable `github.event` expressions become literals.
Whole or runtime-selected event access remains in the plan with the payload.

Every branch is validated first, so short-circuiting cannot hide an unsupported
function, context, or matrix type. No remaining condition can carry
`github.event` into runtime.
function, context, or matrix type.

Reusable-workflow call conditions use the same operators and status functions but only the caller contexts listed in [Reusable workflows](#reusable-workflows). The runtime evaluates their ordered guards before the called job's own condition.

Expand All @@ -780,9 +780,10 @@ Before creating a job plan, the compiler resolves scalar `github.event.*`
values and event-dependent parts of otherwise runtime expressions.

Missing event members become null; template interpolation renders null as an
empty string. Event values cannot introduce new `${{ ... }}` regions. Whole
events and unresolved event references are unsupported because plans keep only
event identity and a payload digest.
empty string. Event values cannot introduce new `${{ ... }}` regions. A job
that still needs whole, projected, or dynamically indexed `github.event`
access retains the payload in its immutable plan for runtime use and retries.
Other jobs keep only event identity and a payload digest.

Job-level expressions support the same operators and pure functions with these field-specific contexts:

Expand Down Expand Up @@ -814,7 +815,8 @@ tokenless context is an error.

Job-level fields and action input defaults cannot call `toJSON(github)`. Bare,
projected, or dynamically indexed `github`, and passing the whole context to
another function, remain unsupported.
another function, remain unsupported. These limits do not apply to access
rooted at `github.event`.

`runner.os` and `runner.arch` resolve to `Linux`/`X64` or `macOS`/`ARM64`.
After runner setup, step runtime fields and job outputs can also use
Expand Down Expand Up @@ -845,9 +847,9 @@ The runtime retains this bounded `github` context:
| `action_path` | Composite action directory inside composite steps; empty elsewhere. |
| `action_repository`, `action_ref` | Remote composite repository and requested ref; empty for local composites and outside composite steps. |
| `token` | Available only in an authorized step expression. |
| `event` | The immutable event payload, only in plans that need runtime event access. |

This is not the full GitHub context. `github.event` and the event payload are
not available at runtime.
This is not the full GitHub context.

`hashFiles()` evaluates when its step field is consumed, so it sees files from
earlier steps such as checkout. A JavaScript action's `with` and `env` can be
Expand Down Expand Up @@ -881,9 +883,10 @@ Compile-time `github` fields are `actor`, `base_ref`, `event_name`, `head_ref`,
`workflow`. Expressions can use computed indexes, numeric array indexes, and
`.*` projections when the complete result resolves during compilation.

Whole or dynamic `github` access and whole-event serialization remain
unsupported. Event-backed runtime expressions can combine reducible event
parts with values supported by their runtime surface.
Whole or dynamic `github` access remains unsupported unless it is rooted at
`github.event`. Event-backed runtime expressions can combine reducible event
parts with values supported by their runtime surface. Action references in
`uses` must remain static and cannot use `github.event`.

## Actions

Expand Down Expand Up @@ -932,7 +935,7 @@ Action metadata parsing remains strict for every other unknown top-level field a

Pre, main, and post phases; inputs; outputs; state; and LIFO post ordering are supported. Other Node declarations are rejected.

JavaScript action `pre-if` and `post-if` metadata uses the condition operators, status functions, pure functions, and `hashFiles()` described in [Conditions](#conditions). Lifecycle conditions can read direct properties from workflow `inputs`, `env`, `github`, `job.services`, `matrix`, `runner`, and `steps`. Other contexts and dynamic or whole-context access return an error. An empty lifecycle condition always runs and does not receive an implicit `success()` guard.
JavaScript action `pre-if` and `post-if` metadata uses the condition operators, status functions, pure functions, and `hashFiles()` described in [Conditions](#conditions). Lifecycle conditions can read direct properties from workflow `inputs`, `env`, `github`, `job.services`, `matrix`, `runner`, and `steps`, and direct or dynamic `github.event` properties. Other contexts and dynamic or whole-context access return an error. An empty lifecycle condition always runs and does not receive an implicit `success()` guard.

Pre conditions use the status and action-scoped environment available when preparation reaches the action. Post conditions run during job teardown and use the final job status and environment, including `GITHUB_ENV` changes from main. Root action posts also see final workflow step state. Nested composite actions retain their isolated step context. Cancellation remains distinct from failure, and posts keep LIFO order.

Expand Down
15 changes: 13 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ trusted or grant credentials.
Explicit and generated event snapshots provide compatibility context. They do
not authorize path-filter admission, queues, secrets, or tokens.

The compiler resolves ordinary scalar `github.event.*` references before it
creates a plan. If a job needs the whole event or a property selected at
runtime, its plan retains the event payload so retries remain self-contained.
The retained payload is limited to 25 MiB and verified against the event digest.
It has the same retention and access boundary as the immutable job plan and
Buildkite build.

The snapshot remains untrusted input. The compiler does not add issued tokens,
resolved secrets, registry credentials, OIDC tokens, or internal admission
metadata to it. Those values stay on their separate credential boundaries.

### Reusable-workflow guards

Reusable-workflow call conditions become immutable plan guards. They run in the
Expand Down Expand Up @@ -132,8 +143,8 @@ runtime registers the token with both Buildkite Agent redaction and local
redaction.

The serialized context contains only the fields listed in the
[compatibility reference](compatibility.md#runtime-interpolation). It does not
contain `github.event` or the full event payload.
[compatibility reference](compatibility.md#runtime-interpolation). A plan also
contains `github.event` when that job requires runtime event access.

For non-pull-request builds, a user who can create a build at any commit may
choose code that requests the workflow's allowed permissions. Enable write
Expand Down
89 changes: 74 additions & 15 deletions internal/compiler/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,19 @@ type actionNode struct {
}

type actionCompilation struct {
selectors []plan.ActionSelector
locks []plan.ActionLock
capabilities []string
requiredSecrets []string
githubTokenActions []string
requiresMise bool
requiresGitHubToken bool
selectors []plan.ActionSelector
locks []plan.ActionLock
capabilities []string
requiredSecrets []string
githubTokenActions []string
requiresMise bool
requiresGitHubToken bool
requiresEventPayload bool
}

type actionRequirements struct {
githubToken bool
eventPayload bool
requiredSecrets map[string]bool
}

Expand Down Expand Up @@ -252,6 +254,7 @@ func compileActionInvocations(ctx context.Context, workspace string, actionSourc
}
sort.Strings(caps)
requiresGitHubToken := false
requiresEventPayload := false
requiredSecrets := map[string]bool{}
var githubTokenActions []string
if suppliedInputs != nil {
Expand All @@ -261,6 +264,7 @@ func compileActionInvocations(ctx context.Context, workspace string, actionSourc
return actionCompilation{}, fmt.Errorf("compile action %q: %w", refs[i], err)
}
requiresGitHubToken = requiresGitHubToken || requirements.githubToken
requiresEventPayload = requiresEventPayload || requirements.eventPayload
if requirements.githubToken {
githubTokenActions = append(githubTokenActions, refs[i])
}
Expand All @@ -271,13 +275,14 @@ func compileActionInvocations(ctx context.Context, workspace string, actionSourc
}
secretNames := sortedKeys(requiredSecrets)
return actionCompilation{
selectors: selectors,
locks: locks,
capabilities: caps,
requiredSecrets: secretNames,
githubTokenActions: githubTokenActions,
requiresMise: b.requiresMise,
requiresGitHubToken: requiresGitHubToken,
selectors: selectors,
locks: locks,
capabilities: caps,
requiredSecrets: secretNames,
githubTokenActions: githubTokenActions,
requiresMise: b.requiresMise,
requiresGitHubToken: requiresGitHubToken,
requiresEventPayload: requiresEventPayload,
}, nil
}

Expand Down Expand Up @@ -372,14 +377,62 @@ func (b *actionLockBuilder) add(ctx context.Context, raw string, depth int) (*ac

func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAuthored bool, serverURL string) (actionRequirements, error) {
requirements := actionRequirements{requiredSecrets: map[string]bool{}}
for _, condition := range []string{n.metadata.Runs.PreIf, n.metadata.Runs.PostIf} {
referencesEvent, err := expression.ConditionReferencesGitHubEventPayload(condition)
if err != nil {
return actionRequirements{}, err
}
requirements.eventPayload = requirements.eventPayload || referencesEvent
Comment thread
lox marked this conversation as resolved.
}
inspectTemplate := func(value string) error {
referencesEvent, err := expression.TemplateReferencesGitHubEvent(value)
if err != nil {
return err
}
requirements.eventPayload = requirements.eventPayload || referencesEvent
return nil
}
if n.runtime == metadata.RuntimeDocker {
for _, name := range sortedKeys(n.metadata.Runs.Env) {
if err := inspectTemplate(n.metadata.Runs.Env[name]); err != nil {
return actionRequirements{}, err
}
}
}
if n.runtime == metadata.RuntimeComposite {
for _, name := range sortedKeys(n.metadata.Outputs) {
if err := inspectTemplate(n.metadata.Outputs[name].Value); err != nil {
return actionRequirements{}, err
}
}
for _, step := range n.metadata.Runs.Steps {
referencesEvent, err := expression.ConditionReferencesGitHubEventPayload(step.If)
if err != nil {
return actionRequirements{}, err
}
requirements.eventPayload = requirements.eventPayload || referencesEvent
for _, value := range []string{step.Run, step.WorkingDirectory} {
if err := inspectTemplate(value); err != nil {
return actionRequirements{}, err
}
}
for _, values := range []map[string]string{step.Env, step.With} {
for _, name := range sortedKeys(values) {
if err := inspectTemplate(values[name]); err != nil {
return actionRequirements{}, err
}
}
}
}
}
for _, suppliedName := range sortedKeys(supplied) {
value := supplied[suppliedName]
referencesEvent, err := expression.TemplateReferencesGitHubEvent(value)
if err != nil {
return actionRequirements{}, fmt.Errorf("action input %q: %w", suppliedName, err)
}
if referencesEvent {
return actionRequirements{}, fmt.Errorf("action input %q: github.event cannot be retained in a job plan", suppliedName)
requirements.eventPayload = true
}
names, err := expression.SecretReferences(value)
if err != nil {
Expand Down Expand Up @@ -420,6 +473,11 @@ func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAutho
if err := expression.ValidateActionInputDefault(*input.Default); err != nil {
return actionRequirements{}, fmt.Errorf("action input %q default: %w", name, err)
}
referencesEvent, err := expression.TemplateReferencesGitHubEvent(*input.Default)
Comment thread
lox marked this conversation as resolved.
if err != nil {
return actionRequirements{}, fmt.Errorf("action input %q default: %w", name, err)
}
requirements.eventPayload = requirements.eventPayload || referencesEvent
referencesToken, err := expression.ActionInputDefaultRequiresGitHubToken(*input.Default, serverURL)
if err != nil {
return actionRequirements{}, fmt.Errorf("action input %q default: %w", name, err)
Expand Down Expand Up @@ -455,6 +513,7 @@ func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAutho
return actionRequirements{}, fmt.Errorf("composite action step %d child %q: %w", i+1, step.Uses, err)
}
requirements.githubToken = requirements.githubToken || childRequirements.githubToken
requirements.eventPayload = requirements.eventPayload || childRequirements.eventPayload
for name := range childRequirements.requiredSecrets {
requirements.requiredSecrets[name] = true
}
Expand Down
86 changes: 82 additions & 4 deletions internal/compiler/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ runs:
}
}

func TestCompileActionInvocationsRejectsRetainedEventPayload(t *testing.T) {
func TestCompileActionInvocationsRequiresRetainedEventPayload(t *testing.T) {
workspace := t.TempDir()
writeAction(t, workspace, "event", `name: event input
inputs:
Expand All @@ -560,12 +560,90 @@ runs:
using: node24
main: index.js
`)
_, err := compileActionInvocations(
compiled, err := compileActionInvocations(
t.Context(), workspace, nil, "https://github.com", []string{"./event"},
[]map[string]string{{"action": "${{ github.event.action }}"}},
)
if err == nil || !strings.Contains(err.Error(), "github.event cannot be retained in a job plan") {
t.Fatalf("retained action input event error = %v", err)
if err != nil {
t.Fatal(err)
}
if !compiled.requiresEventPayload {
t.Fatal("action input event did not require the retained event payload")
}
}

func TestCompileActionInvocationsRequiresPayloadForDynamicEventDefault(t *testing.T) {
workspace := t.TempDir()
writeAction(t, workspace, "event", `name: event default
inputs:
field:
default: action
value:
default: ${{ github.event[inputs.field] }}
runs:
using: node24
main: index.js
`)
compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./event"}, []map[string]string{nil})
if err != nil {
t.Fatal(err)
}
if !compiled.requiresEventPayload {
t.Fatal("dynamic action input default did not require the retained event payload")
}
}

func TestCompileActionInvocationsRequiresEventPayloadForLifecycleCondition(t *testing.T) {
workspace := t.TempDir()
writeAction(t, workspace, "event", `name: event lifecycle
runs:
using: node24
main: index.js
post: index.js
post-if: github.event[env.EVENT_FIELD] == 'opened'
`)
compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./event"}, []map[string]string{nil})
if err != nil {
t.Fatal(err)
}
if !compiled.requiresEventPayload {
t.Fatal("action lifecycle event did not require the retained event payload")
}
}

func TestCompileActionInvocationsRequiresEventPayloadForCompositeMetadata(t *testing.T) {
workspace := t.TempDir()
writeAction(t, workspace, "event", `name: event composite
runs:
using: composite
steps:
- shell: bash
run: echo '${{ toJSON(github.event) }}'
`)
compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./event"}, []map[string]string{nil})
if err != nil {
t.Fatal(err)
}
if !compiled.requiresEventPayload {
t.Fatal("composite metadata event did not require the retained event payload")
}
}

func TestCompileActionInvocationsDoesNotRetainPayloadForEventIdentity(t *testing.T) {
workspace := t.TempDir()
writeAction(t, workspace, "identity", `name: identity lifecycle
runs:
using: node24
main: index.js
post: index.js
post-if: github.ref_name == 'main'
`)
compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./identity"}, []map[string]string{nil})
if err != nil {
t.Fatal(err)
}
if compiled.requiresEventPayload {
t.Fatal("event identity unnecessarily required the retained event payload")
}
}

Expand Down
Loading