diff --git a/docs/compatibility.md b/docs/compatibility.md index 70bffe83..c158afa7 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -876,8 +876,10 @@ context listed below, including `token`, with sorted keys and two-space indentation. The compiler treats that call as a token reference, so normal permissions, -admission, and redaction apply. Composite steps can consume an already -authorized context, but composite metadata cannot grant token authority. A +admission, and redaction apply. A reachable direct `github.token` reference in +a composite shell step's `run`, `env`, or `working-directory` can request the +token. Composite metadata can consume an already authorized serialized context, +but serialization and nested action inputs cannot grant token authority. A tokenless context is an error. Job-level fields and action input defaults cannot call `toJSON(github)`. Bare, @@ -1211,8 +1213,8 @@ JavaScript and Docker actions with compatible bundled cache clients also receive event repository when it: - statically references `secrets.GITHUB_TOKEN` or `github.token`; or -- uses an action whose effective input default can reach `github.token` for the - event provider. +- uses an action whose effective input default or reachable composite shell + step template can reach `github.token` for the event provider. A `github.server_url == 'https://github.com'` guard skips the token branch for an Origin repository. Native adapters ignore upstream input defaults, so diff --git a/docs/plans/expression-authority-planning.md b/docs/plans/expression-authority-planning.md deleted file mode 100644 index 74767a59..00000000 --- a/docs/plans/expression-authority-planning.md +++ /dev/null @@ -1,243 +0,0 @@ -# Expression authority planning - -## Problem - -The compiler and runtime do not use one representation of expression-bearing -execution. The compiler inventories workflow fields in -`requiredSecrets`, inspects action metadata in `inspectInvocation`, and -reconstructs enough runtime behavior to decide whether a job can receive -`github.token`. The runtime reloads action metadata and independently evaluates -input defaults, lifecycle conditions, composite steps, and outputs. - -This makes credential planning depend on duplicated knowledge of: - -- every expression-bearing field -- each field's expression surface and provenance -- ordered input resolution -- lazy operator and function semantics -- action preparation, main, and post ordering - -[PR #339](https://github.com/buildkite/buildkite-gha/pull/339) demonstrates the -cost. Supporting `github.token` in composite metadata required separate -reachability logic for inputs, conditions, pure functions, nested actions, and -preparation. Review repeatedly found cases where planning and execution reached -different branches or fields. - -The security contract requires conservative but precise planning. A known-false -or provider-inapplicable branch must not grant token authority. A branch that -depends on an unknown runtime value may grant it. Composite metadata cannot -grant ordinary secret authority, and whole-context `github` serialization has -different authority rules from direct `github.token` access. - -## Proposed approach - -Use one expression semantic core and one normalized execution program for both -planning and runtime. - -### Abstract expression evaluation - -Extend the existing semantic evaluator with an abstract value domain: - -```go -type AbstractValue struct { - Known bool - Value any -} - -type Analysis struct { - Value AbstractValue - Effects Effects -} - -type Validation struct { - SecretReferences []string -} -``` - -Concrete and abstract evaluation share expression-tree traversal, operator -semantics, coercion, and function argument selection. Abstract context values -are either known or unknown. Unknown branches join their possible effects; -known short-circuits discard effects from branches runtime cannot evaluate. -Implement these as explicit concrete and abstract domains behind the shared -traversal, not by passing unknown sentinel values through concrete `any` value -helpers. - -Reachable authority effects retain provenance. At minimum, distinguish: - -- direct `github.token` -- workflow-authored `toJSON(github)` -- composite-authored `toJSON(github)` - -Validation remains an exhaustive pass over every AST branch. Unsupported -syntax and prohibited authority must fail even when concrete or abstract -evaluation would skip the branch. It also inventories statically named ordinary -secrets independently of reachable effects. `Unknown` means a valid runtime -dependency, not an evaluation or validation failure. - -Keep ordinary secret behavior unchanged during this migration. Validation -inventories statically named workflow secrets even when a known branch skips -their expressions; authority policy retains all existing filters, including the -exception for a secret used only by a declared optional action input, which -resolves as empty unless required elsewhere. Prohibited composite secret -references remain errors in unreachable branches. Reachability only narrows -effects whose existing contract requires it, including `github.token`. Any -later change to ordinary-secret reachability needs a separate compatibility and -security decision. - -The abstract domain must satisfy both soundness and useful precision: - -```text -concrete effects ⊆ abstract effects -fully known abstract effects = concrete effects -effects(refined context) ⊆ effects(less-known context) -``` - -The first property prevents missed authority. The other two prevent an -implementation that always requests every credential from satisfying the -soundness check while violating known-false and provider guard boundaries. - -### Normalized execution program - -After reusable-workflow expansion and action resolution, lower each job and its -resolved action graph into an immutable execution program. Keep the parsed -workflow model as source-oriented compiler input. The normalized program owns -runtime expression sites and their control flow. - -Each expression site records: - -- source expression or template -- fixed expression surface -- expected result type -- workflow or action-metadata provenance -- source location for diagnostics - -Represent lifecycle behavior structurally rather than attaching phase labels -to a flat field list. The program needs operations equivalent to: - -- evaluate a typed or template expression -- guard a sequence of operations -- resolve ordered action inputs and defaults -- invoke an action -- register a post action -- enter preparation, main, and post phases - -The shared program interpreter has two adapters: - -- the planning adapter supplies abstract context values and collects authority -- the runtime adapter supplies concrete values and performs action or command - execution - -This keeps lifecycle ordering in one module while allowing planning and runtime -to perform different effects at its seams. - -Commands and workflow-command mutations are not predictable during planning. -The planning adapter treats their outputs, environment changes, and state as -unknown, then joins later authority conservatively. The operation graph remains -finite and retains the existing workflow, job, step, and nested-action limits. - -Resolved action programs are stored by action lock ID in the job plan. Source -locks continue to bind repositories, commits, paths, and tree digests. Runtime -continues to verify source content and executable entrypoints, but does not -reload metadata to derive a second execution model. Plan validation requires -every action program and child invocation to reference an existing lock, and -the encoded program remains covered by the job-plan digest. - -Reusable workflows continue to flatten before plan construction. Their -expanded jobs then use the same normalization path as direct jobs, eliminating -separate authority treatment for called-workflow fields. - -## Scope - -This work centralizes expression reachability, field ownership, and action -lifecycle ordering. It does not: - -- broaden supported expression syntax or contexts -- change workflow-token permission policy -- allow dynamic or whole-context secret access -- make private actions or reusable workflows available -- replace immutable source verification -- use runtime-only token minting as an authorization decision - -On-demand token resolution may later avoid minting an authorized but unused -token. It cannot replace plan-level authority, capability, or permission -decisions. - -## Delivery slices - -### 1. Share concrete and abstract expression semantics - -Add the abstract value and effect domain under `internal/expression`. Run it -through the existing semantic evaluator's traversal and pure-function -implementation. Preserve all exported evaluation behavior and diagnostics. - -Move token reachability and known-condition cases onto abstract evaluation. -Keep existing compiler field inventories temporarily. Delete replaced -token-specific AST recursion once expression and compiler tests pass unchanged. - -This slice is a behavior-preserving architectural refactor. It removes one -source of semantic drift but does not claim complete field coverage. - -### 2. Normalize workflow execution fields - -Add the normalized program model and lower expanded `JobInstance` values into -it before plan authorization. Include job and step conditions, defaults, -environment, outputs, services, containers, typed controls, and action -invocations. - -Use test-only differential fixtures to compare legacy runtime results with the -program runtime adapter. Do not add production fallback between representations. - -### 3. Normalize resolved actions - -Lower action input declarations, ordered defaults, lifecycle conditions, -composite steps, outputs, and child selectors into action programs keyed by -lock ID. Model preparation separately from main-step reachability, including -environment evaluation before JavaScript `pre-if`, composite child preparation, -conditional post registration, and reverse post execution. - -Derive action authority by abstractly executing this program. Runtime executes -the same program with the concrete adapter. - -### 4. Cut over the plan contract - -Bump the plan schema and require normalized execution programs. Reject plans -that combine normalized authority data with legacy raw execution fields. - -Delete: - -- `requiredSecrets` field enumeration -- `inspectInvocation` lifecycle simulation -- token-specific reachability walkers -- runtime action-metadata interpretation - -Keep source digest and entrypoint verification independent of the normalized -metadata contract. - -## Verification - -Test the expression module through its concrete and abstract interfaces: - -- property and fuzz tests for `concrete effects ⊆ abstract effects` -- exact effect equality for fully known contexts -- monotonic effect narrowing as unknown values become known -- known, unknown, and unavailable context values -- `&&`, `||`, `case()`, and pure-function argument laziness -- direct token and whole-context provenance -- exhaustive validation of unreachable prohibited references - -Test the normalized program through both adapters: - -- every supported workflow expression surface -- GitHub and Origin provider guards -- ordered defaults and forwarded inputs -- nested composite preparation, main, output, and post behavior -- reusable-workflow expansion and deferred inputs -- tokenless jobs and authorized-but-runtime-skipped token paths -- plan encoding, validation, and action source verification - -Run `mise run check` for every slice. Run the GitHub-hosted expression -differential oracle when expression semantics change. - -The architectural acceptance test is that adding an expression-bearing -operation defines its surface and execution position once. Planning and runtime -must inherit it without adding another field-specific authority scanner. diff --git a/internal/cli/hosted.go b/internal/cli/hosted.go index 1fef5920..7b4caa22 100644 --- a/internal/cli/hosted.go +++ b/internal/cli/hosted.go @@ -429,7 +429,7 @@ func githubTokenAdmissionDiagnostic(artifact compiler.PlanArtifact, reason strin if len(quoted) > 1 { actionLabel = "actions" } - causes = append(causes, actionLabel+" "+strings.Join(quoted, ", ")+" defaults an input to github.token") + causes = append(causes, actionLabel+" "+strings.Join(quoted, ", ")+" references github.token in metadata") } if len(causes) == 0 { causes = append(causes, "the compiled job requests a workflow token") diff --git a/internal/compiler/actions.go b/internal/compiler/actions.go index 43e1d9a1..7d6ead53 100644 --- a/internal/compiler/actions.go +++ b/internal/compiler/actions.go @@ -215,12 +215,19 @@ func compileActionLocks(ctx context.Context, workspace string, actionSource Acti } func compileActionInvocations(ctx context.Context, workspace string, actionSource ActionSource, serverURL string, refs []string, suppliedInputs []map[string]string) (actionCompilation, error) { + return compileReachableActionInvocations(ctx, workspace, actionSource, serverURL, refs, suppliedInputs, nil) +} + +func compileReachableActionInvocations(ctx context.Context, workspace string, actionSource ActionSource, serverURL string, refs []string, suppliedInputs []map[string]string, reachable []bool) (actionCompilation, error) { if workspace == "" { return actionCompilation{}, fmt.Errorf("workflow path must identify a repository root") } if suppliedInputs != nil && len(suppliedInputs) != len(refs) { return actionCompilation{}, fmt.Errorf("action references and supplied inputs have different lengths") } + if reachable != nil && len(reachable) != len(refs) { + return actionCompilation{}, fmt.Errorf("action references and reachability have different lengths") + } abs, err := filepath.Abs(workspace) if err != nil { return actionCompilation{}, fmt.Errorf("resolve workspace: %w", err) @@ -256,7 +263,8 @@ func compileActionInvocations(ctx context.Context, workspace string, actionSourc var githubTokenActions []string if suppliedInputs != nil { for i, root := range roots { - requirements, err := root.inspectInvocation(suppliedInputs[i], true, serverURL) + mayRun := reachable == nil || reachable[i] + requirements, err := root.inspectInvocation(suppliedInputs[i], true, serverURL, mayRun, nil) if err != nil { return actionCompilation{}, fmt.Errorf("compile action %q: %w", refs[i], err) } @@ -373,7 +381,7 @@ func (b *actionLockBuilder) add(ctx context.Context, raw string, depth int) (*ac return n, nil } -func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAuthored bool, serverURL string) (actionRequirements, error) { +func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAuthored bool, serverURL string, reachable bool, callerReferences map[string]any) (actionRequirements, error) { requirements := actionRequirements{requiredSecrets: map[string]bool{}} for _, suppliedName := range sortedKeys(supplied) { value := supplied[suppliedName] @@ -439,7 +447,27 @@ func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAutho if n.runtime != metadata.RuntimeComposite { return requirements, nil } + knownReferences := n.knownInputReferences(supplied, serverURL, callerReferences) for i, step := range n.metadata.Runs.Steps { + if step.Uses == "" { + mayRun := reachable && compositeStepMayRun(step.If, knownReferences) + for _, field := range []struct { + name string + value string + }{ + {name: "run", value: step.Run}, + {name: "working-directory", value: step.WorkingDirectory}, + } { + if err := inspectCompositeStepTemplate(fmt.Sprintf("step %d %s", i+1, field.name), field.value, knownReferences, mayRun, &requirements); err != nil { + return actionRequirements{}, err + } + } + for _, name := range sortedKeys(step.Env) { + if err := inspectCompositeStepTemplate(fmt.Sprintf("step %d environment %q", i+1, name), step.Env[name], knownReferences, mayRun, &requirements); err != nil { + return actionRequirements{}, err + } + } + } if step.Uses == "" { continue } @@ -453,7 +481,7 @@ func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAutho return actionRequirements{}, fmt.Errorf("composite action step %d child %q: bounded upload-artifact adapter: %w", i+1, step.Uses, err) } } - childRequirements, err := child.inspectInvocation(step.With, false, serverURL) + childRequirements, err := child.inspectInvocation(step.With, false, serverURL, reachable && compositeStepMayRun(step.If, knownReferences), knownReferences) if err != nil { return actionRequirements{}, fmt.Errorf("composite action step %d child %q: %w", i+1, step.Uses, err) } @@ -465,6 +493,66 @@ func (n *actionNode) inspectInvocation(supplied map[string]string, workflowAutho return requirements, nil } +func (n *actionNode) knownInputReferences(supplied map[string]string, serverURL string, callerReferences map[string]any) map[string]any { + known := map[string]any{ + "github.server_url": serverURL, + "job.check_run_id": "", + } + if callerReferences == nil { + callerReferences = known + } + for _, name := range sortedKeys(supplied) { + value, resolved, err := expression.EvaluateKnownStepTemplate(supplied[name], callerReferences) + if err != nil || !resolved { + continue + } + known["inputs."+strings.ToLower(name)] = value + } + for _, name := range sortedKeys(n.metadata.Inputs) { + input := n.metadata.Inputs[name] + if input.Default == nil || hasActionInput(supplied, name) { + continue + } + value, resolved, err := expression.EvaluateKnownActionInputDefault(*input.Default, known) + if err != nil || !resolved { + continue + } + known["inputs."+strings.ToLower(name)] = value + } + return known +} + +func compositeStepMayRun(condition string, knownReferences map[string]any) bool { + run, err := expression.ConditionMayBeTrue(condition, knownReferences) + return err != nil || run +} + +func inspectCompositeStepTemplate(field, template string, knownReferences map[string]any, reachable bool, requirements *actionRequirements) error { + if template == "" { + return nil + } + referencesEvent, err := expression.TemplateReferencesGitHubEvent(template) + if err != nil { + return fmt.Errorf("composite action %s: %w", field, err) + } + if referencesEvent { + return fmt.Errorf("composite action %s: github.event cannot be retained in a job plan", field) + } + names, err := expression.SecretReferences(template) + if err != nil { + return fmt.Errorf("composite action %s: %w", field, err) + } + if len(names) != 0 { + return fmt.Errorf("composite action %s: composite action metadata cannot grant secret authority", field) + } + referencesToken, err := expression.StepTemplateRequiresGitHubToken(template, knownReferences) + if err != nil { + return fmt.Errorf("composite action %s: %w", field, err) + } + requirements.githubToken = requirements.githubToken || reachable && referencesToken + return nil +} + func hasActionInput(inputs map[string]string, name string) bool { for candidate := range inputs { if strings.EqualFold(candidate, name) { diff --git a/internal/compiler/actions_test.go b/internal/compiler/actions_test.go index 064ac6b6..4236f5aa 100644 --- a/internal/compiler/actions_test.go +++ b/internal/compiler/actions_test.go @@ -445,6 +445,281 @@ runs: } } +func TestCompileActionInvocationsDetectsReachableCompositeShellGitHubToken(t *testing.T) { + workspace := t.TempDir() + writeAction(t, workspace, "token", `name: composite token +inputs: + fallback: + default: cargo-binstall +runs: + using: composite + steps: + - shell: bash + env: + DEFAULT_GITHUB_TOKEN: ${{ inputs.fallback == 'cargo-binstall' && github.server_url == 'https://github.com' && github.token || '' }} + run: test -n "$DEFAULT_GITHUB_TOKEN" +`) + + for _, test := range []struct { + name string + serverURL string + supplied map[string]string + want bool + }{ + {name: "matching default", serverURL: "https://github.com", want: true}, + {name: "matching explicit input", serverURL: "https://github.com", supplied: map[string]string{"fallback": "cargo-binstall"}, want: true}, + {name: "disabled explicit input", serverURL: "https://github.com", supplied: map[string]string{"fallback": "none"}}, + {name: "unknown input", serverURL: "https://github.com", supplied: map[string]string{"fallback": "${{ matrix.fallback }}"}, want: true}, + {name: "Origin provider", serverURL: "https://origin.cursor.com"}, + } { + t.Run(test.name, func(t *testing.T) { + compiled, err := compileActionInvocations(t.Context(), workspace, nil, test.serverURL, []string{"./token"}, []map[string]string{test.supplied}) + if err != nil { + t.Fatal(err) + } + if compiled.requiresGitHubToken != test.want { + t.Fatalf("requires GitHub token = %t, want %t", compiled.requiresGitHubToken, test.want) + } + }) + } +} + +func TestCompileActionInvocationsInspectsCompositeShellTemplates(t *testing.T) { + workspace := t.TempDir() + for _, test := range []struct { + name string + field string + indent string + }{ + {name: "environment", field: "env:\n TOKEN: ${{ github.token }}\n run: echo token", indent: " "}, + {name: "run", field: "run: echo '${{ github.token }}'", indent: " "}, + {name: "working directory", field: "working-directory: ${{ github.token }}\n run: pwd", indent: " "}, + } { + t.Run(test.name, func(t *testing.T) { + writeAction(t, workspace, test.name, "name: token\nruns:\n using: composite\n steps:\n - shell: bash\n"+test.indent+test.field+"\n") + compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./" + test.name}, []map[string]string{{}}) + if err != nil { + t.Fatal(err) + } + if !compiled.requiresGitHubToken { + t.Fatal("composite shell template did not request GitHub token") + } + }) + } +} + +func TestCompileActionInvocationsSkipsKnownFalseCompositeShellStep(t *testing.T) { + workspace := t.TempDir() + writeAction(t, workspace, "token", `name: conditional token +inputs: + enabled: + default: "false" + unrelated: + default: "" +runs: + using: composite + steps: + - if: inputs.enabled == 'true' + shell: bash + env: + TOKEN: ${{ github.token }} + run: echo token +`) + for _, test := range []struct { + enabled string + want bool + }{ + {enabled: "false"}, + {enabled: "true", want: true}, + {enabled: "${{ matrix.enabled }}", want: true}, + } { + compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./token"}, []map[string]string{{"enabled": test.enabled, "unrelated": "${{ matrix.unrelated }}"}}) + if err != nil { + t.Fatal(err) + } + if compiled.requiresGitHubToken != test.want { + t.Fatalf("enabled %q requires GitHub token = %t, want %t", test.enabled, compiled.requiresGitHubToken, test.want) + } + } +} + +func TestCompileActionInvocationsReducesProviderBackedCompositeInputDefault(t *testing.T) { + workspace := t.TempDir() + writeAction(t, workspace, "token", `name: provider-conditional token +inputs: + enabled: + default: ${{ github.server_url == 'https://github.com' && 'true' || 'false' }} + forwarded: + default: ${{ inputs.enabled }} +runs: + using: composite + steps: + - shell: bash + env: + TOKEN: ${{ inputs.forwarded == 'true' && github.token || '' }} + run: echo token +`) + for _, test := range []struct { + name string + serverURL string + supplied map[string]string + }{ + {name: "provider-backed default", serverURL: "https://origin.cursor.com", supplied: map[string]string{}}, + {name: "provider-backed supplied input", serverURL: "https://origin.cursor.com", supplied: map[string]string{"enabled": "${{ github.server_url == 'https://github.com' && 'true' || 'false' }}"}}, + {name: "supplied input forwarded by default", serverURL: "https://github.com", supplied: map[string]string{"enabled": "false"}}, + } { + t.Run(test.name, func(t *testing.T) { + compiled, err := compileActionInvocations(t.Context(), workspace, nil, test.serverURL, []string{"./token"}, []map[string]string{test.supplied}) + if err != nil { + t.Fatal(err) + } + if compiled.requiresGitHubToken { + t.Fatal("known false forwarded input requested a GitHub token") + } + }) + } +} + +func TestCompileActionInvocationsReducesComputedCompositeInputIndexes(t *testing.T) { + workspace := t.TempDir() + writeAction(t, workspace, "token", `name: computed input token +inputs: + enabled: + default: "false" +runs: + using: composite + steps: + - if: inputs[format('{0}', 'enabled')] == 'true' + shell: bash + env: + TOKEN: ${{ inputs[format('{0}', 'enabled')] == 'true' && github.token || '' }} + run: echo token +`) + compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./token"}, []map[string]string{{"enabled": "false"}}) + if err != nil { + t.Fatal(err) + } + if compiled.requiresGitHubToken { + t.Fatal("known false computed input requested a GitHub token") + } +} + +func TestCompileActionInvocationsConstrainsCompositeShellTokenByInvocationReachability(t *testing.T) { + workspace := t.TempDir() + writeAction(t, workspace, "child", `name: child token +runs: + using: composite + steps: + - shell: bash + env: + TOKEN: ${{ github.token }} + run: echo token +`) + writeAction(t, workspace, "parent", `name: parent +runs: + using: composite + steps: + - if: false + uses: ./child +`) + + compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./parent"}, []map[string]string{{}}) + if err != nil { + t.Fatal(err) + } + if compiled.requiresGitHubToken { + t.Fatal("unreachable nested shell requested a GitHub token") + } + + compiled, err = compileReachableActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./child"}, []map[string]string{{}}, []bool{false}) + if err != nil { + t.Fatal(err) + } + if compiled.requiresGitHubToken { + t.Fatal("unreachable workflow invocation shell requested a GitHub token") + } +} + +func TestCompileActionInvocationsPreservesDefaultsForUnreachableInvocation(t *testing.T) { + workspace := t.TempDir() + writeAction(t, workspace, "token", `name: default token +inputs: + token: + default: ${{ github.token }} +runs: + using: composite + steps: [] +`) + compiled, err := compileReachableActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./token"}, []map[string]string{{}}, []bool{false}) + if err != nil { + t.Fatal(err) + } + if !compiled.requiresGitHubToken { + t.Fatal("unreachable invocation dropped input-default token requirement") + } +} + +func TestCompileActionInvocationsEvaluatesNestedInputsAgainstCaller(t *testing.T) { + workspace := t.TempDir() + writeAction(t, workspace, "child", `name: child +inputs: + a: {} + b: {} +runs: + using: composite + steps: + - shell: bash + env: + TOKEN: ${{ inputs.b == 'true' && github.token || '' }} + run: echo token +`) + writeAction(t, workspace, "parent", `name: parent +inputs: + a: + default: "true" +runs: + using: composite + steps: + - uses: ./child + with: + a: "false" + b: ${{ inputs.a }} +`) + compiled, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./parent"}, []map[string]string{{}}) + if err != nil { + t.Fatal(err) + } + if !compiled.requiresGitHubToken { + t.Fatal("nested input evaluated against child values and omitted a required GitHub token") + } +} + +func TestCompileActionInvocationsRejectsAuthorityFromCompositeShellMetadata(t *testing.T) { + workspace := t.TempDir() + for _, test := range []struct { + name string + template string + condition string + want string + }{ + {name: "secret", template: "${{ secrets.DEPLOY_KEY }}", want: "cannot grant secret authority"}, + {name: "event", template: "${{ github.event.action }}", want: "github.event cannot be retained"}, + {name: "unreachable secret", template: "${{ secrets.DEPLOY_KEY }}", condition: "false", want: "cannot grant secret authority"}, + } { + t.Run(test.name, func(t *testing.T) { + condition := "" + if test.condition != "" { + condition = " if: " + test.condition + "\n" + } + writeAction(t, workspace, test.name, "name: invalid authority\nruns:\n using: composite\n steps:\n - shell: bash\n"+condition+" env:\n VALUE: "+test.template+"\n run: echo value\n") + _, err := compileActionInvocations(t.Context(), workspace, nil, "https://github.com", []string{"./" + test.name}, []map[string]string{{}}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("compileActionInvocations() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCompileActionInvocationsAcceptsUnavailableCheckRunIDDefaultWithoutAuthority(t *testing.T) { workspace := t.TempDir() writeAction(t, workspace, "check-run", `name: check run @@ -869,11 +1144,84 @@ jobs: steps: - uses: ./.github/actions/token `) - if err == nil || !strings.Contains(err.Error(), "action input default that references github.token") || !strings.Contains(err.Error(), "no effective permissions") { + if err == nil || !strings.Contains(err.Error(), "resolved action metadata that references github.token") || !strings.Contains(err.Error(), "no effective permissions") { t.Fatalf("compilePlansForTest() error = %v, want empty permission rejection", err) } } +func TestCompilePlansScopesGitHubTokenForCompositeShellMetadata(t *testing.T) { + workspace := t.TempDir() + workflowPath := filepath.Join(workspace, ".github", "workflows", "token.yml") + if err := os.MkdirAll(filepath.Dir(workflowPath), 0o755); err != nil { + t.Fatal(err) + } + writeAction(t, workspace, ".github/actions/token", `name: composite token +runs: + using: composite + steps: + - shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: test -n "$GITHUB_TOKEN" +`) + workflow := `on: push +permissions: + contents: read +jobs: + token: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/token +` + if err := os.WriteFile(workflowPath, []byte(workflow), 0o644); err != nil { + t.Fatal(err) + } + bundle, err := CompileBundleWithOptions(workflowPath, []byte(workflow), pushEvent(t), "0.0.0-test", testDistributionDigest, "importer", defaultOptions()) + if err != nil { + t.Fatal(err) + } + if len(bundle.Plans) != 1 || bundle.Plans[0].Job.GitHubToken == nil || !bundle.Plans[0].Job.HasCapability("provider-token-write") || !reflect.DeepEqual(bundle.Plans[0].Authorization.GitHubTokenActions, []string{"./.github/actions/token"}) { + t.Fatalf("composite metadata token plan = %#v", bundle.Plans) + } + + unreachableWorkflow := `on: push +permissions: {} +jobs: + token: + runs-on: ubuntu-latest + steps: + - if: false + uses: ./.github/actions/token +` + bundle, err = CompileBundleWithOptions(workflowPath, []byte(unreachableWorkflow), pushEvent(t), "0.0.0-test", testDistributionDigest, "importer", defaultOptions()) + if err != nil { + t.Fatal(err) + } + if len(bundle.Plans) != 1 || bundle.Plans[0].Job.GitHubToken != nil || len(bundle.Plans[0].Authorization.GitHubTokenActions) != 0 { + t.Fatalf("unreachable composite metadata token plan = %#v", bundle.Plans) + } + + matrixWorkflow := `on: push +permissions: {} +jobs: + token: + strategy: + matrix: + enabled: [false] + runs-on: ubuntu-latest + steps: + - if: matrix.enabled + uses: ./.github/actions/token +` + bundle, err = CompileBundleWithOptions(workflowPath, []byte(matrixWorkflow), pushEvent(t), "0.0.0-test", testDistributionDigest, "importer", defaultOptions()) + if err != nil { + t.Fatal(err) + } + if len(bundle.Plans) != 1 || bundle.Plans[0].Job.GitHubToken != nil || len(bundle.Plans[0].Authorization.GitHubTokenActions) != 0 { + t.Fatalf("matrix-disabled composite metadata token plan = %#v", bundle.Plans) + } +} + func TestCompileActionLocksRemoteCompositeUsesWorkspaceRoot(t *testing.T) { w, remote := t.TempDir(), t.TempDir() writeAction(t, w, "child", "name: child\nruns:\n using: docker\n image: Dockerfile\n") diff --git a/internal/compiler/plan_builder.go b/internal/compiler/plan_builder.go index d51a30d9..4cc33c54 100644 --- a/internal/compiler/plan_builder.go +++ b/internal/compiler/plan_builder.go @@ -367,7 +367,15 @@ func (b planBuilder) buildActions(instance JobInstance, steps []plan.Step, actio built.capabilities = capabilities return built, nil } - compiled, err := compileActionInvocations(b.ctx, instance.RepositoryRoot, b.actionSource, plan.EventServerURL(b.ir.Event.Provider), actionRefs, actionInputs) + reachable := make([]bool, len(actionIndexes)) + knownReferences := make(map[string]any, len(instance.Matrix)) + for name, value := range instance.Matrix { + knownReferences["matrix."+strings.ToLower(name)] = value + } + for i, stepIndex := range actionIndexes { + reachable[i] = compositeStepMayRun(steps[stepIndex].Condition, knownReferences) + } + compiled, err := compileReachableActionInvocations(b.ctx, instance.RepositoryRoot, b.actionSource, plan.EventServerURL(b.ir.Event.Provider), actionRefs, actionInputs, reachable) if err != nil { return built, fmt.Errorf("build plan for job %q: %w", instance.LogicalJobID, err) } @@ -568,7 +576,7 @@ func (b planBuilder) authorizePlanSecrets(instance JobInstance, workflowProgram policyWorkflow = filepath.Base(instance.SourcePath) } if len(b.ir.Workflow.WorkflowTokenPermissions) == 0 { - reference := "an action input default that references github.token" + reference := "resolved action metadata that references github.token" if referencesGitHubTokenSecret { reference = "secrets.GITHUB_TOKEN" } else if referencesGitHubToken { diff --git a/internal/expression/abstract.go b/internal/expression/abstract.go index 71e230bc..9529b534 100644 --- a/internal/expression/abstract.go +++ b/internal/expression/abstract.go @@ -9,10 +9,12 @@ import ( ) // AbstractValue is either one concrete expression value or an unknown runtime -// value. Unknown is distinct from a known null value. +// value. Unknown is distinct from a known null value and may still have known +// truthiness for logical short-circuiting. type AbstractValue struct { - Known bool - Value any + Known bool + Value any + Truthy *bool } // GitHubTokenEffect records why evaluation can require github.token. @@ -48,6 +50,12 @@ func unknownAnalysis(values ...Analysis) Analysis { return Analysis{Effects: effects} } +func unknownAnalysisWithTruthiness(truthiness bool, values ...Analysis) Analysis { + analysis := unknownAnalysis(values...) + analysis.Value.Truthy = &truthiness + return analysis +} + func derivedAnalysis(value any, inputs ...Analysis) Analysis { analysis := knownAnalysis(value) for _, input := range inputs { @@ -65,24 +73,51 @@ func (abstractExpressionDomain) derive(value any, inputs ...Analysis) Analysis { func (abstractExpressionDomain) value(analysis Analysis) (any, bool) { return analysis.Value.Value, analysis.Value.Known } +func (abstractExpressionDomain) truthiness(analysis Analysis, truthy func(any) bool) (bool, bool) { + if analysis.Value.Known { + return truthy(analysis.Value.Value), true + } + if analysis.Value.Truthy != nil { + return *analysis.Value.Truthy, true + } + return false, false +} func (abstractExpressionDomain) unknown(values ...Analysis) Analysis { return unknownAnalysis(values...) } -func (abstractExpressionDomain) join(values ...Analysis) Analysis { +func (abstractExpressionDomain) unknownWithTruthiness(truthiness bool, values ...Analysis) Analysis { + return unknownAnalysisWithTruthiness(truthiness, values...) +} +func (domain abstractExpressionDomain) join(truthy func(any) bool, values ...Analysis) Analysis { if len(values) == 0 { return Analysis{} } result := unknownAnalysis(values...) first := values[0].Value - if !first.Known { + if first.Known { + identical := true + for _, value := range values[1:] { + if !value.Value.Known || !abstractValuesIdentical(value.Value.Value, first.Value) { + identical = false + break + } + } + if identical { + result.Value = first + return result + } + } + firstTruthy, known := domain.truthiness(values[0], truthy) + if !known { return result } for _, value := range values[1:] { - if !value.Value.Known || !abstractValuesIdentical(value.Value.Value, first.Value) { + valueTruthy, known := domain.truthiness(value, truthy) + if !known || valueTruthy != firstTruthy { return result } } - result.Value = first + result.Value.Truthy = &firstTruthy return result } @@ -120,7 +155,18 @@ func newAbstractEvaluator(surface evaluationSurface) expressionEvaluator[Analysi func analyzeActionInputDefault(node actionlint.ExprNode, knownReferences map[string]any) (Analysis, error) { evaluator := newAbstractEvaluator(actionInputDefaultSurface) - evaluator.resolve = func(root string, path []string) (Analysis, error) { + evaluator.resolve = abstractReferenceResolver(knownReferences) + evaluator.call = func(evaluator *expressionEvaluator[Analysis], node *actionlint.FuncCallNode) (Analysis, error) { + if value, recognized, err := evaluatePureFunction(evaluator, node); recognized { + return value, err + } + return Analysis{}, fmt.Errorf("action input default function %q is unsupported", node.Callee) + } + return evaluator.evaluate(node) +} + +func abstractReferenceResolver(knownReferences map[string]any) func(string, []string) (Analysis, error) { + return func(root string, path []string) (Analysis, error) { if strings.EqualFold(root, "github") && len(path) == 1 && strings.EqualFold(path[0], "token") { analysis := Analysis{Effects: Effects{GitHubToken: GitHubTokenDirect}} if value, ok := knownReferences["github.token"]; ok { @@ -133,11 +179,66 @@ func analyzeActionInputDefault(node actionlint.ExprNode, knownReferences map[str } return Analysis{}, nil } +} + +func analyzeStepTemplate(node actionlint.ExprNode, knownReferences map[string]any) (Analysis, error) { + evaluator := newAbstractEvaluator(stepRuntimeSurface) + evaluator.resolve = abstractReferenceResolver(knownReferences) + evaluator.resolveRoot = func(string) (Analysis, error) { return Analysis{}, nil } + evaluator.resolveComputedIndex = true evaluator.call = func(evaluator *expressionEvaluator[Analysis], node *actionlint.FuncCallNode) (Analysis, error) { + if isToJSONGitHubCall(node) { + return Analysis{Effects: Effects{GitHubToken: GitHubTokenCompositeContext}}, nil + } if value, recognized, err := evaluatePureFunction(evaluator, node); recognized { return value, err } - return Analysis{}, fmt.Errorf("action input default function %q is unsupported", node.Callee) + if strings.EqualFold(node.Callee, "hashFiles") { + arguments := make([]Analysis, 0, len(node.Args)) + for _, argument := range node.Args { + value, err := evaluator.evaluate(argument) + if err != nil { + return Analysis{}, err + } + arguments = append(arguments, value) + } + return unknownAnalysis(arguments...), nil + } + return Analysis{}, fmt.Errorf("step template function %q is unsupported", node.Callee) + } + return evaluator.evaluate(node) +} + +func analyzeCondition(node actionlint.ExprNode, knownReferences map[string]any) (Analysis, error) { + evaluator := newAbstractEvaluator(conditionSurface) + evaluator.resolve = abstractReferenceResolver(knownReferences) + evaluator.resolveRoot = func(string) (Analysis, error) { return Analysis{}, nil } + evaluator.resolveComputedIndex = true + evaluator.call = func(evaluator *expressionEvaluator[Analysis], node *actionlint.FuncCallNode) (Analysis, error) { + if value, recognized, err := evaluatePureFunction(evaluator, node); recognized { + return value, err + } + switch strings.ToLower(node.Callee) { + case "always": + if len(node.Args) == 0 { + return knownAnalysis(true), nil + } + case "success", "failure", "cancelled": + if len(node.Args) == 0 { + return Analysis{}, nil + } + case "hashfiles": + arguments := make([]Analysis, 0, len(node.Args)) + for _, argument := range node.Args { + value, err := evaluator.evaluate(argument) + if err != nil { + return Analysis{}, err + } + arguments = append(arguments, value) + } + return unknownAnalysis(arguments...), nil + } + return Analysis{}, fmt.Errorf("condition function %q is unavailable during planning", node.Callee) } return evaluator.evaluate(node) } diff --git a/internal/expression/abstract_test.go b/internal/expression/abstract_test.go index 3f4406e8..79145e6d 100644 --- a/internal/expression/abstract_test.go +++ b/internal/expression/abstract_test.go @@ -107,6 +107,94 @@ func TestActionInputDefaultAuthorityPreservesRuntimeErrorFallback(t *testing.T) } } +func TestStepTemplateTokenEffectsNarrowWithKnownInputs(t *testing.T) { + template := "${{ inputs.fallback == 'cargo-binstall' && github.server_url == 'https://github.com' && github.token || '' }}" + for _, test := range []struct { + name string + known map[string]any + want bool + }{ + {name: "unknown input", known: map[string]any{"github.server_url": "https://github.com"}, want: true}, + {name: "matching input", known: map[string]any{"inputs.fallback": "cargo-binstall", "github.server_url": "https://github.com"}, want: true}, + {name: "disabled input", known: map[string]any{"inputs.fallback": "none", "github.server_url": "https://github.com"}}, + {name: "Origin provider", known: map[string]any{"inputs.fallback": "cargo-binstall", "github.server_url": "https://origin.cursor.com"}}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := StepTemplateRequiresGitHubToken(template, test.known) + if err != nil || got != test.want { + t.Fatalf("StepTemplateRequiresGitHubToken() = %v, %v, want %v", got, err, test.want) + } + }) + } +} + +func TestStepTemplateWholeGitHubContextDoesNotGrantTokenAuthority(t *testing.T) { + got, err := StepTemplateRequiresGitHubToken("${{ toJSON(github) }}", nil) + if err != nil || got { + t.Fatalf("StepTemplateRequiresGitHubToken() = %v, %v, want false", got, err) + } +} + +func TestStepTemplateFunctionsPreserveTokenReachability(t *testing.T) { + for _, test := range []struct { + template string + want bool + }{ + {template: "${{ hashFiles(github.token) }}", want: true}, + {template: "${{ format('constant', github.token) }}"}, + } { + got, err := StepTemplateRequiresGitHubToken(test.template, nil) + if err != nil || got != test.want { + t.Fatalf("StepTemplateRequiresGitHubToken(%q) = %v, %v, want %v", test.template, got, err, test.want) + } + } +} + +func TestStepTemplateKnownRightGuardPrunesUnknownLeft(t *testing.T) { + for _, template := range []string{ + "${{ env.RUNTIME == 'yes' && inputs.enabled == 'true' && github.token || '' }}", + "${{ true && (env.RUNTIME == 'yes' && inputs.enabled == 'true') && github.token || '' }}", + "${{ case(env.SELECT == 'yes', env.RUNTIME == 'yes' && false, false) && github.token || '' }}", + "${{ case(env.RUNTIME == 'yes' && inputs.enabled == 'true', github.token, '') }}", + } { + got, err := StepTemplateRequiresGitHubToken(template, map[string]any{"inputs.enabled": "false"}) + if err != nil || got { + t.Errorf("StepTemplateRequiresGitHubToken(%q) = %v, %v, want false", template, got, err) + } + } +} + +func TestConditionMayBeTrueUsesKnownReferencesAfterUnknownValues(t *testing.T) { + known := map[string]any{ + "inputs.enabled": "false", + "github.server_url": "https://origin.cursor.com", + } + for _, condition := range []string{ + "env.RUNTIME == 'yes' && inputs.enabled == 'true'", + "env.RUNTIME == 'yes' && github.server_url == 'https://github.com'", + "success() && inputs.enabled == 'true'", + } { + mayRun, err := ConditionMayBeTrue(condition, known) + if err != nil || mayRun { + t.Errorf("ConditionMayBeTrue(%q) = %v, %v, want false", condition, mayRun, err) + } + } +} + +func TestEvaluateKnownActionInputDefaultUsesProviderValues(t *testing.T) { + value, known, err := EvaluateKnownActionInputDefault( + "${{ github.server_url == 'https://github.com' && 'true' || 'false' }}", + map[string]any{"github.server_url": "https://origin.cursor.com"}, + ) + if err != nil || !known || value != "false" { + t.Fatalf("EvaluateKnownActionInputDefault() = %q, %v, %v, want false, true", value, known, err) + } + _, known, err = EvaluateKnownActionInputDefault("${{ matrix.enabled }}", map[string]any{"github.server_url": "https://github.com"}) + if err != nil || known { + t.Fatalf("runtime-dependent EvaluateKnownActionInputDefault() known = %v, error = %v", known, err) + } +} + func TestAbstractActionInputDefaultIsSoundAsValuesBecomeKnown(t *testing.T) { for _, source := range []string{ "matrix.enabled && github.token || ''", diff --git a/internal/expression/action_input_default.go b/internal/expression/action_input_default.go index 32759037..f404cb3f 100644 --- a/internal/expression/action_input_default.go +++ b/internal/expression/action_input_default.go @@ -79,6 +79,24 @@ func EvaluateActionInputDefault(template string, context Context) (string, error return evaluateRuntimeTemplate(template, context, evaluateActionInputDefaultNode) } +// EvaluateKnownActionInputDefault evaluates a metadata default when immutable +// planning references are sufficient. Runtime-dependent defaults report unknown. +func EvaluateKnownActionInputDefault(template string, knownReferences map[string]any) (string, bool, error) { + known := true + value, err := evaluateRuntimeTemplate(template, Context{}, func(node actionlint.ExprNode, _ Context) (any, error) { + analysis, err := analyzeActionInputDefault(node, knownReferences) + if err != nil { + return nil, err + } + if !analysis.Value.Known { + known = false + return "", nil + } + return analysis.Value.Value, nil + }) + return value, known, err +} + func isDirectRunnerDebug(node actionlint.ExprNode, root string, path []string) bool { _, direct := node.(*actionlint.ObjectDerefNode) return direct && strings.EqualFold(root, "runner") && len(path) == 1 && strings.EqualFold(path[0], "debug") diff --git a/internal/expression/condition.go b/internal/expression/condition.go index 7b6df9cc..bfec0730 100644 --- a/internal/expression/condition.go +++ b/internal/expression/condition.go @@ -425,6 +425,21 @@ func EvaluateCondition(source string, context ConditionContext) (bool, error) { return result, nil } +// ConditionMayBeTrue reports whether a condition can run when only immutable +// planning references are known. Runtime-dependent values remain unknown. +func ConditionMayBeTrue(source string, knownReferences map[string]any) (bool, error) { + node, empty, err := parseCondition(source) + if err != nil || empty { + return true, err + } + analysis, err := analyzeCondition(node, knownReferences) + if err != nil { + return true, nil + } + truthy, known := (abstractExpressionDomain{}).truthiness(analysis, githubTruthy) + return !known || truthy, nil +} + func evaluateConditionNode(node actionlint.ExprNode, context ConditionContext) (any, error) { evaluator := newSemanticEvaluator(conditionSurface) rootValues := make(map[string]any) diff --git a/internal/expression/evaluator.go b/internal/expression/evaluator.go index 29771447..56988abf 100644 --- a/internal/expression/evaluator.go +++ b/internal/expression/evaluator.go @@ -15,16 +15,17 @@ import ( // runtime evaluators; abstract evaluation uses the same traversal with a value // that can represent unknown runtime data and authority effects. type expressionEvaluator[T any] struct { - policy evaluationPolicy - resolve func(string, []string) (T, error) - resolveRoot func(string) (T, error) - domain expressionDomain[T] - truthy func(any) bool - validateCompare func(actionlint.CompareOpNodeKind) error - compare func(actionlint.CompareOpNodeKind, any, any) (any, error) - call func(*expressionEvaluator[T], *actionlint.FuncCallNode) (T, error) - unsupported func(actionlint.ExprNode) error - logicalError func(actionlint.LogicalOpNodeKind) error + policy evaluationPolicy + resolve func(string, []string) (T, error) + resolveRoot func(string) (T, error) + resolveComputedIndex bool + domain expressionDomain[T] + truthy func(any) bool + validateCompare func(actionlint.CompareOpNodeKind) error + compare func(actionlint.CompareOpNodeKind, any, any) (any, error) + call func(*expressionEvaluator[T], *actionlint.FuncCallNode) (T, error) + unsupported func(actionlint.ExprNode) error + logicalError func(actionlint.LogicalOpNodeKind) error } type semanticEvaluator = expressionEvaluator[any] @@ -33,8 +34,10 @@ type expressionDomain[T any] interface { known(any) T derive(any, ...T) T value(T) (any, bool) + truthiness(T, func(any) bool) (bool, bool) unknown(...T) T - join(...T) T + unknownWithTruthiness(bool, ...T) T + join(func(any) bool, ...T) T } type concreteExpressionDomain struct{} @@ -42,10 +45,16 @@ type concreteExpressionDomain struct{} func (concreteExpressionDomain) known(value any) any { return value } func (concreteExpressionDomain) derive(value any, _ ...any) any { return value } func (concreteExpressionDomain) value(value any) (any, bool) { return value, true } +func (concreteExpressionDomain) truthiness(value any, truthy func(any) bool) (bool, bool) { + return truthy(value), true +} func (concreteExpressionDomain) unknown(...any) any { panic("concrete expression evaluation produced an unknown value") } -func (concreteExpressionDomain) join(...any) any { +func (concreteExpressionDomain) unknownWithTruthiness(bool, ...any) any { + panic("concrete expression evaluation produced an unknown value") +} +func (concreteExpressionDomain) join(func(any) bool, ...any) any { panic("concrete expression evaluation joined multiple paths") } @@ -92,6 +101,9 @@ func (e *expressionEvaluator[T]) result(value T, inputs ...T) T { concrete, known := e.domain.value(value) inputs = append(inputs, value) if !known { + if truthy, truthKnown := e.domain.truthiness(value, e.truthy); truthKnown { + return e.domain.unknownWithTruthiness(truthy, inputs...) + } return e.domain.unknown(inputs...) } return e.domain.derive(concrete, inputs...) @@ -245,11 +257,11 @@ func (e *expressionEvaluator[T]) evaluate(node actionlint.ExprNode) (T, error) { if err != nil { return zero, err } - concrete, known := e.domain.value(value) + truthy, known := e.domain.truthiness(value, e.truthy) if !known { return e.domain.unknown(value), nil } - return e.domain.derive(!e.truthy(concrete), value), nil + return e.domain.derive(!truthy, value), nil } case *actionlint.LogicalOpNode: if e.policy.allowLogical { @@ -257,15 +269,29 @@ func (e *expressionEvaluator[T]) evaluate(node actionlint.ExprNode) (T, error) { if err != nil { return zero, err } - leftValue, leftKnown := e.domain.value(left) + leftTruthy, leftKnown := e.domain.truthiness(left, e.truthy) if !leftKnown { right, err := e.evaluate(node.Right) if err != nil { return zero, err } + rightTruthy, rightKnown := e.domain.truthiness(right, e.truthy) + if rightKnown { + switch node.Kind { + case actionlint.LogicalOpNodeKindAnd: + if !rightTruthy { + return e.domain.unknownWithTruthiness(false, left, right), nil + } + case actionlint.LogicalOpNodeKindOr: + if rightTruthy { + return e.domain.unknownWithTruthiness(true, left, right), nil + } + default: + return zero, e.logicalError(node.Kind) + } + } return e.domain.unknown(left, right), nil } - leftTruthy := e.truthy(leftValue) switch node.Kind { case actionlint.LogicalOpNodeKindAnd: if !leftTruthy { @@ -289,11 +315,11 @@ func (e *expressionEvaluator[T]) evaluate(node actionlint.ExprNode) (T, error) { return zero, err } if e.policy.logicalBool { - rightValue, rightKnown := e.domain.value(right) + rightTruthy, rightKnown := e.domain.truthiness(right, e.truthy) if !rightKnown { return e.domain.unknown(left, right), nil } - return e.domain.derive(e.truthy(rightValue), left, right), nil + return e.domain.derive(rightTruthy, left, right), nil } return e.result(right, left), nil } @@ -384,6 +410,25 @@ func (e *expressionEvaluator[T]) evaluateAccess(node actionlint.ExprNode) (T, er } return e.domain.derive(nil, receiver), nil case *actionlint.IndexAccessNode: + if root, ok := node.Operand.(*actionlint.VariableNode); ok && e.resolveComputedIndex { + index, err := e.evaluate(node.Index) + if err != nil { + return zero, err + } + indexValue, known := e.domain.value(index) + if !known { + return e.domain.unknown(index), nil + } + name, ok := indexValue.(string) + if !ok { + return e.domain.unknown(index), nil + } + value, err := e.resolve(root.Name, []string{name}) + if err != nil { + return zero, err + } + return e.result(value, index), nil + } operand, err := e.evaluateAccess(node.Operand) if err != nil { return zero, err diff --git a/internal/expression/pure_functions.go b/internal/expression/pure_functions.go index b24e1148..2f0e7b2e 100644 --- a/internal/expression/pure_functions.go +++ b/internal/expression/pure_functions.go @@ -268,8 +268,9 @@ func evaluateCaseFunction[T any](evaluator *expressionEvaluator[T], node *action if err != nil { return zero, err } - predicateValue, known := evaluator.domain.value(predicate) - if !known { + predicateValue, valueKnown := evaluator.domain.value(predicate) + predicateTruthy, truthKnown := evaluator.domain.truthiness(predicate, evaluator.truthy) + if !valueKnown && !truthKnown { selected, err := evaluator.evaluate(node.Args[index+1]) if err != nil { return zero, err @@ -278,11 +279,15 @@ func evaluateCaseFunction[T any](evaluator *expressionEvaluator[T], node *action if err != nil { return zero, err } - return evaluator.result(evaluator.domain.join(selected, remainder), predicate), nil + return evaluator.result(evaluator.domain.join(evaluator.truthy, selected, remainder), predicate), nil } - selected, ok := predicateValue.(bool) - if !ok { - return zero, fmt.Errorf("function %q predicate %d resolved to %T, want boolean", node.Callee, index/2+1, predicateValue) + selected := predicateTruthy + if valueKnown { + var ok bool + selected, ok = predicateValue.(bool) + if !ok { + return zero, fmt.Errorf("function %q predicate %d resolved to %T, want boolean", node.Callee, index/2+1, predicateValue) + } } if selected { value, err := evaluator.evaluate(node.Args[index+1]) diff --git a/internal/expression/runtime.go b/internal/expression/runtime.go index fe2be4fe..540ea9e4 100644 --- a/internal/expression/runtime.go +++ b/internal/expression/runtime.go @@ -198,6 +198,48 @@ func EvaluateStep(template string, context Context) (string, error) { return evaluateRuntimeTemplate(template, context, evaluateStepRuntimeNode) } +// EvaluateKnownStepTemplate evaluates a step template when immutable planning +// references are sufficient. Runtime-dependent templates report unknown. +func EvaluateKnownStepTemplate(template string, knownReferences map[string]any) (string, bool, error) { + known := true + value, err := evaluateRuntimeTemplate(template, Context{}, func(node actionlint.ExprNode, _ Context) (any, error) { + analysis, err := analyzeStepTemplate(node, knownReferences) + if err != nil { + return nil, err + } + if !analysis.Value.Known { + known = false + return "", nil + } + return analysis.Value.Value, nil + }) + return value, known, err +} + +// StepTemplateRequiresGitHubToken reports whether a composite-authored step +// template can reach a direct github.token reference. knownReferences may +// contain immutable planning values such as inputs. and +// github.server_url; every other runtime value remains unknown. +func StepTemplateRequiresGitHubToken(template string, knownReferences map[string]any) (bool, error) { + referencesToken, err := ReferencesCompositeStepGitHubToken(template) + if err != nil || !referencesToken { + return referencesToken, err + } + requiresToken := false + err = visitTemplateExpressions(template, func(node actionlint.ExprNode) error { + analysis, analysisErr := analyzeStepTemplate(node, knownReferences) + if analysisErr != nil { + // Runtime-dependent failures cannot prove the token branch + // unreachable, so preserve conservative authority. + requiresToken = true + return nil + } + requiresToken = requiresToken || analysis.Effects.GitHubToken&GitHubTokenDirect != 0 + return nil + }) + return requiresToken, err +} + // EvaluateStepControl evaluates one complete expression for a typed workflow // step control. func EvaluateStepControl(expression string, context Context) (any, error) { diff --git a/internal/program/evaluate.go b/internal/program/evaluate.go index 534708a6..6947d416 100644 --- a/internal/program/evaluate.go +++ b/internal/program/evaluate.go @@ -14,9 +14,7 @@ type EvaluationContext struct { Condition expression.ConditionContext } -// EvaluateSite applies the concrete runtime semantics selected by a site. -// The normalized interpreter and the legacy runtime can therefore be tested -// against the same expression inputs before the plan-schema cutover. +// EvaluateSite applies the concrete expression semantics selected by a site. func EvaluateSite(site Site, context EvaluationContext) (any, error) { var value any var err error diff --git a/internal/program/program.go b/internal/program/program.go index 519a0334..585001d5 100644 --- a/internal/program/program.go +++ b/internal/program/program.go @@ -1,5 +1,5 @@ -// Package program defines the normalized workflow execution model shared by -// compilation, authority planning, and, after the plan-schema cutover, runtime. +// Package program defines the normalized workflow model shared by compilation, +// authority planning, and plan projection. package program // Surface selects the expression semantics for one execution site. @@ -28,8 +28,7 @@ const ( ResultObject ResultType = "object" ) -// Provenance records who authored an expression. Action metadata gains its own -// provenance when resolved actions are normalized in the next delivery slice. +// Provenance records who authored an expression. type Provenance string const ProvenanceWorkflow Provenance = "workflow"