From f612d69e5e5c7190c3fbbf1510ecb9f8ed66a4d2 Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Sun, 30 Aug 2026 11:56:19 +0000 Subject: [PATCH] Expose GitHub-compatible expression contexts at every workflow phase Define one phase-aware availability contract for workflow validation, planning, and runner evaluation. Populate documented GitHub, job, strategy, action, steps, needs, and environment context values, including rerun triggering actors and current job status in action metadata expressions. Defer needs-dependent job planning safely across controller retries and preserve stored planning wire compatibility. --- api/v1alpha1/labels.go | 4 +- api/v1alpha1/workflowrun_types.go | 8 + cmd/open-actions-runner/main_test.go | 2 + docs/reference.md | 184 ++++++---- internal/controller/runner_controller_test.go | 1 + internal/controller/workflowrun_controller.go | 332 ++++++++++-------- .../controller/workflowrun_controller_test.go | 244 +++++++++++-- internal/eventsnapshot/snapshot.go | 1 + internal/eventsnapshot/snapshot_test.go | 7 +- internal/expression/expression_test.go | 12 + internal/expression/value.go | 7 + .../crds/actions.kelos.dev_workflowruns.yaml | 8 + internal/runner/action.go | 60 +++- internal/runner/composite.go | 100 +++++- internal/runner/expression.go | 179 ++++++---- internal/runner/runner.go | 42 ++- internal/runner/runner_test.go | 192 +++++++++- internal/webhook/delivery.go | 6 +- internal/webhook/delivery_test.go | 7 +- internal/webhook/github.go | 9 +- internal/webhook/github_test.go | 3 +- internal/workflow/expression_context.go | 58 +++ internal/workflow/expression_context_test.go | 94 +++++ internal/workflow/workflow.go | 63 ++-- internal/workflowcontext/context.go | 257 ++++++++++++++ internal/workflowcontext/context_test.go | 138 ++++++++ internal/workflowenv/environment.go | 12 + internal/workflowenv/environment_test.go | 12 + 28 files changed, 1677 insertions(+), 365 deletions(-) create mode 100644 internal/workflow/expression_context.go create mode 100644 internal/workflow/expression_context_test.go create mode 100644 internal/workflowcontext/context.go create mode 100644 internal/workflowcontext/context_test.go diff --git a/api/v1alpha1/labels.go b/api/v1alpha1/labels.go index 813bd47..a7da835 100644 --- a/api/v1alpha1/labels.go +++ b/api/v1alpha1/labels.go @@ -16,5 +16,7 @@ const ( AnnotationProjectName = "actions.kelos.dev/project-name" AnnotationWorkflowFile = "actions.kelos.dev/workflow-file" AnnotationWorkflowPlan = "actions.kelos.dev/workflow-plan" - AnnotationMatrixPlan = "actions.kelos.dev/matrix-plan" + // AnnotationDeferredJobPlan is persisted on planning ConfigMaps and must + // remain stable for stored WorkflowRuns. + AnnotationDeferredJobPlan = "actions.kelos.dev/matrix-plan" ) diff --git a/api/v1alpha1/workflowrun_types.go b/api/v1alpha1/workflowrun_types.go index 0f7422f..15a82a3 100644 --- a/api/v1alpha1/workflowrun_types.go +++ b/api/v1alpha1/workflowrun_types.go @@ -138,6 +138,14 @@ type WorkflowRunRerun struct { // +optional RequestID string `json:"requestID,omitempty"` + // TriggeringActor is the GitHub login that requested this attempt. It is + // omitted when the rerun source cannot identify a GitHub user. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=100 + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9][A-Za-z0-9_-]*(\[bot\])?$` + // +optional + TriggeringActor string `json:"triggeringActor,omitempty"` + // JobIDs selects expanded WorkflowJob IDs to rerun. Selected jobs reuse the // latest available results and outputs of prerequisite jobs from earlier // attempts. Omit it to execute every job in the workflow. diff --git a/cmd/open-actions-runner/main_test.go b/cmd/open-actions-runner/main_test.go index ebed6ec..bb986c8 100644 --- a/cmd/open-actions-runner/main_test.go +++ b/cmd/open-actions-runner/main_test.go @@ -24,6 +24,7 @@ func TestRunWritesWorkflowJobResult(t *testing.T) { Event: runner.Event{Name: "push", DeliveryID: "delivery"}, Revision: runner.Revision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main", RefName: "main"}, WorkflowName: "CI", + WorkflowPath: ".github/workflows/ci.yml", JobID: "build", GitHubTokenPermissions: map[string]string{"contents": "read"}, TimeoutSeconds: int64((6 * time.Hour) / time.Second), @@ -78,6 +79,7 @@ func TestRunLoadsNeedsContext(t *testing.T) { Event: runner.Event{Name: "push", DeliveryID: "delivery"}, Revision: runner.Revision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main", RefName: "main"}, WorkflowName: "CI", + WorkflowPath: ".github/workflows/ci.yml", JobID: "report", GitHubTokenPermissions: map[string]string{"contents": "read"}, TimeoutSeconds: int64((6 * time.Hour) / time.Second), diff --git a/docs/reference.md b/docs/reference.md index 25d2014..f5b0b7f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -470,15 +470,16 @@ attempt lineage, `previousRunRef` names the immediately preceding completed attempt, and `attempt` starts at 2. Both references include the WorkflowRun UID to reject names that were deleted and recreated. `requestID` is an optional idempotency identity and contains the webhook delivery ID for GitHub -rerequests. `jobIDs` is an optional set of expanded WorkflowJob IDs; the -selected jobs reuse the latest available results and outputs of prerequisites -from earlier attempts. Omitting `jobIDs` reruns every job. The rerun fields are -immutable. +rerequests. `triggeringActor` is the optional GitHub login that requested this +attempt; GitHub Check Run rerequests populate it. `jobIDs` is an optional set of +expanded WorkflowJob IDs; the selected jobs reuse the latest available results +and outputs of prerequisites from earlier attempts. Omitting `jobIDs` reruns +every job. The rerun fields are immutable. The controller also requires the project, source, workflow path, lineage, and attempt number to match the previous run before it executes a rerun. Workflows -with output-derived dynamic matrices currently require a full rerun with -`jobIDs` omitted because their expanded IDs do not exist until dependencies -finish. +with jobs whose planning depends on `needs` require a full rerun with `jobIDs` +omitted because their final configuration and expanded IDs do not exist until +dependencies finish. Runner labels are canonical lowercase ASCII in Kubernetes resources. Workflow `runs-on` labels use the same representation. Each Runner is one reusable @@ -575,23 +576,24 @@ in declaration order: compatible entries augment base combinations and incompatible entries add standalone combinations. Include-only matrices are supported. -Matrix expressions may use `github`, `needs`, `vars`, and `inputs`. If an -expression reads `needs`, expansion waits until every direct dependency is -terminal and its outputs are persisted. The controller then evaluates the job -condition before the matrix. A failed, skipped, or cancelled dependency -therefore skips the dynamic job under the default success condition; an -explicit status function such as `always()` can permit evaluation. Missing -outputs, invalid JSON, non-array axes, non-mapping complete matrices, -non-scalar final values, empty axes, and oversized results finish the logical -job with `MatrixEvaluationFailed` rather than leaving it pending. +Matrix expressions may use `github`, `open_actions`, `needs`, `vars`, and +`inputs`. Job names, runner labels, and timeout expressions may also use +`needs`. When one of these planning expressions reads `needs`, planning waits +until every direct dependency is terminal and its outputs are persisted. The +controller then evaluates the job condition before the deferred fields. A +failed, skipped, or cancelled dependency therefore skips the job under the +default success condition; an explicit status function such as `always()` can +permit evaluation. Missing outputs, invalid JSON, non-array axes, non-mapping +complete matrices, non-scalar final values, empty axes, and oversized results +finish the logical job with `JobPlanningFailed` rather than leaving it pending. The controller creates one `WorkflowJob` per final combination in deterministic order. Each child has a unique `spec.jobID`, while `spec.matrix.logicalJobID`, `values`, `maxParallel`, and `failFast` preserve its -logical identity and strategy. Deferred matrix plans are immutable resources -owned by the WorkflowRun, so the same children are recovered across controller -restarts. `max-parallel` limits active children in that group independently of -the number of matching Runners. +logical identity and strategy. Deferred job plans are immutable resources +owned by the WorkflowRun, so the same configuration and children are recovered +across controller restarts. `max-parallel` limits active children in that group +independently of the number of matching Runners. `fail-fast` defaults to `true`. After a matrix child fails, queued combinations in the same WorkflowRun and logical matrix job finish with `MatrixFailFast`, and @@ -653,15 +655,15 @@ The resources expose these condition contracts: | `WorkflowRun` | `Succeeded` | `False` | `ProjectUnavailable`, `WorkflowFetchFailed`, `WorkflowInvalid`, `TriggerInvalid`, `RerunInvalid`, `ChildCreationFailed`, `JobFailed`, `JobTimedOut`, `JobCancelled`, `RevisionSuperseded`, `ExecutionStateLost` | | `WorkflowJob` | `Ready` | `Unknown` | `DependenciesPending`, `WaitingForConcurrency` | | `WorkflowJob` | `Ready` | `True` | `ConditionPassed`, `ConcurrencyAcquired` | -| `WorkflowJob` | `Ready` | `False` | `ConditionFalse`, `ConditionEvaluationFailed`, `MatrixEvaluationFailed`, `ConcurrencyEvaluationFailed`, `ConcurrencySuperseded`, `ConcurrencyCancelled`, `CancellationRequested`, `MatrixFailFast` | +| `WorkflowJob` | `Ready` | `False` | `ConditionFalse`, `ConditionEvaluationFailed`, `JobPlanningFailed`, `ConcurrencyEvaluationFailed`, `ConcurrencySuperseded`, `ConcurrencyCancelled`, `CancellationRequested`, `MatrixFailFast` | | `WorkflowJob` | `ConcurrencyAcquired` | `Unknown` | `WaitingForConcurrency` | | `WorkflowJob` | `ConcurrencyAcquired` | `True` | `ConcurrencyAcquired` | | `WorkflowJob` | `ConcurrencyAcquired` | `False` | `ConcurrencySuperseded` | | `WorkflowJob` | `Scheduled` | `True` | `RunnerAssigned` | -| `WorkflowJob` | `Scheduled` | `False` | `ConditionFalse`, `ConditionEvaluationFailed`, `MatrixEvaluationFailed`, `ConcurrencyEvaluationFailed`, `ConcurrencySuperseded`, `ConcurrencyCancelled`, `CancellationRequested`, `MatrixFailFast`, `ProjectRecreated` | +| `WorkflowJob` | `Scheduled` | `False` | `ConditionFalse`, `ConditionEvaluationFailed`, `JobPlanningFailed`, `ConcurrencyEvaluationFailed`, `ConcurrencySuperseded`, `ConcurrencyCancelled`, `CancellationRequested`, `MatrixFailFast`, `ProjectRecreated` | | `WorkflowJob` | `Succeeded` | `Unknown` | `JobRunning` | | `WorkflowJob` | `Succeeded` | `True` | `JobSucceeded` | -| `WorkflowJob` | `Succeeded` | `False` | `JobFailed`, `JobTimedOut`, `JobCancelled`, `JobResultInvalid`, `ConditionEvaluationFailed`, `MatrixEvaluationFailed`, `ConcurrencyEvaluationFailed`, `PlanUnavailable`, `JobStartFailed`, `GitHubTokenPermissionsRejected`, `ExecutionStateLost`, `CancellationRequested`, `MatrixFailFast`, `ProjectRecreated` | +| `WorkflowJob` | `Succeeded` | `False` | `JobFailed`, `JobTimedOut`, `JobCancelled`, `JobResultInvalid`, `ConditionEvaluationFailed`, `JobPlanningFailed`, `ConcurrencyEvaluationFailed`, `PlanUnavailable`, `JobStartFailed`, `GitHubTokenPermissionsRejected`, `ExecutionStateLost`, `CancellationRequested`, `MatrixFailFast`, `ProjectRecreated` | | `WorkflowJob` | `CancellationRequested` | `True` | `CancellationRequested`, `ConditionEvaluationFailed`, `ConcurrencyCancelled`, `MatrixFailFast` | | `WorkflowJob` | `CancellationRequested` | `False` | `ConditionPassed` | @@ -745,10 +747,14 @@ Open Actions supplies the following runner-owned names: - Action identity: `GITHUB_ACTION_PATH`, `GITHUB_ACTION_REPOSITORY`. - Workflow and repository identity: `GITHUB_ACTIONS`, `GITHUB_API_URL`, - `GITHUB_BASE_REF`, `GITHUB_EVENT_ACTION`, `GITHUB_EVENT_NAME`, - `GITHUB_EVENT_PATH`, `GITHUB_HEAD_REF`, `GITHUB_JOB`, `GITHUB_REF`, - `GITHUB_REF_NAME`, `GITHUB_REPOSITORY`, `GITHUB_SERVER_URL`, `GITHUB_SHA`, - `GITHUB_WORKFLOW`, `GITHUB_WORKSPACE`. + `GITHUB_ACTOR`, `GITHUB_BASE_REF`, `GITHUB_EVENT_ACTION`, + `GITHUB_EVENT_NAME`, `GITHUB_EVENT_PATH`, `GITHUB_GRAPHQL_URL`, + `GITHUB_HEAD_REF`, `GITHUB_JOB`, `GITHUB_REF`, `GITHUB_REF_NAME`, + `GITHUB_REF_TYPE`, `GITHUB_REPOSITORY`, `GITHUB_REPOSITORY_ID`, + `GITHUB_REPOSITORY_OWNER`, `GITHUB_RETENTION_DAYS`, `GITHUB_RUN_ATTEMPT`, + `GITHUB_RUN_ID`, `GITHUB_RUN_NUMBER`, `GITHUB_SERVER_URL`, `GITHUB_SHA`, + `GITHUB_TRIGGERING_ACTOR`, `GITHUB_WORKFLOW`, `GITHUB_WORKFLOW_REF`, + `GITHUB_WORKFLOW_SHA`, `GITHUB_WORKSPACE`. - Command files: `GITHUB_ENV`, `GITHUB_OUTPUT`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY`. - Runner identity and paths: `RUNNER_ARCH`, `RUNNER_ENVIRONMENT`, `RUNNER_NAME`, @@ -805,34 +811,89 @@ supported. Directories and symlink targets outside the workspace are not hashed. The function returns the SHA-256 digest of the matched file digests in path order, or an empty string when no files match. -Contexts are restricted by evaluation phase. An allowed context still fails at -evaluation when the corresponding execution feature has not supplied it. +Contexts are restricted by expression site. A context that is not listed below +is rejected when the workflow or action metadata is loaded. -| Phase | Allowed contexts and functions | Currently supplied | -| --- | --- | --- | -| Workflow concurrency | `github`, `inputs`, `vars` | All listed contexts | -| Job concurrency | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `inputs`, `vars` | All listed contexts after dependencies settle; `strategy` and `matrix` are present for matrix jobs | -| Workflow environment | `github`, `open_actions`, `secrets`, `inputs`, `vars` | All listed contexts | -| Matrix expansion | `github`, `open_actions`, `needs`, `vars`, `inputs` | All listed contexts; `needs` contains terminal direct dependencies | -| Workflow job condition | `github`, `open_actions`, `needs`, `vars`, `inputs`, and status functions | `github`, `open_actions`, direct dependency results and outputs, `vars`, `inputs`, and status functions | -| Job name, timeout, and runner labels | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `vars`, `inputs` | `github`, `open_actions`, `inputs`, `vars`, and `matrix` for matrix jobs; deferred matrix jobs also receive `needs` | -| Job environment | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `vars`, `secrets`, `inputs` | `github`, `open_actions`, direct dependency results and outputs, `inputs`, `vars`, `secrets`, and `matrix` for matrix jobs | -| Workflow step name, run script, working directory, environment, and inputs | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs`, and `hashFiles` | `github`, `open_actions`, direct dependency results and outputs, `matrix`, `runner`, `env`, `vars`, `secrets`, `inputs`, `steps`, and `hashFiles` | -| Workflow step `continue-on-error` | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs`, and `hashFiles` | `github`, `open_actions`, direct dependency results and outputs, `matrix`, `runner`, `env`, `vars`, `secrets`, `inputs`, `steps`, and `hashFiles` | -| Workflow step condition | Step contexts except `secrets`, plus status functions and `hashFiles` | `github`, `open_actions`, direct dependency results and outputs, `matrix`, `runner`, `env`, `vars`, `inputs`, `steps`, status functions, and `hashFiles` | -| Job outputs | Workflow step contexts without `hashFiles` | `github`, `open_actions`, direct dependency results and outputs, `matrix`, `runner`, `env`, `vars`, `secrets`, `inputs`, `steps` | -| Composite step fields and outputs | `github`, `open_actions`, `runner`, `env`, `inputs`, `steps`, and `hashFiles` | All listed contexts and functions | -| Composite step condition | Composite contexts, status functions, and `hashFiles` | All listed contexts and functions | -| Action input default | `github`, `open_actions`, `runner` | All listed contexts; a default is evaluated only when the workflow does not supply that input | - -The runner context supplies `name`, `os`, `arch`, `temp`, `tool_cache`, and -`environment` during job execution. `runner.name` and `RUNNER_NAME` identify -the assigned Runner resource. `runner.environment` and `RUNNER_ENVIRONMENT` -are `self-hosted`, because Open Actions runners execute on user-managed -infrastructure. When `ACTIONS_STEP_DEBUG` enables the runner debug indicator, -`runner.debug` and `RUNNER_DEBUG` have the string value `1`; otherwise the -property and environment variable are absent. Action metadata input defaults -receive the same runner context, including the expression used by +| Expression site | Contexts and functions | +| --- | --- | +| Workflow concurrency | `github`, `inputs`, `vars` | +| Workflow environment | `github`, `open_actions`, `secrets`, `inputs`, `vars` | +| Job condition | `github`, `open_actions`, `needs`, `vars`, `inputs`; status functions | +| Job matrix | `github`, `open_actions`, `needs`, `vars`, `inputs` | +| Job name, runner labels, timeout, and concurrency | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `vars`, `inputs` | +| Job environment | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `vars`, `secrets`, `inputs` | +| Job outputs | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs` | +| Workflow step name, run script, working directory, environment, inputs, and `continue-on-error` | `github`, `open_actions`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs`; `hashFiles` | +| Workflow step condition | Step contexts except `secrets`; status functions and `hashFiles` | +| Action input default | `github`, `open_actions`, `strategy`, `matrix`, `job`, `runner`; `hashFiles` | +| Composite step name, run script, working directory, environment, inputs, and `continue-on-error` | `github`, `open_actions`, `inputs`, `strategy`, `matrix`, `steps`, `job`, `runner`, `env`; `hashFiles` | +| Composite step condition | Composite step contexts; status functions and `hashFiles` | +| Composite output | Composite step contexts without `hashFiles` | + +`open_actions` is an Open Actions extension. It supplies `run_url` and +`run_query_url` at every site where the table lists it. + +The `github` context supplies the following properties when their source is +available: + +- Repository and revision: `repository`, `repository_id`, `repository_owner`, + `repositoryUrl`, `sha`, `ref`, `ref_name`, `ref_type`, `head_ref`, and + `base_ref`. +- Workflow and run: `workflow`, `workflow_ref`, `workflow_sha`, `run_id`, + `run_number`, `run_attempt`, `actor`, `triggering_actor`, `job`, and + `retention_days`. +- Event and endpoints: `event`, `event_name`, `event_path`, `server_url`, + `api_url`, and `graphql_url`. +- Execution: `workspace`, `token`, and `secret_source`. +- Action metadata: `action_path`, `action_ref`, `action_repository`, and + `action_status` for composite actions, with repository and ref also available + while action input defaults are evaluated. + +IDs and run counters in `github` are strings, `ref_protected` is Boolean when +available, and all other scalar properties are strings. `event` preserves the +JSON payload's Boolean, number, string, array, object, and null types. Webhook +payloads also supply `actor_id` and `repository_owner_id` when the corresponding +numeric ID is present. Synthetic events do not invent those IDs. Open Actions +does not currently supply `github.action`, `github.env`, `github.path`, or +`github.ref_protected` to expressions. The `GITHUB_ENV` and `GITHUB_PATH` +process variables still identify the current command files. Missing and +inapplicable properties evaluate to an empty string. + +The `job` context supplies `status`, `workflow_ref`, `workflow_sha`, +`workflow_repository`, and `workflow_file_path`. `status` reflects the job's +current `success`, `failure`, or `cancelled` state. Job containers and services +are unsupported, so `job.container` and `job.services` are unavailable. Open +Actions reports one Check Run for the workflow rather than one per job and does +not supply `job.check_run_id`. + +For each expanded matrix job, `strategy` supplies numeric `job-index`, +`job-total`, and `max-parallel` values and Boolean `fail-fast`; `matrix` +contains the typed scalar values for that combination. Both contexts are empty +for a non-matrix job. `runner` supplies `name`, `os`, `arch`, `temp`, +`tool_cache`, and `environment` during job execution. When +`ACTIONS_STEP_DEBUG` enables the runner debug indicator, `runner.debug` has the +string value `1`; otherwise that property is absent. + +Each completed or skipped identified step appears in `steps` with an `outputs` +mapping plus `outcome` and `conclusion`. The latter values are `success`, +`failure`, `cancelled`, or `skipped`; `continue-on-error` can make a failed +outcome have a successful conclusion. Composite actions receive the same shape +for their own identified steps. `needs` contains only direct dependencies and +supplies their `result` and persisted `outputs` mappings. + +`inputs` preserves the declared input types: strings remain strings, Booleans +remain Boolean, and numeric values remain numbers. `vars`, `secrets`, and `env` +contain string values. The `env` context includes only values declared at the +workflow, job, current step, or current composite scope and values written to +`GITHUB_ENV` by completed commands. It does not expose inherited process +variables or runner default variables. Context and property lookup is +case-insensitive; environment variable names remain case-sensitive on Linux. + +`runner.name` and `RUNNER_NAME` identify the assigned Runner resource. +`runner.environment` and `RUNNER_ENVIRONMENT` are `self-hosted`, because Open +Actions runners execute on user-managed infrastructure. The `RUNNER_DEBUG` +process variable follows `runner.debug`. Action metadata input defaults receive +the same runner context, including the expression used by `actions/github-script`: `${{ runner.debug == '1' }}`. Every action input default is syntax- and context-validated when the action metadata loads, including defaults for supplied inputs and skipped steps. @@ -864,7 +925,9 @@ Webhook runs use the signed payload's `sender.login` as `actor`. Direct `workflow_dispatch` and `workflow_call` resources use `spec.source.github.actor`; it defaults to `open-actions` when the caller does not supply an identity. Scheduled runs use `open-actions`. Reruns preserve the -first attempt's actor, matching GitHub's `github.actor` behavior. +first attempt's actor, matching GitHub's `github.actor` behavior. For GitHub +Check Run rerequests, `github.triggering_actor` identifies the user who +requested the current attempt; otherwise it matches `github.actor`. The values are available during planning and runner execution: @@ -874,6 +937,7 @@ The values are available during planning and runner execution: | `github.run_number` | `GITHUB_RUN_NUMBER` | | `github.run_attempt` | `GITHUB_RUN_ATTEMPT` | | `github.actor` | `GITHUB_ACTOR` | +| `github.triggering_actor` | `GITHUB_TRIGGERING_ACTOR` | | `open_actions.run_url` | `OPEN_ACTIONS_RUN_URL` | `open_actions.run_url` links to the current attempt in the Open Actions Console. It @@ -1383,20 +1447,20 @@ same immutable snapshot. Synthetic `workflow_dispatch`, `schedule`, and `workflow_call` runs use a bounded generated document containing their inputs, schedule, repository identity, and supported event metadata. -The controller emits job-plan version 8, and the runner accepts versions 1 -through 8. When a release changes the job-plan version, update every Runner +The controller emits job-plan version 9, and the runner accepts versions 1 +through 9. When a release changes the job-plan version, update every Runner `spec.execution.image` to an image that accepts both the installed and target controller versions before upgrading the controller. The received job-plan version also determines the runner result version: plan versions 1 through 5 -use result version 1, and plan versions 6 through 8 use result version 2. A +use result version 1, and plan versions 6 through 9 use result version 2. A runner that accepts more than one plan version must emit the result version assigned to that plan, not always the latest result version supported by the runner binary. Integration commit construction is part of this versioned contract; changing its merge behavior or commit metadata requires a job-plan version transition. -Docker and local actions, matrix `include` and `exclude`, service containers, -and caches are not supported. Expressions outside the documented +Docker actions, local actions, service containers, and caches are not +supported. Expressions outside the documented fields and runtime contexts are rejected during planning or execution and are never interpreted as literal values. `WorkflowJob` resources are not retried or reassigned when a Runner is removed. diff --git a/internal/controller/runner_controller_test.go b/internal/controller/runner_controller_test.go index d17297d..8ddf721 100644 --- a/internal/controller/runner_controller_test.go +++ b/internal/controller/runner_controller_test.go @@ -2138,6 +2138,7 @@ func runnerControllerPlanData(t *testing.T, permissions map[string]string, steps Event: runner.Event{Name: "push", DeliveryID: "delivery"}, Revision: runner.Revision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main", RefName: "main"}, WorkflowName: "CI", + WorkflowPath: ".github/workflows/ci.yml", JobID: "build", GitHubTokenPermissions: permissions, TimeoutSeconds: int64((6 * time.Hour) / time.Second), diff --git a/internal/controller/workflowrun_controller.go b/internal/controller/workflowrun_controller.go index 09894a5..9dbc639 100644 --- a/internal/controller/workflowrun_controller.go +++ b/internal/controller/workflowrun_controller.go @@ -23,6 +23,7 @@ import ( "github.com/kelos-dev/open-actions/internal/projectvalue" "github.com/kelos-dev/open-actions/internal/runner" "github.com/kelos-dev/open-actions/internal/workflow" + "github.com/kelos-dev/open-actions/internal/workflowcontext" "github.com/kelos-dev/open-actions/internal/workflowsnapshot" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" @@ -45,9 +46,11 @@ import ( ) const ( - jobPlanKey = "job.json" - jobNeedsKey = "needs.json" - matrixPlanKey = "matrix.json" + jobPlanKey = "job.json" + jobNeedsKey = "needs.json" + // deferredJobPlanKey is persisted in planning ConfigMaps and must remain + // stable for stored WorkflowRuns. + deferredJobPlanKey = "matrix.json" workflowPlanKey = "workflow.json" maxJobPlanBytes = 900_000 resourceNameMaxLength = 63 @@ -63,6 +66,8 @@ const ( workflowRunSequenceScopeKey = "scope" workflowRunSequenceNextKey = "next" maxGitHubCompatibleNumber = int64(9_007_199_254_740_991) + deferredJobResultRunsOn = "deferred-planning" + matrixEvaluationResultRunsOn = "matrix-evaluation" ) var digestEncoding = base32.StdEncoding.WithPadding(base32.NoPadding) @@ -506,7 +511,8 @@ func (r *WorkflowRunReconciler) reconcileWorkflowRun(ctx context.Context, run *a } variables := r.projectVariableContext(ctx, project) if concurrency := workflowRunConcurrencyDecision(run); concurrency == nil { - concurrencyGroup, cancelInProgress, err := workflow.EvaluateConcurrency(definition, planningEvent, variables) + expressionContext := r.jobExpressionContext(planningRun, definition.Name, planningEvent.InputValues, variables, eventPayload) + concurrencyGroup, cancelInProgress, err := workflow.EvaluateConcurrencyContext(definition, expressionContext) if err != nil { return r.planningEvaluationFailed(ctx, run, err) } @@ -520,12 +526,12 @@ func (r *WorkflowRunReconciler) reconcileWorkflowRun(ctx context.Context, run *a return ctrl.Result{}, err } } - plannedJobs, deferredMatrices, err := r.planWorkflowJobs(planningRun, definition, planningEvent.InputValues, variables, eventPayload) + plannedJobs, deferredJobs, err := r.planWorkflowJobs(planningRun, definition, planningEvent.InputValues, variables, eventPayload) if err != nil { return r.planningEvaluationFailed(ctx, run, err) } - if len(deferredMatrices) > 0 && run.Spec.Rerun != nil && len(run.Spec.Rerun.JobIDs) > 0 { - return r.planningFailed(ctx, run, "RerunInvalid", fmt.Errorf("WorkflowRun %q does not support selective reruns with dynamic matrices", run.Name), planningFailureTerminal) + if len(deferredJobs) > 0 && run.Spec.Rerun != nil && len(run.Spec.Rerun.JobIDs) > 0 { + return r.planningFailed(ctx, run, "RerunInvalid", fmt.Errorf("WorkflowRun %q does not support selective reruns with deferred job planning", run.Name), planningFailureTerminal) } plannedJobs, err = selectRerunWorkflowJobs(run, plannedJobs) if err != nil { @@ -541,11 +547,11 @@ func (r *WorkflowRunReconciler) reconcileWorkflowRun(ctx context.Context, run *a return r.planningFailed(ctx, run, "RerunInvalid", err, disposition) } } - jobCount := int32(len(plannedJobs) + len(deferredMatrices)) + jobCount := int32(len(plannedJobs) + len(deferredJobs)) run.Status.WorkflowName = definition.Name run.Status.Jobs = &actionsv1alpha1.WorkflowRunJobStatus{Total: jobCount} - if len(deferredMatrices) > 0 { - if err := r.ensureWorkflowPlan(ctx, run, project, plannedJobs, deferredMatrices); err != nil { + if len(deferredJobs) > 0 { + if err := r.ensureWorkflowPlan(ctx, run, project, plannedJobs, deferredJobs); err != nil { return r.planningFailed(ctx, run, "ChildCreationFailed", err, childCreationFailureDisposition(err)) } } @@ -974,7 +980,7 @@ type plannedWorkflowJob struct { timeoutSeconds int64 } -type deferredMatrixPlan struct { +type deferredJobPlan struct { JobID string `json:"jobID"` WorkflowName string `json:"workflowName"` WorkflowEnv map[string]string `json:"workflowEnv,omitempty"` @@ -985,9 +991,10 @@ type deferredMatrixPlan struct { } type workflowPlanManifest struct { - JobIDs []string `json:"jobIDs"` - SourceIDs []string `json:"sourceIDs"` - Matrices map[string]string `json:"matrices"` + JobIDs []string `json:"jobIDs"` + SourceIDs []string `json:"sourceIDs"` + // DeferredJobs keeps its JSON name for stored WorkflowRun plan compatibility. + DeferredJobs map[string]string `json:"matrices"` } func selectRerunWorkflowJobs(run *actionsv1alpha1.WorkflowRun, plannedJobs []plannedWorkflowJob) ([]plannedWorkflowJob, error) { @@ -1268,7 +1275,7 @@ func (r *WorkflowRunReconciler) ensureWorkflowJobs(ctx context.Context, run *act return nil } -func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRun, definition *workflow.Definition, inputValues map[string]any, variables any, eventPayload map[string]any) ([]plannedWorkflowJob, []deferredMatrixPlan, error) { +func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRun, definition *workflow.Definition, inputValues map[string]any, variables any, eventPayload map[string]any) ([]plannedWorkflowJob, []deferredJobPlan, error) { workflowEnv, err := stringMap(definition.Env) if err != nil { return nil, nil, fmt.Errorf("workflow env: %w", err) @@ -1282,7 +1289,7 @@ func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRu sort.Strings(jobIDs) plannedJobs := make([]plannedWorkflowJob, 0, len(jobIDs)) plannedIDs := make(map[string]struct{}) - deferredMatrices := make([]deferredMatrixPlan, 0) + deferredJobs := make([]deferredJobPlan, 0) sourceIDs := make(map[string]struct{}, len(jobIDs)) for _, id := range jobIDs { sourceIDs[id] = struct{}{} @@ -1290,7 +1297,7 @@ func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRu for _, id := range jobIDs { definitionJob := definition.Jobs[id] definitionJob.Permissions = workflow.EffectivePermissions(definition.Permissions, definitionJob.Permissions) - if workflow.MatrixUsesNeeds(definitionJob.Strategy) { + if workflow.JobPlanningUsesNeeds(definitionJob) { if !variablesSnapshotted { variableSnapshot, err = snapshotExpressionVariables(variables) if err != nil { @@ -1298,10 +1305,10 @@ func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRu } variablesSnapshotted = true } - deferredMatrices = append(deferredMatrices, deferredMatrixPlan{ + deferredJobs = append(deferredJobs, deferredJobPlan{ JobID: id, WorkflowName: definition.Name, WorkflowEnv: workflowEnv, EventPayload: eventPayload, Variables: variableSnapshot, Job: definitionJob, InputValues: inputValues, }) - if len(plannedJobs)+len(deferredMatrices) > workflow.MaxJobs { + if len(plannedJobs)+len(deferredJobs) > workflow.MaxJobs { return nil, nil, fmt.Errorf("workflow expands to more than %d jobs", workflow.MaxJobs) } continue @@ -1319,11 +1326,11 @@ func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRu return nil, nil, err } plannedJobs = append(plannedJobs, expanded...) - if len(plannedJobs)+len(deferredMatrices) > workflow.MaxJobs { + if len(plannedJobs)+len(deferredJobs) > workflow.MaxJobs { return nil, nil, fmt.Errorf("workflow expands to more than %d jobs", workflow.MaxJobs) } } - return plannedJobs, deferredMatrices, nil + return plannedJobs, deferredJobs, nil } func (r *WorkflowRunReconciler) expandPlannedWorkflowJob(run *actionsv1alpha1.WorkflowRun, workflowName, id string, workflowEnv map[string]string, definitionJob workflow.Job, inputValues map[string]any, expressionContext workflowexpression.Context, combinations []map[string]any, sourceIDs, plannedIDs map[string]struct{}) ([]plannedWorkflowJob, error) { @@ -1350,12 +1357,8 @@ func (r *WorkflowRunReconciler) expandPlannedWorkflowJob(run *actionsv1alpha1.Wo jobContext := expressionContext jobContext.Values = maps.Clone(expressionContext.Values) if matrix != nil { - contexts := []string{"github", "open_actions", "matrix", "inputs", "vars"} - if _, found := jobContext.Values["needs"]; found { - contexts = append(contexts, "needs") - } - jobContext.Availability = workflowexpression.NewAvailability(contexts...) jobContext.Values["matrix"] = matrix + jobContext.Values["strategy"] = workflowJobStrategyContext(matrixSpec) } resolvedJob, err := workflow.EvaluateJob(id, definitionJob, jobContext) if err != nil { @@ -1373,6 +1376,9 @@ func (r *WorkflowRunReconciler) expandPlannedWorkflowJob(run *actionsv1alpha1.Wo if err != nil { return nil, err } + if matrixSpec != nil { + plan.Strategy = workflowJobStrategyContext(matrixSpec) + } data, err := json.Marshal(plan) if err != nil { return nil, fmt.Errorf("encode job plan: %w", err) @@ -1582,38 +1588,51 @@ func (r *WorkflowRunReconciler) jobExpressionContext(run *actionsv1alpha1.Workfl headRef, baseRef := githubSourcePullRequestRefs(githubSource) eventValues := githubEventExpressionValue(githubSource, inputValues, eventPayload) identity := run.Status.Identity - runID, runNumber, runAttempt, runURL, runQueryURL := "", "", "", "", "" + var runID, runNumber int64 + var runAttempt int32 + runURL, runQueryURL := "", "" if identity != nil { - runID = strconv.FormatInt(identity.ID, 10) - runNumber = strconv.FormatInt(identity.Number, 10) - runAttempt = strconv.FormatInt(int64(identity.Attempt), 10) + runID = identity.ID + runNumber = identity.Number + runAttempt = identity.Attempt runURL = identity.URL if r.ConsoleURL != "" { runQueryURL = workflowRunQueryURL(r.ConsoleURL, run) } } + github := workflowcontext.GitHub(workflowcontext.GitHubValues{ + Actor: githubSourceActor(githubSource), + ActorID: workflowcontext.EventID(eventValues, "sender", "id"), + APIURL: r.GitHubAPIBase, + BaseRef: baseRef, + Event: eventValues, + EventName: string(githubSource.Event.Name), + HeadRef: headRef, + Ref: githubSource.Revision.Ref, + RefName: githubclient.RefName(githubSource.Revision.Ref), + RepositoryID: githubSource.Repository.ID, + RepositoryName: githubSource.Repository.Name, + RepositoryOwner: githubSource.Repository.Owner, + RepositoryOwnerID: workflowcontext.EventID(eventValues, "repository", "owner", "id"), + RepositoryURL: workflowcontext.EventString(eventValues, "repository", "git_url"), + RunAttempt: runAttempt, + RunID: runID, + RunNumber: runNumber, + ServerURL: r.GitHubServerURL, + SHA: githubSource.Revision.SHA, + TriggeringActor: workflowRunTriggeringActor(run), + WorkflowName: workflowName, + WorkflowPath: run.Spec.WorkflowPath, + }) return workflowexpression.Context{ Availability: workflowexpression.NewAvailability("github", "open_actions", "inputs", "vars"), Values: map[string]any{ - "inputs": inputValues, - "vars": variables, - "github": map[string]any{ - "actor": githubSourceActor(githubSource), - "workflow": workflowName, - "event_name": string(githubSource.Event.Name), - "event": eventValues, - "repository": githubSource.Repository.Owner + "/" + githubSource.Repository.Name, - "sha": githubSource.Revision.SHA, - "ref": githubSource.Revision.Ref, - "ref_name": githubclient.RefName(githubSource.Revision.Ref), - "head_ref": headRef, - "base_ref": baseRef, - "server_url": strings.TrimSuffix(r.GitHubServerURL, "/"), - "api_url": strings.TrimSuffix(r.GitHubAPIBase, "/"), - "run_id": runID, - "run_number": runNumber, - "run_attempt": runAttempt, - }, + "inputs": inputValues, + "vars": variables, + "github": github, + "matrix": map[string]any{}, + "needs": map[string]any{}, + "strategy": map[string]any{}, "open_actions": map[string]any{ "run_url": runURL, "run_query_url": runQueryURL, @@ -1970,11 +1989,11 @@ func (r *WorkflowRunReconciler) ensureWorkflowFileSnapshot(ctx context.Context, return nil } -func (r *WorkflowRunReconciler) ensureWorkflowPlan(ctx context.Context, run *actionsv1alpha1.WorkflowRun, project *actionsv1alpha1.Project, plannedJobs []plannedWorkflowJob, deferredMatrices []deferredMatrixPlan) error { +func (r *WorkflowRunReconciler) ensureWorkflowPlan(ctx context.Context, run *actionsv1alpha1.WorkflowRun, project *actionsv1alpha1.Project, plannedJobs []plannedWorkflowJob, deferredJobs []deferredJobPlan) error { manifest := workflowPlanManifest{ - JobIDs: make([]string, 0, len(plannedJobs)), - SourceIDs: make([]string, 0, len(plannedJobs)+len(deferredMatrices)), - Matrices: make(map[string]string, len(deferredMatrices)), + JobIDs: make([]string, 0, len(plannedJobs)), + SourceIDs: make([]string, 0, len(plannedJobs)+len(deferredJobs)), + DeferredJobs: make(map[string]string, len(deferredJobs)), } sourceIDs := map[string]struct{}{} for _, job := range plannedJobs { @@ -1986,17 +2005,17 @@ func (r *WorkflowRunReconciler) ensureWorkflowPlan(ctx context.Context, run *act sourceIDs[sourceID] = struct{}{} } sort.Strings(manifest.JobIDs) - for index := range deferredMatrices { - plan := &deferredMatrices[index] + for index := range deferredJobs { + plan := &deferredJobs[index] sourceIDs[plan.JobID] = struct{}{} - name := matrixPlanConfigMapName(run.Name, plan.JobID) - manifest.Matrices[plan.JobID] = name + name := deferredJobPlanConfigMapName(run.Name, plan.JobID) + manifest.DeferredJobs[plan.JobID] = name data, err := json.Marshal(plan) if err != nil { - return &terminalPlanningError{cause: fmt.Errorf("encode dynamic matrix plan for job %q: %w", plan.JobID, err)} + return &terminalPlanningError{cause: fmt.Errorf("encode deferred job plan for job %q: %w", plan.JobID, err)} } if len(data) > maxJobPlanBytes { - return &terminalPlanningError{cause: fmt.Errorf("dynamic matrix plan for job %q exceeds %d bytes", plan.JobID, maxJobPlanBytes)} + return &terminalPlanningError{cause: fmt.Errorf("deferred job plan for job %q exceeds %d bytes", plan.JobID, maxJobPlanBytes)} } configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -2007,14 +2026,14 @@ func (r *WorkflowRunReconciler) ensureWorkflowPlan(ctx context.Context, run *act actionsv1alpha1.LabelWorkflowRunUID: string(run.UID), }, Annotations: map[string]string{ - actionsv1alpha1.AnnotationProjectName: project.Name, - actionsv1alpha1.AnnotationMatrixPlan: plan.JobID, + actionsv1alpha1.AnnotationProjectName: project.Name, + actionsv1alpha1.AnnotationDeferredJobPlan: plan.JobID, }, }, Immutable: pointerTo(true), - Data: map[string]string{matrixPlanKey: string(data)}, + Data: map[string]string{deferredJobPlanKey: string(data)}, } - if err := r.ensureWorkflowOwnedConfigMap(ctx, run, configMap, matrixPlanKey); err != nil { + if err := r.ensureWorkflowOwnedConfigMap(ctx, run, configMap, deferredJobPlanKey); err != nil { return err } } @@ -2072,7 +2091,8 @@ func workflowPlanConfigMapName(runName string) string { return childName(runName, "workflow-plan") } -func matrixPlanConfigMapName(runName, jobID string) string { +func deferredJobPlanConfigMapName(runName, jobID string) string { + // The matrix-plan suffix is part of the persisted WorkflowRun plan manifest. return childName(workflowJobName(runName, jobID), "matrix-plan") } @@ -2119,7 +2139,7 @@ func (r *WorkflowRunReconciler) jobPlan(run *actionsv1alpha1.WorkflowRun, workfl Inputs: inputValues, Run: runner.Run{ ID: identity.ID, Number: identity.Number, Attempt: identity.Attempt, - Actor: githubSourceActor(githubSource), URL: identity.URL, + Actor: githubSourceActor(githubSource), TriggeringActor: workflowRunTriggeringActor(run), URL: identity.URL, QueryURL: workflowRunQueryURL(r.ConsoleURL, run), }, Repository: runner.Repository{ @@ -2142,6 +2162,7 @@ func (r *WorkflowRunReconciler) jobPlan(run *actionsv1alpha1.WorkflowRun, workfl Review: runnerReviewEvent(githubSource.Event.Review), }, WorkflowName: workflowName, + WorkflowPath: run.Spec.WorkflowPath, Revision: runner.Revision{ SHA: githubSource.Revision.SHA, HeadSHA: githubSource.Revision.HeadSHA, @@ -2163,6 +2184,13 @@ func (r *WorkflowRunReconciler) jobPlan(run *actionsv1alpha1.WorkflowRun, workfl }, nil } +func workflowRunTriggeringActor(run *actionsv1alpha1.WorkflowRun) string { + if run.Spec.Rerun != nil && run.Spec.Rerun.TriggeringActor != "" { + return run.Spec.Rerun.TriggeringActor + } + return githubSourceActor(run.Spec.Source.GitHub) +} + func githubTokenPermissions(run *actionsv1alpha1.WorkflowRun, permissions workflow.Permissions) map[string]string { restrictWrites := false githubSource := run.Spec.Source.GitHub @@ -2327,7 +2355,7 @@ type workflowPlanState struct { expected map[string]struct{} } -func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, run *actionsv1alpha1.WorkflowRun, jobs *actionsv1alpha1.WorkflowJobList) (workflowPlanState, error) { +func (r *WorkflowRunReconciler) reconcileDeferredJobs(ctx context.Context, run *actionsv1alpha1.WorkflowRun, jobs *actionsv1alpha1.WorkflowJobList) (workflowPlanState, error) { state := workflowPlanState{} planName := run.Annotations[actionsv1alpha1.AnnotationWorkflowPlan] if planName == "" { @@ -2350,7 +2378,7 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru } state.active = true state.pending = map[string]struct{}{} - state.expected = make(map[string]struct{}, len(manifest.JobIDs)+len(manifest.Matrices)) + state.expected = make(map[string]struct{}, len(manifest.JobIDs)+len(manifest.DeferredJobs)) for _, id := range manifest.JobIDs { if _, found := state.expected[id]; found { return state, &terminalPlanningError{cause: fmt.Errorf("WorkflowRun %q plan repeats job %q", run.Name, id)} @@ -2377,40 +2405,44 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru for _, id := range manifest.SourceIDs { sourceIDs[id] = struct{}{} } - matrixIDs := make([]string, 0, len(manifest.Matrices)) - for id := range manifest.Matrices { - matrixIDs = append(matrixIDs, id) + deferredJobIDs := make([]string, 0, len(manifest.DeferredJobs)) + for id := range manifest.DeferredJobs { + deferredJobIDs = append(deferredJobIDs, id) } - sort.Strings(matrixIDs) - for _, id := range matrixIDs { + sort.Strings(deferredJobIDs) + for _, id := range deferredJobIDs { planConfigMap := &corev1.ConfigMap{} - planKey := client.ObjectKey{Namespace: run.Namespace, Name: manifest.Matrices[id]} + planKey := client.ObjectKey{Namespace: run.Namespace, Name: manifest.DeferredJobs[id]} if err := r.APIReader.Get(ctx, planKey, planConfigMap); err != nil { if apierrors.IsNotFound(err) { - return state, &terminalPlanningError{cause: fmt.Errorf("dynamic matrix plan ConfigMap %q for job %q is missing from WorkflowRun %q", planKey.Name, id, run.Name)} + return state, &terminalPlanningError{cause: fmt.Errorf("deferred job plan ConfigMap %q for job %q is missing from WorkflowRun %q", planKey.Name, id, run.Name)} } return state, err } - if !metav1.IsControlledBy(planConfigMap, run) || planConfigMap.Immutable == nil || !*planConfigMap.Immutable || planConfigMap.Annotations[actionsv1alpha1.AnnotationMatrixPlan] != id { - return state, &terminalPlanningError{cause: fmt.Errorf("dynamic matrix plan ConfigMap %q does not match job %q in WorkflowRun %q", planConfigMap.Name, id, run.Name)} + if !metav1.IsControlledBy(planConfigMap, run) || planConfigMap.Immutable == nil || !*planConfigMap.Immutable || planConfigMap.Annotations[actionsv1alpha1.AnnotationDeferredJobPlan] != id { + return state, &terminalPlanningError{cause: fmt.Errorf("deferred job plan ConfigMap %q does not match job %q in WorkflowRun %q", planConfigMap.Name, id, run.Name)} } - plan := deferredMatrixPlan{} - decoder := json.NewDecoder(strings.NewReader(planConfigMap.Data[matrixPlanKey])) + plan := deferredJobPlan{} + decoder := json.NewDecoder(strings.NewReader(planConfigMap.Data[deferredJobPlanKey])) decoder.UseNumber() if err := decoder.Decode(&plan); err != nil { - return state, &terminalPlanningError{cause: fmt.Errorf("decode dynamic matrix plan ConfigMap %q: %w", planConfigMap.Name, err)} + return state, &terminalPlanningError{cause: fmt.Errorf("decode deferred job plan ConfigMap %q: %w", planConfigMap.Name, err)} } if plan.JobID != id { - return state, &terminalPlanningError{cause: fmt.Errorf("dynamic matrix plan ConfigMap %q identifies job %q, want %q", planConfigMap.Name, plan.JobID, id)} + return state, &terminalPlanningError{cause: fmt.Errorf("deferred job plan ConfigMap %q identifies job %q, want %q", planConfigMap.Name, plan.JobID, id)} } if resultJob := jobsByID[id]; resultJob != nil && workflowJobTerminal(resultJob) { state.expected[id] = struct{}{} continue } - matrixExpanded := false + resultPlaceholder, err := r.deferredJobResultPlaceholder(run, planConfigMap, plan) + if err != nil { + return state, &terminalPlanningError{cause: err} + } + jobPlanned := false for _, job := range jobsByLogicalID[id] { - if job.Spec.Matrix != nil && job.Spec.Matrix.LogicalJobID == id { - matrixExpanded = true + if !deferredJobResultPlaceholderMatches(job, resultPlaceholder, run) { + jobPlanned = true break } } @@ -2438,10 +2470,10 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru expressionContext := r.jobExpressionContext(run, plan.WorkflowName, plan.InputValues, plan.Variables, plan.EventPayload) expressionContext.Values["needs"] = workflowNeedsContext(logicalJob, jobsByLogicalID).ExpressionValues() expressionContext.Status = workflowJobAncestorStatus(logicalJob, jobsByLogicalID, run.Spec.CancelRequested) - if !matrixExpanded { + if !jobPlanned { runnable, err := workflow.EvaluateJobCondition(id, plan.Job.If, expressionContext) if err != nil { - changed, resultErr := r.completeDynamicMatrix(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "ConditionEvaluationFailed", err.Error()) + changed, resultErr := r.completeDeferredJobPlanning(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "ConditionEvaluationFailed", err.Error()) state.changed = state.changed || changed state.expected[id] = struct{}{} return state, resultErr @@ -2449,13 +2481,13 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru if !runnable { result := actionsv1alpha1.WorkflowJobResultSkipped reason := "ConditionFalse" - message := "The workflow job condition evaluated to false before matrix expansion" + message := "The workflow job condition evaluated to false before deferred planning" if run.Spec.CancelRequested { result = actionsv1alpha1.WorkflowJobResultCancelled reason = "CancellationRequested" - message = "The workflow job was cancelled before matrix expansion" + message = "The workflow job was cancelled before deferred planning" } - changed, resultErr := r.completeDynamicMatrix(ctx, run, planConfigMap, plan, jobsByID[id], result, reason, message) + changed, resultErr := r.completeDeferredJobPlanning(ctx, run, planConfigMap, plan, jobsByID[id], result, reason, message) state.changed = state.changed || changed state.expected[id] = struct{}{} return state, resultErr @@ -2468,14 +2500,17 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru if errors.As(err, &unavailable) { return state, err } - changed, resultErr := r.completeDynamicMatrix(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "MatrixEvaluationFailed", err.Error()) + changed, resultErr := r.completeDeferredJobPlanning(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "JobPlanningFailed", err.Error()) state.changed = state.changed || changed state.expected[id] = struct{}{} return state, resultErr } - if projectedWorkflowJobCount(len(jobs.Items), jobsByLogicalID, matrixIDs, id, len(combinations)) > workflow.MaxJobs { + if len(combinations) == 0 { + combinations = []map[string]any{nil} + } + if projectedWorkflowJobCount(len(jobs.Items), jobsByLogicalID, deferredJobIDs, id, len(combinations)) > workflow.MaxJobs { message := fmt.Sprintf("workflow expands to more than %d jobs", workflow.MaxJobs) - changed, resultErr := r.completeDynamicMatrix(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "MatrixEvaluationFailed", message) + changed, resultErr := r.completeDeferredJobPlanning(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "JobPlanningFailed", message) state.changed = state.changed || changed state.expected[id] = struct{}{} return state, resultErr @@ -2483,7 +2518,7 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru plannedIDs := make(map[string]struct{}, len(jobsByID)+len(manifest.JobIDs)) for existingID, existing := range jobsByID { - if existing.Spec.Matrix == nil || existing.Spec.Matrix.LogicalJobID != id { + if existing.Spec.JobID != id && (existing.Spec.Matrix == nil || existing.Spec.Matrix.LogicalJobID != id) { plannedIDs[existingID] = struct{}{} } } @@ -2496,7 +2531,7 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru if errors.As(err, &unavailable) { return state, err } - changed, resultErr := r.completeDynamicMatrix(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "MatrixEvaluationFailed", err.Error()) + changed, resultErr := r.completeDeferredJobPlanning(ctx, run, planConfigMap, plan, jobsByID[id], actionsv1alpha1.WorkflowJobResultFailure, "JobPlanningFailed", err.Error()) state.changed = state.changed || changed state.expected[id] = struct{}{} return state, resultErr @@ -2521,9 +2556,9 @@ func (r *WorkflowRunReconciler) reconcileDynamicMatrices(ctx context.Context, ru return state, nil } -func projectedWorkflowJobCount(existingJobs int, jobsByLogicalID map[string][]*actionsv1alpha1.WorkflowJob, matrixIDs []string, currentID string, combinations int) int { +func projectedWorkflowJobCount(existingJobs int, jobsByLogicalID map[string][]*actionsv1alpha1.WorkflowJob, deferredJobIDs []string, currentID string, combinations int) int { result := existingJobs - len(jobsByLogicalID[currentID]) + combinations - for _, id := range matrixIDs { + for _, id := range deferredJobIDs { if id != currentID && len(jobsByLogicalID[id]) == 0 { result++ } @@ -2531,32 +2566,16 @@ func projectedWorkflowJobCount(existingJobs int, jobsByLogicalID map[string][]*a return result } -func (r *WorkflowRunReconciler) completeDynamicMatrix(ctx context.Context, run *actionsv1alpha1.WorkflowRun, planConfigMap *corev1.ConfigMap, plan deferredMatrixPlan, existing *actionsv1alpha1.WorkflowJob, result actionsv1alpha1.WorkflowJobResult, reason, message string) (bool, error) { - project := &actionsv1alpha1.Project{ObjectMeta: metav1.ObjectMeta{UID: types.UID(planConfigMap.Labels[actionsv1alpha1.LabelProjectUID])}} - desired := &actionsv1alpha1.WorkflowJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: workflowJobName(run.Name, plan.JobID), - Namespace: run.Namespace, - Labels: workflowJobLabels(run, project, plan.JobID), - Annotations: map[string]string{actionsv1alpha1.AnnotationProjectName: planConfigMap.Annotations[actionsv1alpha1.AnnotationProjectName]}, - }, - Spec: actionsv1alpha1.WorkflowJobSpec{ - WorkflowRunRef: corev1.LocalObjectReference{Name: run.Name}, - JobID: plan.JobID, - DisplayName: plan.JobID, - RunsOn: []string{"matrix-evaluation"}, - Needs: append([]string(nil), plan.Job.Needs...), - If: plan.Job.If, - }, - } - if err := controllerutil.SetControllerReference(run, desired, r.Scheme()); err != nil { +func (r *WorkflowRunReconciler) completeDeferredJobPlanning(ctx context.Context, run *actionsv1alpha1.WorkflowRun, planConfigMap *corev1.ConfigMap, plan deferredJobPlan, existing *actionsv1alpha1.WorkflowJob, result actionsv1alpha1.WorkflowJobResult, reason, message string) (bool, error) { + desired, err := r.deferredJobResultPlaceholder(run, planConfigMap, plan) + if err != nil { return false, &terminalPlanningError{cause: err} } job := desired created := false if existing != nil { - if !workflowJobIdentityMatches(existing, desired, run) { - return false, &terminalPlanningError{cause: fmt.Errorf("WorkflowJob %q does not match dynamic matrix job %q in WorkflowRun %q", existing.Name, plan.JobID, run.Name)} + if !deferredJobResultPlaceholderMatches(existing, desired, run) { + return false, &terminalPlanningError{cause: fmt.Errorf("WorkflowJob %q does not match deferred job %q in WorkflowRun %q", existing.Name, plan.JobID, run.Name)} } job = existing } else if err := r.Create(ctx, desired); err != nil { @@ -2567,8 +2586,8 @@ func (r *WorkflowRunReconciler) completeDynamicMatrix(ctx context.Context, run * if err := r.APIReader.Get(ctx, client.ObjectKey{Namespace: run.Namespace, Name: workflowJobName(run.Name, plan.JobID)}, job); err != nil { return false, err } - if !workflowJobIdentityMatches(job, desired, run) { - return false, &terminalPlanningError{cause: fmt.Errorf("WorkflowJob %q does not match dynamic matrix job %q in WorkflowRun %q", job.Name, plan.JobID, run.Name)} + if !deferredJobResultPlaceholderMatches(job, desired, run) { + return false, &terminalPlanningError{cause: fmt.Errorf("WorkflowJob %q does not match deferred job %q in WorkflowRun %q", job.Name, plan.JobID, run.Name)} } } else { created = true @@ -2582,13 +2601,50 @@ func (r *WorkflowRunReconciler) completeDynamicMatrix(ctx context.Context, run * return true, nil } +func (r *WorkflowRunReconciler) deferredJobResultPlaceholder(run *actionsv1alpha1.WorkflowRun, planConfigMap *corev1.ConfigMap, plan deferredJobPlan) (*actionsv1alpha1.WorkflowJob, error) { + project := &actionsv1alpha1.Project{ObjectMeta: metav1.ObjectMeta{UID: types.UID(planConfigMap.Labels[actionsv1alpha1.LabelProjectUID])}} + desired := &actionsv1alpha1.WorkflowJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: workflowJobName(run.Name, plan.JobID), + Namespace: run.Namespace, + Labels: workflowJobLabels(run, project, plan.JobID), + Annotations: map[string]string{actionsv1alpha1.AnnotationProjectName: planConfigMap.Annotations[actionsv1alpha1.AnnotationProjectName]}, + }, + Spec: actionsv1alpha1.WorkflowJobSpec{ + WorkflowRunRef: corev1.LocalObjectReference{Name: run.Name}, + JobID: plan.JobID, + DisplayName: plan.JobID, + RunsOn: []string{deferredJobResultRunsOn}, + Needs: append([]string(nil), plan.Job.Needs...), + If: plan.Job.If, + }, + } + if err := controllerutil.SetControllerReference(run, desired, r.Scheme()); err != nil { + return nil, err + } + return desired, nil +} + +func deferredJobResultPlaceholderMatches(existing, desired *actionsv1alpha1.WorkflowJob, run *actionsv1alpha1.WorkflowRun) bool { + // Stored result placeholders can use either sentinel and must remain + // reconcilable across controller upgrades. + for _, runsOn := range []string{deferredJobResultRunsOn, matrixEvaluationResultRunsOn} { + candidate := desired.DeepCopy() + candidate.Spec.RunsOn = []string{runsOn} + if workflowJobIdentityMatches(existing, candidate, run) { + return true + } + } + return false +} + func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *actionsv1alpha1.WorkflowRun, workflowName string, total int32) (ctrl.Result, error) { reader := r.APIReader jobs := &actionsv1alpha1.WorkflowJobList{} if err := reader.List(ctx, jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { return ctrl.Result{}, err } - planState, err := r.reconcileDynamicMatrices(ctx, run, jobs) + planState, err := r.reconcileDeferredJobs(ctx, run, jobs) if err != nil { return ctrl.Result{}, err } @@ -2596,17 +2652,17 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac return ctrl.Result{Requeue: true}, nil } expectedObjects := int(total) - pendingMatrices := map[string]struct{}{} + pendingJobs := map[string]struct{}{} if planState.active { expectedObjects = len(planState.expected) - pendingMatrices = planState.pending - total = int32(expectedObjects + len(pendingMatrices)) + pendingJobs = planState.pending + total = int32(expectedObjects + len(pendingJobs)) } failFastPending, err := r.reconcileMatrixFailFast(ctx, jobs) if err != nil { return ctrl.Result{}, err } - status := &actionsv1alpha1.WorkflowRunJobStatus{Total: total, Waiting: int32(len(pendingMatrices))} + status := &actionsv1alpha1.WorkflowRunJobStatus{Total: total, Waiting: int32(len(pendingJobs))} var startTime *metav1.Time lostState := "" waitingForRuntimeState := false @@ -2645,7 +2701,7 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac } } variables := r.workflowRunVariableContext(ctx, run) - if err := r.reconcileWorkflowJobGraphWithDependencies(ctx, run, workflowName, inputValues, variables, eventPayload, jobs.Items, dependencyJobs, pendingMatrices); err != nil { + if err := r.reconcileWorkflowJobGraphWithDependencies(ctx, run, workflowName, inputValues, variables, eventPayload, jobs.Items, dependencyJobs, pendingJobs); err != nil { return ctrl.Result{}, err } } @@ -2777,7 +2833,7 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac Reason: "JobsPlanned", Message: plannedMessage, }) - terminal := len(pendingMatrices) == 0 && len(jobs.Items) == expectedObjects && status.Succeeded+status.Failed+status.TimedOut+status.Skipped+status.Cancelled == status.Total + terminal := len(pendingJobs) == 0 && len(jobs.Items) == expectedObjects && status.Succeeded+status.Failed+status.TimedOut+status.Skipped+status.Cancelled == status.Total if terminal && status.Cancelled > 0 { active, err := activeRuntimeWorkloads(ctx, reader, run) if err != nil { @@ -2884,14 +2940,14 @@ func (r *WorkflowRunReconciler) workflowJobGraphInputValues(ctx context.Context, return nil, nil } -func (r *WorkflowRunReconciler) reconcileWorkflowJobGraph(ctx context.Context, run *actionsv1alpha1.WorkflowRun, workflowName string, inputValues map[string]any, variables any, eventPayload map[string]any, jobs []actionsv1alpha1.WorkflowJob, pendingMatrixSets ...map[string]struct{}) error { - return r.reconcileWorkflowJobGraphWithDependencies(ctx, run, workflowName, inputValues, variables, eventPayload, jobs, nil, pendingMatrixSets...) +func (r *WorkflowRunReconciler) reconcileWorkflowJobGraph(ctx context.Context, run *actionsv1alpha1.WorkflowRun, workflowName string, inputValues map[string]any, variables any, eventPayload map[string]any, jobs []actionsv1alpha1.WorkflowJob, pendingJobSets ...map[string]struct{}) error { + return r.reconcileWorkflowJobGraphWithDependencies(ctx, run, workflowName, inputValues, variables, eventPayload, jobs, nil, pendingJobSets...) } -func (r *WorkflowRunReconciler) reconcileWorkflowJobGraphWithDependencies(ctx context.Context, run *actionsv1alpha1.WorkflowRun, workflowName string, inputValues map[string]any, variables any, eventPayload map[string]any, jobs, dependencyJobs []actionsv1alpha1.WorkflowJob, pendingMatrixSets ...map[string]struct{}) error { - pendingMatrices := map[string]struct{}{} - if len(pendingMatrixSets) > 0 { - pendingMatrices = pendingMatrixSets[0] +func (r *WorkflowRunReconciler) reconcileWorkflowJobGraphWithDependencies(ctx context.Context, run *actionsv1alpha1.WorkflowRun, workflowName string, inputValues map[string]any, variables any, eventPayload map[string]any, jobs, dependencyJobs []actionsv1alpha1.WorkflowJob, pendingJobSets ...map[string]struct{}) error { + pendingJobs := map[string]struct{}{} + if len(pendingJobSets) > 0 { + pendingJobs = pendingJobSets[0] } jobsByID := make(map[string]*actionsv1alpha1.WorkflowJob, len(jobs)+len(dependencyJobs)) for index := range dependencyJobs { @@ -2939,7 +2995,7 @@ func (r *WorkflowRunReconciler) reconcileWorkflowJobGraphWithDependencies(ctx co for _, dependency := range job.Spec.Needs { needed := jobsByLogicalID[dependency] if len(needed) == 0 { - if _, pending := pendingMatrices[dependency]; pending { + if _, pending := pendingJobs[dependency]; pending { dependenciesReady = false continue } @@ -3107,17 +3163,7 @@ func (r *WorkflowRunReconciler) workflowJobMatrixExpressionContext(ctx context.C } func workflowJobStrategyContext(matrix *actionsv1alpha1.WorkflowJobMatrix) map[string]any { - maxParallel := matrix.MaxParallel - if maxParallel == 0 { - maxParallel = matrix.JobTotal - } - result := map[string]any{ - "job-index": matrix.JobIndex, - "job-total": matrix.JobTotal, - "fail-fast": matrix.FailFast == nil || *matrix.FailFast, - "max-parallel": maxParallel, - } - return result + return workflowcontext.Strategy(matrix.JobIndex, matrix.JobTotal, matrix.MaxParallel, matrix.FailFast == nil || *matrix.FailFast) } func (r *WorkflowRunReconciler) reconcileAssignedWorkflowJobCancellation(ctx context.Context, run *actionsv1alpha1.WorkflowRun, workflowName string, inputValues map[string]any, variables any, eventPayload map[string]any, job *actionsv1alpha1.WorkflowJob, jobs map[string][]*actionsv1alpha1.WorkflowJob) error { diff --git a/internal/controller/workflowrun_controller_test.go b/internal/controller/workflowrun_controller_test.go index 1462ec9..139cf28 100644 --- a/internal/controller/workflowrun_controller_test.go +++ b/internal/controller/workflowrun_controller_test.go @@ -63,6 +63,7 @@ func (r *countingReader) Get(ctx context.Context, key client.ObjectKey, object c func TestJobPlanCoversSupportedSteps(t *testing.T) { reconciler := &WorkflowRunReconciler{GitHubServerURL: "https://github.com", GitHubAPIBase: "https://api.github.com", ActionCloneBaseURL: "https://github.com/git"} run := &actionsv1alpha1.WorkflowRun{Spec: actionsv1alpha1.WorkflowRunSpec{ + WorkflowPath: ".github/workflows/ci.yml", Source: actionsv1alpha1.WorkflowRunSource{ Type: actionsv1alpha1.SourceTypeGitHub, GitHub: &actionsv1alpha1.GitHubWorkflowRunSource{ @@ -90,6 +91,9 @@ func TestJobPlanCoversSupportedSteps(t *testing.T) { if plan.Version != runner.PlanVersion || plan.Repository.ID != 1 || plan.Event.DeliveryID != "delivery" || plan.Revision.BaseRef != "target" { t.Errorf("plan identity = %#v", plan) } + if plan.WorkflowPath != ".github/workflows/ci.yml" { + t.Errorf("workflow path = %q", plan.WorkflowPath) + } if plan.TimeoutSeconds != 90*60 || plan.CleanupTimeoutSeconds != int64(runner.CleanupTimeout/time.Second) { t.Errorf("plan timeouts = execution %d, cleanup %d", plan.TimeoutSeconds, plan.CleanupTimeoutSeconds) } @@ -420,7 +424,7 @@ func TestPlanWorkflowJobsAppliesPermissionPrecedence(t *testing.T) { if err != nil { t.Fatal(err) } - run := &actionsv1alpha1.WorkflowRun{Spec: actionsv1alpha1.WorkflowRunSpec{Source: actionsv1alpha1.WorkflowRunSource{ + run := &actionsv1alpha1.WorkflowRun{Spec: actionsv1alpha1.WorkflowRunSpec{WorkflowPath: ".github/workflows/ci.yml", Source: actionsv1alpha1.WorkflowRunSource{ Type: actionsv1alpha1.SourceTypeGitHub, GitHub: &actionsv1alpha1.GitHubWorkflowRunSource{ Actor: "octocat", @@ -1308,11 +1312,12 @@ func TestJobExpressionsIncludeRunIdentity(t *testing.T) { } run := &actionsv1alpha1.WorkflowRun{ ObjectMeta: metav1.ObjectMeta{Name: "ci", Namespace: "default"}, - Spec: actionsv1alpha1.WorkflowRunSpec{Source: actionsv1alpha1.WorkflowRunSource{ + Spec: actionsv1alpha1.WorkflowRunSpec{WorkflowPath: ".github/workflows/ci.yml", Rerun: &actionsv1alpha1.WorkflowRunRerun{TriggeringActor: "hubot"}, Source: actionsv1alpha1.WorkflowRunSource{ Type: actionsv1alpha1.SourceTypeGitHub, GitHub: &actionsv1alpha1.GitHubWorkflowRunSource{ Actor: "octocat", Event: actionsv1alpha1.GitHubEvent{Name: "push", DeliveryID: "delivery"}, - Revision: actionsv1alpha1.GitRevision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main"}, + Repository: actionsv1alpha1.GitHubRepository{ID: 1, Owner: "acme", Name: "example"}, + Revision: actionsv1alpha1.GitRevision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main"}, }, }}, Status: actionsv1alpha1.WorkflowRunStatus{Identity: &actionsv1alpha1.WorkflowRunIdentityStatus{ @@ -1320,13 +1325,13 @@ func TestJobExpressionsIncludeRunIdentity(t *testing.T) { }}, } job, err := workflow.EvaluateJob("test", workflow.Job{ - Name: "${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}-${{ github.actor }}-${{ open_actions.run_url }}-${{ open_actions.run_query_url }}", + Name: "${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}-${{ github.actor }}-${{ github.triggering_actor }}-${{ github.repository_owner }}-${{ github.ref_type }}-${{ github.workflow_ref }}-${{ open_actions.run_url }}-${{ open_actions.run_query_url }}", RunsOn: workflow.StringList{"ubuntu-latest"}, }, reconciler.jobExpressionContext(run, "CI", nil, nil, nil)) if err != nil { t.Fatal(err) } - want := "101-7-2-octocat-https://actions.example/runs/default/ci-https://actions.example/api/v1/runs/default/ci/newer" + want := "101-7-2-octocat-hubot-acme-branch-acme/example/.github/workflows/ci.yml@refs/heads/main-https://actions.example/runs/default/ci-https://actions.example/api/v1/runs/default/ci/newer" if job.Name != want { t.Fatalf("job name = %q, want %q", job.Name, want) } @@ -1371,7 +1376,10 @@ func TestPlanWorkflowJobsExpandsArchitectureMatrix(t *testing.T) { if err := json.Unmarshal([]byte(job.plan), plan); err != nil { t.Fatal(err) } - if plan.JobID != "build-images" || plan.Matrix["arch"] != arch || plan.Outputs["image"] != "${{ matrix.arch }}-${{ steps.build.outputs.image }}" { + if plan.JobID != "build-images" || plan.Matrix["arch"] != arch || + plan.Strategy["job-index"] != float64(index) || plan.Strategy["job-total"] != float64(2) || + plan.Strategy["max-parallel"] != float64(1) || plan.Strategy["fail-fast"] != true || + plan.Outputs["image"] != "${{ matrix.arch }}-${{ steps.build.outputs.image }}" { t.Errorf("plan for %s = %#v", arch, plan) } } @@ -1403,25 +1411,30 @@ func TestPlanWorkflowJobsEnforcesExpandedWorkflowLimit(t *testing.T) { } } -func TestStaticMatrixJobCannotUseUnavailableNeedsContext(t *testing.T) { - definition, err := workflow.Parse([]byte("name: CI\non: push\njobs:\n prepare:\n runs-on: ubuntu-latest\n steps:\n - run: prepare\n build:\n needs: prepare\n strategy:\n matrix:\n arch: [amd64]\n name: Build ${{ needs.prepare.result }}\n runs-on: ubuntu-latest\n steps:\n - run: build\n")) - if err != nil { - t.Fatal(err) - } - run := &actionsv1alpha1.WorkflowRun{Spec: actionsv1alpha1.WorkflowRunSpec{Source: actionsv1alpha1.WorkflowRunSource{GitHub: &actionsv1alpha1.GitHubWorkflowRunSource{}}}} - setTestWorkflowRunIdentity(run) - _, _, err = (&WorkflowRunReconciler{}).planWorkflowJobs(run, definition, nil, nil, nil) - if err == nil || !strings.Contains(err.Error(), `context "needs" is unavailable`) { - t.Fatalf("planning error = %v, want unavailable needs context", err) +func TestJobPlanningDefersNeedsContext(t *testing.T) { + for _, strategy := range []string{"", " strategy:\n matrix:\n arch: [amd64]\n"} { + definition, err := workflow.Parse([]byte("name: CI\non: push\njobs:\n prepare:\n runs-on: ubuntu-latest\n steps:\n - run: prepare\n build:\n needs: prepare\n" + strategy + " name: Build ${{ needs.prepare.result }}\n runs-on: ubuntu-latest\n steps:\n - run: build\n")) + if err != nil { + t.Fatal(err) + } + run := &actionsv1alpha1.WorkflowRun{Spec: actionsv1alpha1.WorkflowRunSpec{Source: actionsv1alpha1.WorkflowRunSource{GitHub: &actionsv1alpha1.GitHubWorkflowRunSource{}}}} + setTestWorkflowRunIdentity(run) + planned, deferred, err := (&WorkflowRunReconciler{}).planWorkflowJobs(run, definition, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(planned) != 1 || planned[0].id != "prepare" || len(deferred) != 1 || deferred[0].JobID != "build" { + t.Fatalf("planned = %#v, deferred = %#v", planned, deferred) + } } } -func TestProjectedWorkflowJobCountIncludesExistingAndPendingMatrices(t *testing.T) { - matrixIDs := []string{"a", "m", "z"} +func TestProjectedWorkflowJobCountIncludesExistingAndPendingJobs(t *testing.T) { + deferredJobIDs := []string{"a", "m", "z"} jobsByLogicalID := map[string][]*actionsv1alpha1.WorkflowJob{ "z": make([]*actionsv1alpha1.WorkflowJob, 50), } - if got := projectedWorkflowJobCount(950, jobsByLogicalID, matrixIDs, "a", 50); got != 1001 { + if got := projectedWorkflowJobCount(950, jobsByLogicalID, deferredJobIDs, "a", 50); got != 1001 { t.Fatalf("projected jobs = %d, want 1001", got) } @@ -1455,6 +1468,175 @@ func TestPlanWorkflowJobsPreservesDisabledMatrixFailFast(t *testing.T) { } } +func TestReconcileDeferredJobConfigurationFromDependencyResult(t *testing.T) { + scheme := runnerTestScheme(t) + run := &actionsv1alpha1.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "deferred", Namespace: "default", UID: types.UID("run-uid")}, + Spec: actionsv1alpha1.WorkflowRunSpec{ + ProjectRef: corev1.LocalObjectReference{Name: "default"}, + WorkflowPath: ".github/workflows/ci.yml", + Source: actionsv1alpha1.WorkflowRunSource{Type: actionsv1alpha1.SourceTypeGitHub, GitHub: &actionsv1alpha1.GitHubWorkflowRunSource{ + Repository: actionsv1alpha1.GitHubRepository{ID: 1, Owner: "acme", Name: "example"}, + Event: actionsv1alpha1.GitHubEvent{Name: actionsv1alpha1.GitHubEventNamePush, DeliveryID: "delivery"}, + Revision: actionsv1alpha1.GitRevision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main"}, + }}, + }, + } + setTestWorkflowRunIdentity(run) + project := &actionsv1alpha1.Project{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default", UID: types.UID("project-uid")}} + definition, err := workflow.Parse([]byte("name: Deferred\non: push\njobs:\n prepare:\n runs-on: ubuntu-latest\n steps:\n - run: prepare\n build:\n needs: prepare\n name: Build ${{ needs.prepare.result }}\n runs-on: ubuntu-latest\n steps:\n - run: build\n")) + if err != nil { + t.Fatal(err) + } + clusterClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&actionsv1alpha1.WorkflowRun{}, &actionsv1alpha1.WorkflowJob{}). + WithObjects(run). + Build() + reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient} + planned, deferred, err := reconciler.planWorkflowJobs(run, definition, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := reconciler.ensureWorkflowPlan(context.Background(), run, project, planned, deferred); err != nil { + t.Fatal(err) + } + if err := reconciler.ensureWorkflowJobs(context.Background(), run, project, planned); err != nil { + t.Fatal(err) + } + + jobs := &actionsv1alpha1.WorkflowJobList{} + if err := clusterClient.List(context.Background(), jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { + t.Fatal(err) + } + for index := range jobs.Items { + if jobs.Items[index].Spec.JobID == "prepare" { + jobs.Items[index].Status.Result = actionsv1alpha1.WorkflowJobResultSuccess + if err := clusterClient.Status().Update(context.Background(), &jobs.Items[index]); err != nil { + t.Fatal(err) + } + } + } + jobs = &actionsv1alpha1.WorkflowJobList{} + if err := clusterClient.List(context.Background(), jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { + t.Fatal(err) + } + state, err := reconciler.reconcileDeferredJobs(context.Background(), run, jobs) + if err != nil { + t.Fatal(err) + } + if !state.changed { + t.Fatal("deferred job creation did not report a change") + } + + build := &actionsv1alpha1.WorkflowJob{} + if err := clusterClient.Get(context.Background(), client.ObjectKey{Namespace: run.Namespace, Name: workflowJobName(run.Name, "build")}, build); err != nil { + t.Fatal(err) + } + if build.Spec.DisplayName != "Build success" || build.Spec.Matrix != nil { + t.Fatalf("deferred WorkflowJob = %#v", build.Spec) + } +} + +func TestReconcileDeferredJobsCompletesExistingResultPlaceholder(t *testing.T) { + for _, test := range []struct { + name string + strategy string + runsOn string + }{ + {name: "job", runsOn: deferredJobResultRunsOn}, + {name: "matrix", strategy: " strategy:\n matrix:\n arch: [amd64]\n", runsOn: deferredJobResultRunsOn}, + {name: "persisted matrix placeholder", strategy: " strategy:\n matrix:\n arch: [amd64]\n", runsOn: matrixEvaluationResultRunsOn}, + } { + t.Run(test.name, func(t *testing.T) { + scheme := runnerTestScheme(t) + run := &actionsv1alpha1.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "deferred", Namespace: "default", UID: types.UID("run-uid")}, + Spec: actionsv1alpha1.WorkflowRunSpec{ + ProjectRef: corev1.LocalObjectReference{Name: "default"}, + Source: actionsv1alpha1.WorkflowRunSource{Type: actionsv1alpha1.SourceTypeGitHub, GitHub: &actionsv1alpha1.GitHubWorkflowRunSource{ + Repository: actionsv1alpha1.GitHubRepository{ID: 1, Owner: "acme", Name: "example"}, + Event: actionsv1alpha1.GitHubEvent{Name: actionsv1alpha1.GitHubEventNamePush, DeliveryID: "delivery"}, + Revision: actionsv1alpha1.GitRevision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main"}, + }}, + }, + } + setTestWorkflowRunIdentity(run) + project := &actionsv1alpha1.Project{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default", UID: types.UID("project-uid")}} + definition, err := workflow.Parse([]byte("name: Deferred\non: push\njobs:\n prepare:\n runs-on: ubuntu-latest\n steps:\n - run: prepare\n build:\n needs: prepare\n if: ${{ needs.prepare.result == 'failure' }}\n name: Build ${{ needs.prepare.result }}\n" + test.strategy + " runs-on: ubuntu-latest\n steps:\n - run: build\n")) + if err != nil { + t.Fatal(err) + } + clusterClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&actionsv1alpha1.WorkflowRun{}, &actionsv1alpha1.WorkflowJob{}). + WithObjects(run). + Build() + reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient} + planned, deferred, err := reconciler.planWorkflowJobs(run, definition, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(planned) != 1 || len(deferred) != 1 { + t.Fatalf("planned = %#v, deferred = %#v", planned, deferred) + } + ctx := context.Background() + if err := reconciler.ensureWorkflowPlan(ctx, run, project, planned, deferred); err != nil { + t.Fatal(err) + } + if err := reconciler.ensureWorkflowJobs(ctx, run, project, planned); err != nil { + t.Fatal(err) + } + + jobs := &actionsv1alpha1.WorkflowJobList{} + if err := clusterClient.List(ctx, jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { + t.Fatal(err) + } + prepare := &jobs.Items[0] + prepare.Status.Result = actionsv1alpha1.WorkflowJobResultSuccess + if err := clusterClient.Status().Update(ctx, prepare); err != nil { + t.Fatal(err) + } + planConfigMap := &corev1.ConfigMap{} + if err := clusterClient.Get(ctx, client.ObjectKey{Namespace: run.Namespace, Name: deferredJobPlanConfigMapName(run.Name, "build")}, planConfigMap); err != nil { + t.Fatal(err) + } + placeholder, err := reconciler.deferredJobResultPlaceholder(run, planConfigMap, deferred[0]) + if err != nil { + t.Fatal(err) + } + placeholder.Spec.RunsOn = []string{test.runsOn} + if err := clusterClient.Create(ctx, placeholder); err != nil { + t.Fatal(err) + } + + jobs = &actionsv1alpha1.WorkflowJobList{} + if err := clusterClient.List(ctx, jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { + t.Fatal(err) + } + state, err := reconciler.reconcileDeferredJobs(ctx, run, jobs) + if err != nil { + t.Fatal(err) + } + if !state.changed { + t.Fatal("placeholder completion did not report a change") + } + stored := &actionsv1alpha1.WorkflowJob{} + if err := clusterClient.Get(ctx, client.ObjectKeyFromObject(placeholder), stored); err != nil { + t.Fatal(err) + } + if stored.Status.Result != actionsv1alpha1.WorkflowJobResultSkipped || stored.Status.CompletionTime == nil { + t.Fatalf("placeholder status = %#v", stored.Status) + } + jobs = &actionsv1alpha1.WorkflowJobList{} + if err := clusterClient.List(ctx, jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { + t.Fatal(err) + } + if len(jobs.Items) != 2 { + t.Fatalf("WorkflowJobs = %d, want dependency and result placeholder", len(jobs.Items)) + } + }) + } +} + func TestReconcileDynamicMatrixFromDependencyOutput(t *testing.T) { scheme := runnerTestScheme(t) run := &actionsv1alpha1.WorkflowRun{ @@ -1502,7 +1684,7 @@ func TestReconcileDynamicMatrixFromDependencyOutput(t *testing.T) { if err := clusterClient.List(context.Background(), jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { t.Fatal(err) } - state, err := reconciler.reconcileDynamicMatrices(context.Background(), run, jobs) + state, err := reconciler.reconcileDeferredJobs(context.Background(), run, jobs) if err != nil { t.Fatal(err) } @@ -1520,7 +1702,7 @@ func TestReconcileDynamicMatrixFromDependencyOutput(t *testing.T) { if err := clusterClient.List(context.Background(), jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { t.Fatal(err) } - state, err = reconciler.reconcileDynamicMatrices(context.Background(), run, jobs) + state, err = reconciler.reconcileDeferredJobs(context.Background(), run, jobs) if err != nil { t.Fatal(err) } @@ -1571,7 +1753,7 @@ func TestReconcileDynamicMatrixFromDependencyOutput(t *testing.T) { } restarted := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient, GitHubAPIBase: "https://api.github.example", GitHubServerURL: "https://github.example"} - state, err = restarted.reconcileDynamicMatrices(context.Background(), run, jobs) + state, err = restarted.reconcileDeferredJobs(context.Background(), run, jobs) if err != nil { t.Fatal(err) } @@ -1580,7 +1762,7 @@ func TestReconcileDynamicMatrixFromDependencyOutput(t *testing.T) { } run.Spec.CancelRequested = true - state, err = restarted.reconcileDynamicMatrices(context.Background(), run, jobs) + state, err = restarted.reconcileDeferredJobs(context.Background(), run, jobs) if err != nil { t.Fatal(err) } @@ -1605,7 +1787,7 @@ func TestReconcileDynamicMatrixFromDependencyOutput(t *testing.T) { } } -func TestReconcileDynamicMatricesTreatsMissingPlanAsTerminal(t *testing.T) { +func TestReconcileDeferredJobsTreatsMissingPlanAsTerminal(t *testing.T) { scheme := runnerTestScheme(t) run := &actionsv1alpha1.WorkflowRun{ ObjectMeta: metav1.ObjectMeta{ @@ -1616,8 +1798,8 @@ func TestReconcileDynamicMatricesTreatsMissingPlanAsTerminal(t *testing.T) { }, } data, err := json.Marshal(workflowPlanManifest{ - SourceIDs: []string{"build"}, - Matrices: map[string]string{"build": "missing-plan-build-matrix-plan"}, + SourceIDs: []string{"build"}, + DeferredJobs: map[string]string{"build": "missing-plan-build-matrix-plan"}, }) if err != nil { t.Fatal(err) @@ -1633,14 +1815,14 @@ func TestReconcileDynamicMatricesTreatsMissingPlanAsTerminal(t *testing.T) { } clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(run, workflowPlan).Build() reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient} - _, err = reconciler.reconcileDynamicMatrices(context.Background(), run, &actionsv1alpha1.WorkflowJobList{}) + _, err = reconciler.reconcileDeferredJobs(context.Background(), run, &actionsv1alpha1.WorkflowJobList{}) terminal := &terminalPlanningError{} if !errors.As(err, &terminal) || !strings.Contains(err.Error(), "missing-plan-build-matrix-plan") || !strings.Contains(err.Error(), run.Name) { t.Fatalf("reconciliation error = %v, want terminal missing-plan error naming the ConfigMap and WorkflowRun", err) } } -func TestReconcileDynamicMatricesRefreshesJobsBetweenExpansions(t *testing.T) { +func TestReconcileDeferredJobsRefreshesJobsBetweenExpansions(t *testing.T) { scheme := runnerTestScheme(t) run := &actionsv1alpha1.WorkflowRun{ ObjectMeta: metav1.ObjectMeta{Name: "dynamic", Namespace: "default", UID: types.UID("run-uid")}, @@ -1684,7 +1866,7 @@ func TestReconcileDynamicMatricesRefreshesJobsBetweenExpansions(t *testing.T) { if err := clusterClient.List(context.Background(), jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { t.Fatal(err) } - state, err := reconciler.reconcileDynamicMatrices(context.Background(), run, jobs) + state, err := reconciler.reconcileDeferredJobs(context.Background(), run, jobs) if err != nil { t.Fatal(err) } @@ -1734,7 +1916,7 @@ func TestReconcileDynamicMatrixFailsInvalidOutput(t *testing.T) { if err := clusterClient.List(context.Background(), jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { t.Fatal(err) } - state, err := reconciler.reconcileDynamicMatrices(context.Background(), run, jobs) + state, err := reconciler.reconcileDeferredJobs(context.Background(), run, jobs) if err != nil { t.Fatal(err) } @@ -1749,7 +1931,7 @@ func TestReconcileDynamicMatrixFailsInvalidOutput(t *testing.T) { t.Fatalf("result = %q, want failure", failed.Status.Result) } condition := meta.FindStatusCondition(failed.Status.Conditions, actionsv1alpha1.WorkflowJobConditionSucceeded) - if condition == nil || condition.Reason != "MatrixEvaluationFailed" || !strings.Contains(condition.Message, "parse JSON") { + if condition == nil || condition.Reason != "JobPlanningFailed" || !strings.Contains(condition.Message, "parse JSON") { t.Fatalf("Succeeded condition = %#v", condition) } } diff --git a/internal/eventsnapshot/snapshot.go b/internal/eventsnapshot/snapshot.go index 9ebb6cf..0e4d116 100644 --- a/internal/eventsnapshot/snapshot.go +++ b/internal/eventsnapshot/snapshot.go @@ -20,6 +20,7 @@ func Decode(data []byte) (map[string]any, error) { } document := map[string]any{} decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() if err := decoder.Decode(&document); err != nil { return nil, fmt.Errorf("decode GitHub event snapshot: %w", err) } diff --git a/internal/eventsnapshot/snapshot_test.go b/internal/eventsnapshot/snapshot_test.go index 5c98fca..646402b 100644 --- a/internal/eventsnapshot/snapshot_test.go +++ b/internal/eventsnapshot/snapshot_test.go @@ -1,14 +1,19 @@ package eventsnapshot import ( + "encoding/json" "strings" "testing" ) func TestDecodeBoundsSnapshot(t *testing.T) { - if _, err := Decode([]byte(`{"repository":{"full_name":"acme/example"}}`)); err != nil { + document, err := Decode([]byte(`{"repository":{"id":9007199254740993,"full_name":"acme/example"}}`)) + if err != nil { t.Fatal(err) } + if document["repository"].(map[string]any)["id"] != json.Number("9007199254740993") { + t.Fatalf("decoded snapshot = %#v", document) + } if _, err := Decode([]byte(`{"first":1}{"second":2}`)); err == nil { t.Fatal("Decode() accepted trailing JSON") } diff --git a/internal/expression/expression_test.go b/internal/expression/expression_test.go index d2f96a0..1efa334 100644 --- a/internal/expression/expression_test.go +++ b/internal/expression/expression_test.go @@ -2,6 +2,7 @@ package expression import ( "crypto/sha256" + "encoding/json" "fmt" "os" "path/filepath" @@ -10,6 +11,17 @@ import ( "testing" ) +func TestEvaluationPreservesDecodedJSONNumberType(t *testing.T) { + context := Context{ + Availability: NewAvailability("matrix"), + Values: map[string]any{"matrix": map[string]any{"count": json.Number("2")}}, + } + result := evaluateForTest(t, "${{ matrix.count }}", context) + if result.Value != float64(2) { + t.Fatalf("value = %#v (%T), want numeric 2", result.Value, result.Value) + } +} + func TestTemplateEvaluationPreservesTypesAndLiteralText(t *testing.T) { context := Context{ Availability: NewAvailability("matrix"), diff --git a/internal/expression/value.go b/internal/expression/value.go index 31c85dd..48b7086 100644 --- a/internal/expression/value.go +++ b/internal/expression/value.go @@ -1,6 +1,7 @@ package expression import ( + "encoding/json" "fmt" "math" "reflect" @@ -105,6 +106,12 @@ func normalize(input any, sensitive bool) value { return value{kind: boolKind, boolean: typed, sensitive: sensitive} case string: return value{kind: stringKind, text: typed, sensitive: sensitive} + case json.Number: + number, err := typed.Float64() + if err != nil { + return value{kind: stringKind, text: typed.String(), sensitive: sensitive} + } + return value{kind: numberKind, number: number, sensitive: sensitive} case float64: return value{kind: numberKind, number: typed, sensitive: sensitive} case float32: diff --git a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml index 8cc0d95..36d9acb 100644 --- a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml +++ b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml @@ -194,6 +194,14 @@ spec: minLength: 1 pattern: ^[A-Za-z0-9-]+$ type: string + triggeringActor: + description: |- + TriggeringActor is the GitHub login that requested this attempt. It is + omitted when the rerun source cannot identify a GitHub user. + maxLength: 100 + minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9_-]*(\[bot\])?$ + type: string required: - attempt - originalRunRef diff --git a/internal/runner/action.go b/internal/runner/action.go index 333b725..ea42a37 100644 --- a/internal/runner/action.go +++ b/internal/runner/action.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/kelos-dev/open-actions/internal/actionref" + workflowexpression "github.com/kelos-dev/open-actions/internal/expression" "github.com/kelos-dev/open-actions/internal/gitrepository" "gopkg.in/yaml.v3" ) @@ -169,9 +170,6 @@ func (r *actionResolver) resolve(ctx context.Context, uses string) (preparedActi return preparedAction{}, fmt.Errorf("action %s uses an unsupported lifecycle condition", uses) } case "composite": - if err := validateComposite(definition); err != nil { - return preparedAction{}, fmt.Errorf("action %s: %w", uses, err) - } default: return preparedAction{}, fmt.Errorf("action %s uses unsupported runtime %q", uses, definition.Runs.Using) } @@ -180,7 +178,7 @@ func (r *actionResolver) resolve(ctx context.Context, uses string) (preparedActi return action, nil } -func (r *actionResolver) invocation(step Step, plan *Plan, token string) (*actionInvocation, error) { +func (r *actionResolver) invocation(step Step, plan *Plan, token string, status *workflowexpression.Status) (*actionInvocation, error) { reference, err := actionref.Parse(step.Uses) if err != nil { return nil, err @@ -189,7 +187,7 @@ func (r *actionResolver) invocation(step Step, plan *Plan, token string) (*actio if !found { return nil, fmt.Errorf("action %s was not prepared", step.Uses) } - inputs, err := actionInputs(action.definition.Inputs, step.With, plan, r.environment, token) + inputs, err := actionInputsForAction(action.definition.Inputs, step.With, plan, r.environment, token, action, status) if err != nil { return nil, fmt.Errorf("configure action %s: %w", step.Uses, err) } @@ -297,10 +295,39 @@ func loadActionDefinition(directory string) (actionDefinition, error) { return actionDefinition{}, fmt.Errorf("input %q default: %w", name, err) } } + for name, output := range definition.Outputs { + if output.Value == nil { + continue + } + value, err := inputString(output.Value) + if err != nil { + return actionDefinition{}, fmt.Errorf("output %q value: %w", name, err) + } + if err := validateActionExpression(value, compositeOutputAvailability); err != nil { + return actionDefinition{}, fmt.Errorf("output %q value: %w", name, err) + } + } + if definition.Runs.Using == "composite" { + if err := validateComposite(definition); err != nil { + return actionDefinition{}, err + } + } return definition, nil } func actionInputs(definitions map[string]actionInput, supplied map[string]string, plan *Plan, environment []string, token string) (map[string]string, error) { + return actionInputsWithContext(definitions, supplied, plan, environment, token, "", "", "", nil) +} + +func actionInputsForAction(definitions map[string]actionInput, supplied map[string]string, plan *Plan, environment []string, token string, action preparedAction, status *workflowexpression.Status) (map[string]string, error) { + actionPath := "" + if action.definition.Runs.Using == "composite" { + actionPath = action.directory + } + return actionInputsWithContext(definitions, supplied, plan, environment, token, actionPath, action.reference.Owner+"/"+action.reference.Repository, action.reference.Ref, status) +} + +func actionInputsWithContext(definitions map[string]actionInput, supplied map[string]string, plan *Plan, environment []string, token, actionPath, actionRepository, actionRef string, status *workflowexpression.Status) (map[string]string, error) { result := make(map[string]string, len(definitions)+len(supplied)) definitionNames := make(map[string]string, len(definitions)) for name := range definitions { @@ -329,7 +356,7 @@ func actionInputs(definitions map[string]actionInput, supplied map[string]string if err != nil { return nil, fmt.Errorf("input %q default: %w", name, err) } - resolved, err := resolveActionDefault(value, plan, environment, token) + resolved, err := resolveActionDefault(value, plan, environment, token, actionPath, actionRepository, actionRef, status) if err != nil { return nil, fmt.Errorf("input %q default: %w", name, err) } @@ -350,11 +377,11 @@ func inputString(value any) (string, error) { } } -func resolveActionDefault(value string, plan *Plan, environment []string, token string) (string, error) { - return resolveActionDefaultExpression(value, plan, environment, token) +func resolveActionDefault(value string, plan *Plan, environment []string, token, actionPath, actionRepository, actionRef string, status *workflowexpression.Status) (string, error) { + return resolveActionDefaultExpression(value, plan, environment, token, actionPath, actionRepository, actionRef, status) } -func (e *Executor) runJavaScriptHook(ctx context.Context, invocation *actionInvocation, hook, entrypoint, temporaryDirectory, workspace string, environment *[]string) error { +func (e *Executor) runJavaScriptHook(ctx context.Context, invocation *actionInvocation, hook, entrypoint string, state *executionState) error { if entrypoint == "" { return nil } @@ -368,11 +395,11 @@ func (e *Executor) runJavaScriptHook(ctx context.Context, invocation *actionInvo if _, err := os.Stat(entrypointPath); err != nil { return fmt.Errorf("find %s entrypoint: %w", hook, err) } - files, err := newCommandFiles(filepath.Join(temporaryDirectory, "commands"), fmt.Sprintf("%d-%s", e.commandID.Add(1), hook)) + files, err := newCommandFiles(filepath.Join(state.temporaryDirectory, "commands"), fmt.Sprintf("%d-%s", e.commandID.Add(1), hook)) if err != nil { return err } - actionEnvironment := append([]string(nil), (*environment)...) + actionEnvironment := append([]string(nil), state.environment...) actionEnvironment = appendEnvironment(actionEnvironment, invocation.step.Env) actionEnvironment = setEnvironment(actionEnvironment, "GITHUB_ACTION_PATH", invocation.directory) actionEnvironment = setEnvironment(actionEnvironment, "GITHUB_ACTION_REPOSITORY", invocation.reference.Owner+"/"+invocation.reference.Repository) @@ -385,12 +412,13 @@ func (e *Executor) runJavaScriptHook(ctx context.Context, invocation *actionInvo actionEnvironment = setEnvironment(actionEnvironment, "STATE_"+name, value) } } - executionError := e.executeCommandWithFiles(ctx, invocation.executable, []string{entrypointPath}, workspace, actionEnvironment, &files, workspace) + executionError := e.executeCommandWithFiles(ctx, invocation.executable, []string{entrypointPath}, state.workspace, actionEnvironment, &files, state.workspace) updates, commandError := files.read() if commandError != nil { return errors.Join(executionError, commandError) } - applyEnvironmentUpdates(environment, updates) + applyEnvironmentUpdates(&state.environment, updates) + applyContextEnvironmentUpdates(&state.contextEnvironment, updates) for name, value := range updates.state { invocation.state[name] = value } @@ -403,7 +431,7 @@ func (e *Executor) runJavaScriptHook(ctx context.Context, invocation *actionInvo } func (e *Executor) executeAction(ctx context.Context, state *executionState, step Step, cancelled bool) (map[string]string, error) { - invocation, err := state.resolver.invocation(step, state.plan, e.githubToken) + invocation, err := state.resolver.invocation(step, state.plan, e.githubToken, &state.jobStatus) if err != nil { return nil, err } @@ -423,10 +451,10 @@ func (e *Executor) executeAction(ctx context.Context, state *executionState, ste if invocation.definition.Runs.Post != "" { state.posts = append(state.posts, invocation) } - if err := e.runJavaScriptHook(ctx, invocation, "pre", invocation.definition.Runs.Pre, state.temporaryDirectory, state.workspace, &state.environment); err != nil { + if err := e.runJavaScriptHook(ctx, invocation, "pre", invocation.definition.Runs.Pre, state); err != nil { return nil, err } - if err := e.runJavaScriptHook(ctx, invocation, "main", invocation.definition.Runs.Main, state.temporaryDirectory, state.workspace, &state.environment); err != nil { + if err := e.runJavaScriptHook(ctx, invocation, "main", invocation.definition.Runs.Main, state); err != nil { return invocation.outputs, err } if integrate { diff --git a/internal/runner/composite.go b/internal/runner/composite.go index 8b9b5a1..76cff3d 100644 --- a/internal/runner/composite.go +++ b/internal/runner/composite.go @@ -36,8 +36,11 @@ type compositeStep struct { type compositeContext struct { inputs map[string]string - stepOutput map[string]map[string]string + stepOutput map[string]map[string]any actionPath string + actionRef string + actionRepository string + actionStatus string scopedEnvironment map[string]string state *executionState } @@ -78,15 +81,62 @@ func validateComposite(definition actionDefinition) error { return fmt.Errorf("composite action step %d configures run-only fields", index+1) } } + for field, value := range map[string]string{ + "name": step.Name, "run": step.Run, "shell": step.Shell, "working-directory": step.WorkingDirectory, + } { + if err := validateActionExpression(value, compositeAvailability); err != nil { + return fmt.Errorf("composite step %d %s: %w", index+1, field, err) + } + } + for field, values := range map[string]map[string]any{"env": step.Env, "with": step.With} { + for name, rawValue := range values { + value, err := inputString(rawValue) + if err != nil { + return fmt.Errorf("composite step %d %s value %q: %w", index+1, field, name, err) + } + if err := validateActionExpression(value, compositeAvailability); err != nil { + return fmt.Errorf("composite step %d %s value %q: %w", index+1, field, name, err) + } + } + } + if step.ContinueOnError != nil { + value, err := inputString(step.ContinueOnError) + if err != nil { + return fmt.Errorf("composite step %d continue-on-error: %w", index+1, err) + } + if err := validateActionExpression(value, compositeAvailability); err != nil { + return fmt.Errorf("composite step %d continue-on-error: %w", index+1, err) + } + } + if strings.TrimSpace(step.If) != "" { + condition, err := workflowexpression.ParseCondition(step.If) + if err != nil { + return fmt.Errorf("composite step %d if: %w", index+1, err) + } + if err := condition.Validate(compositeConditionAvailability); err != nil { + return fmt.Errorf("composite step %d if: %w", index+1, err) + } + } } return nil } +func validateActionExpression(value string, availability workflowexpression.Availability) error { + program, err := workflowexpression.Parse(value) + if err != nil { + return err + } + return program.Validate(availability) +} + func (e *Executor) runComposite(ctx context.Context, state *executionState, invocation *actionInvocation, cancelled bool) (map[string]string, error) { compositeContext := &compositeContext{ inputs: invocation.inputs, - stepOutput: map[string]map[string]string{}, + stepOutput: map[string]map[string]any{}, actionPath: invocation.directory, + actionRef: invocation.reference.Ref, + actionRepository: invocation.reference.Owner + "/" + invocation.reference.Repository, + actionStatus: "success", scopedEnvironment: invocation.step.Env, state: state, } @@ -99,6 +149,9 @@ func (e *Executor) runComposite(ctx context.Context, state *executionState, invo return nil, fmt.Errorf("composite step %d condition: %w", index+1, err) } if !runStep { + if rawStep.ID != "" { + recordCompositeStep(compositeContext, rawStep.ID, nil, "skipped", "skipped") + } continue } stepEnvironment, err := resolveCompositeMap(rawStep.Env, compositeContext) @@ -135,19 +188,26 @@ func (e *Executor) runComposite(ctx context.Context, state *executionState, invo } else { outputs, err = e.runCompositeScript(commandContext, state, invocation, step) } - if rawStep.ID != "" && outputs != nil { - compositeContext.stepOutput[rawStep.ID] = outputs - } if err != nil { if continueOnError { + recordCompositeStep(compositeContext, rawStep.ID, outputs, "failure", "success") e.logger.Warn("composite step failed with continue-on-error", "action", invocation.step.Uses, "step", index+1, "name", name, "error", e.masker.mask(err.Error())) } else { cancelledDuringCommand := !cancelledBeforeCommand && ctx.Err() != nil + outcome := "failure" + if cancelledDuringCommand { + outcome = "cancelled" + compositeContext.actionStatus = "cancelled" + } + recordCompositeStep(compositeContext, rawStep.ID, outputs, outcome, outcome) if !cancelledDuringCommand { compositeFailed = true + compositeContext.actionStatus = "failure" } compositeError = errors.Join(compositeError, fmt.Errorf("composite step %d (%s): %w", index+1, name, err)) } + } else { + recordCompositeStep(compositeContext, rawStep.ID, outputs, "success", "success") } e.logger.Info("completed composite step", "action", invocation.step.Uses, "step", index+1, "name", name) } @@ -161,7 +221,7 @@ func (e *Executor) runComposite(ctx context.Context, state *executionState, invo if err != nil { return outputs, errors.Join(compositeError, fmt.Errorf("composite output %q: %w", name, err)) } - value, err = resolveCompositeExpressions(value, compositeContext) + value, err = resolveCompositeOutput(value, compositeContext) if err != nil { return outputs, errors.Join(compositeError, fmt.Errorf("composite output %q: %w", name, err)) } @@ -170,6 +230,17 @@ func (e *Executor) runComposite(ctx context.Context, state *executionState, invo return outputs, compositeError } +func recordCompositeStep(context *compositeContext, id string, outputs map[string]string, outcome, conclusion string) { + if id == "" { + return + } + values := make(map[string]any, len(outputs)) + for name, value := range outputs { + values[name] = value + } + context.stepOutput[id] = map[string]any{"outputs": values, "outcome": outcome, "conclusion": conclusion} +} + func (e *Executor) runCompositeScript(ctx context.Context, state *executionState, invocation *actionInvocation, step Step) (map[string]string, error) { directory, err := withinDirectory(state.workspace, step.WorkingDirectory) if err != nil { @@ -190,6 +261,7 @@ func (e *Executor) runCompositeScript(ctx context.Context, state *executionState return nil, errors.Join(executionError, commandError) } applyEnvironmentUpdates(&state.environment, updates) + applyContextEnvironmentUpdates(&state.contextEnvironment, updates) e.logCommandNames("workflow step output", updates.outputs) return updates.outputs, executionError } @@ -318,6 +390,10 @@ func resolveCompositeExpressions(value string, compositeContext *compositeContex return resolveExpressionString(value, context) } +func resolveCompositeOutput(value string, compositeContext *compositeContext) (string, error) { + return resolveExpressionString(value, compositeOutputExpressionContext(compositeContext)) +} + func applyEnvironmentUpdates(environment *[]string, updates commandUpdates) { for name, value := range updates.environment { if workflowenv.IsRunnerOwned(name) || workflowenv.IsEnvironmentFileBlocked(name) { @@ -335,6 +411,18 @@ func applyEnvironmentUpdates(environment *[]string, updates commandUpdates) { *environment = setEnvironment(*environment, "PATH", pathValue) } +func applyContextEnvironmentUpdates(environment *map[string]string, updates commandUpdates) { + if *environment == nil { + *environment = map[string]string{} + } + for name, value := range updates.environment { + if workflowenv.IsRunnerOwned(name) || workflowenv.IsEnvironmentFileBlocked(name) { + continue + } + (*environment)[name] = value + } +} + func mergeEnvironment(base, override map[string]string) map[string]string { if len(base) == 0 && len(override) == 0 { return nil diff --git a/internal/runner/expression.go b/internal/runner/expression.go index 546115c..b33013d 100644 --- a/internal/runner/expression.go +++ b/internal/runner/expression.go @@ -8,23 +8,29 @@ import ( workflowexpression "github.com/kelos-dev/open-actions/internal/expression" "github.com/kelos-dev/open-actions/internal/workflow" + "github.com/kelos-dev/open-actions/internal/workflowcontext" ) var ( - runnerJobAvailability = workflowexpression.NewAvailability("github", "open_actions", "matrix", "needs", "vars", "secrets", "inputs") - actionDefaultAvailability = workflowexpression.NewAvailability("github", "open_actions", "runner") - runnerStepAvailability = workflowexpression.NewAvailability("github", "open_actions", "matrix", "needs", "runner", "env", "vars", "secrets", "inputs", "steps").WithHashFiles() - runnerConditionAvailability = workflowexpression.NewAvailability("github", "open_actions", "matrix", "needs", "runner", "env", "vars", "inputs", "steps").WithStatusFunctions().WithHashFiles() - compositeAvailability = workflowexpression.NewAvailability("github", "open_actions", "runner", "env", "inputs", "steps").WithHashFiles() - compositeConditionAvailability = workflowexpression.NewAvailability("github", "open_actions", "runner", "env", "inputs", "steps").WithStatusFunctions().WithHashFiles() + runnerJobAvailability = workflow.ExpressionAvailability(workflow.ExpressionJobEnvironment) + runnerJobOutputAvailability = workflow.ExpressionAvailability(workflow.ExpressionJobOutput) + actionDefaultAvailability = workflow.ExpressionAvailability(workflow.ExpressionActionInputDefault) + runnerStepAvailability = workflow.ExpressionAvailability(workflow.ExpressionStep) + runnerConditionAvailability = workflow.ExpressionAvailability(workflow.ExpressionStepCondition) + compositeAvailability = workflow.ExpressionAvailability(workflow.ExpressionCompositeStep) + compositeConditionAvailability = workflow.ExpressionAvailability(workflow.ExpressionCompositeCondition) + compositeOutputAvailability = workflow.ExpressionAvailability(workflow.ExpressionCompositeOutput) ) func resolveJobEnvironment(values map[string]string, plan *Plan, environment []string, token string, secrets, variables map[string]string) (map[string]string, error) { - return resolveExpressionMap(values, expressionContext(plan, environment, "", nil, runnerJobAvailability, nil, token, secrets, variables)) + return resolveExpressionMap(values, expressionContext(plan, environment, nil, "", nil, runnerJobAvailability, nil, token, secrets, variables)) } -func resolveActionDefaultExpression(input string, plan *Plan, environment []string, token string) (string, error) { - context := expressionContext(plan, environment, "", nil, actionDefaultAvailability, nil, token, nil, nil) +func resolveActionDefaultExpression(input string, plan *Plan, environment []string, token, actionPath, actionRepository, actionRef string, status *workflowexpression.Status) (string, error) { + context := expressionContext(plan, environment, nil, actionPath, nil, actionDefaultAvailability, status, token, nil, nil) + github := context.Values["github"].(map[string]any) + github["action_repository"] = actionRepository + github["action_ref"] = actionRef return resolveExpressionString(input, context) } @@ -37,12 +43,12 @@ func validateActionDefaultExpression(input string) error { } func resolveWorkflowStepEnvironment(step Step, state *executionState) (map[string]string, error) { - context := workflowExpressionContext(state, state.environment, runnerStepAvailability, nil) + context := workflowExpressionContext(state, state.contextEnvironment, runnerStepAvailability, nil) return resolveExpressionMap(step.Env, context) } func resolveWorkflowStep(step Step, environment map[string]string, state *executionState) (Step, error) { - stepEnvironment := appendEnvironment(append([]string(nil), state.environment...), environment) + stepEnvironment := mergeEnvironment(state.contextEnvironment, environment) context := workflowExpressionContext(state, stepEnvironment, runnerStepAvailability, nil) resolved := step var err error @@ -104,8 +110,8 @@ func workflowStepCondition(input string, environment map[string]string, status w if strings.TrimSpace(input) == "" { return status.Success, nil } - context := workflowExpressionContext(state, state.environment, runnerConditionAvailability, &status) - environmentContext := workflowExpressionContext(state, state.environment, runnerStepAvailability, nil) + context := workflowExpressionContext(state, state.contextEnvironment, runnerConditionAvailability, &status) + environmentContext := workflowExpressionContext(state, state.contextEnvironment, runnerStepAvailability, nil) baseEnvironment := context.Values["env"].(map[string]any) resolveEnvironment := func(name string) (any, bool, error) { if input, found := stringMapValue(environment, name); found { @@ -133,61 +139,100 @@ func workflowStepCondition(input string, environment map[string]string, status w return evaluateCondition(input, context, status.Success) } -func workflowExpressionContext(state *executionState, environment []string, availability workflowexpression.Availability, status *workflowexpression.Status) workflowexpression.Context { +func workflowExpressionContext(state *executionState, environment map[string]string, availability workflowexpression.Availability, status *workflowexpression.Status) workflowexpression.Context { values := map[string]any{"steps": state.stepOutputs} - return expressionContext(state.plan, environment, "", values, availability, status, state.githubToken, state.secrets, state.variables) + if status == nil { + status = &state.jobStatus + } + return expressionContext(state.plan, state.environment, environment, "", values, availability, status, state.githubToken, state.secrets, state.variables) } func compositeExpressionContext(compositeContext *compositeContext, availability workflowexpression.Availability, status *workflowexpression.Status) workflowexpression.Context { - environment := appendEnvironment(append([]string(nil), compositeContext.state.environment...), compositeContext.scopedEnvironment) + environment := mergeEnvironment(compositeContext.state.contextEnvironment, compositeContext.scopedEnvironment) steps := make(map[string]any, len(compositeContext.stepOutput)) - for id, outputs := range compositeContext.stepOutput { - steps[id] = map[string]any{"outputs": outputs} + for id, result := range compositeContext.stepOutput { + steps[id] = result } values := map[string]any{ "inputs": compositeContext.inputs, "steps": steps, } - return expressionContext(compositeContext.state.plan, environment, compositeContext.actionPath, values, availability, status, compositeContext.state.githubToken, nil, nil) + if status == nil { + status = &compositeContext.state.jobStatus + } + context := expressionContext(compositeContext.state.plan, compositeContext.state.environment, environment, compositeContext.actionPath, values, availability, status, compositeContext.state.githubToken, nil, nil) + github := context.Values["github"].(map[string]any) + github["action_ref"] = compositeContext.actionRef + github["action_repository"] = compositeContext.actionRepository + github["action_status"] = compositeContext.actionStatus + return context +} + +func compositeOutputExpressionContext(compositeContext *compositeContext) workflowexpression.Context { + return compositeExpressionContext(compositeContext, compositeOutputAvailability, nil) } -func expressionContext(plan *Plan, environment []string, actionPath string, extra map[string]any, availability workflowexpression.Availability, status *workflowexpression.Status, token string, secrets, variables map[string]string) workflowexpression.Context { +func expressionContext(plan *Plan, environment []string, declaredEnvironment map[string]string, actionPath string, extra map[string]any, availability workflowexpression.Availability, status *workflowexpression.Status, token string, secrets, variables map[string]string) workflowexpression.Context { pullRequestRefs := planPullRequestRefs(plan) - github := map[string]any{ - "actor": plan.Run.Actor, - "workflow": plan.WorkflowName, - "event_name": plan.Event.Name, - "event": githubExpressionEvent(plan), - "repository": plan.Repository.Owner + "/" + plan.Repository.Name, - "sha": plan.Revision.SHA, - "ref": plan.Revision.Ref, - "ref_name": plan.Revision.RefName, - "head_ref": pullRequestRefs.head, - "base_ref": pullRequestRefs.base, - "workspace": environmentValue(environment, "GITHUB_WORKSPACE"), - "server_url": strings.TrimSuffix(plan.Repository.ServerURL, "/"), - "api_url": strings.TrimSuffix(plan.Repository.APIURL, "/"), - "run_id": strconv.FormatInt(plan.Run.ID, 10), - "run_number": strconv.FormatInt(plan.Run.Number, 10), - "run_attempt": strconv.FormatInt(int64(plan.Run.Attempt), 10), - "token": workflowexpression.Secret(token), - } - if actionPath != "" { - github["action_path"] = actionPath - } + repository := plan.Repository.Owner + "/" + plan.Repository.Name + event := githubExpressionEvent(plan) + github := workflowcontext.GitHub(workflowcontext.GitHubValues{ + Action: environmentValue(environment, "GITHUB_ACTION"), + ActionPath: actionPath, + ActionRef: environmentValue(environment, "GITHUB_ACTION_REF"), + ActionRepository: environmentValue(environment, "GITHUB_ACTION_REPOSITORY"), + ActionStatus: environmentValue(environment, "GITHUB_ACTION_STATUS"), + Actor: plan.Run.Actor, + ActorID: workflowcontext.EventID(event, "sender", "id"), + APIURL: plan.Repository.APIURL, + BaseRef: pullRequestRefs.base, + EnvironmentFile: environmentValue(environment, "GITHUB_ENV"), + Event: event, + EventName: plan.Event.Name, + EventPath: environmentValue(environment, "GITHUB_EVENT_PATH"), + HeadRef: pullRequestRefs.head, + JobID: plan.JobID, + PathFile: environmentValue(environment, "GITHUB_PATH"), + Ref: plan.Revision.Ref, + RefName: plan.Revision.RefName, + RepositoryID: plan.Repository.ID, + RepositoryName: plan.Repository.Name, + RepositoryOwner: plan.Repository.Owner, + RepositoryOwnerID: workflowcontext.EventID(event, "repository", "owner", "id"), + RepositoryURL: workflowcontext.EventString(event, "repository", "git_url"), + RetentionDays: environmentValue(environment, "GITHUB_RETENTION_DAYS"), + RunAttempt: plan.Run.Attempt, + RunID: plan.Run.ID, + RunNumber: plan.Run.Number, + ServerURL: plan.Repository.ServerURL, + SHA: plan.Revision.SHA, + Token: workflowexpression.Secret(token), + TriggeringActor: triggeringActor(plan.Run), + WorkflowName: plan.WorkflowName, + WorkflowPath: plan.WorkflowPath, + Workspace: environmentValue(environment, "GITHUB_WORKSPACE"), + }) values := map[string]any{ "github": github, "open_actions": map[string]any{ "run_url": plan.Run.URL, "run_query_url": plan.Run.QueryURL, }, - "inputs": plan.Inputs, - "matrix": plan.Matrix, - "needs": plan.Needs.ExpressionValues(), - "secrets": secretContext(token, secrets), - "vars": variables, - "runner": runnerExpressionValues(environment), - "env": environmentContext(environment), + "inputs": plan.Inputs, + "matrix": plan.Matrix, + "strategy": plan.Strategy, + "needs": plan.Needs.ExpressionValues(), + "secrets": secretContext(token, secrets), + "vars": variables, + "runner": runnerExpressionValues(environment), + "env": expressionEnvironment(declaredEnvironment), + "job": workflowcontext.Job( + expressionStatus(status), + workflowcontext.WorkflowRef(repository, plan.WorkflowPath, plan.Revision.Ref), + plan.Revision.SHA, + repository, + plan.WorkflowPath, + ), } for name, value := range extra { values[name] = value @@ -195,6 +240,31 @@ func expressionContext(plan *Plan, environment []string, actionPath string, extr return workflowexpression.Context{Availability: availability, Values: values, Status: status} } +func triggeringActor(run Run) string { + if run.TriggeringActor != "" { + return run.TriggeringActor + } + return run.Actor +} + +func expressionStatus(status *workflowexpression.Status) string { + if status == nil || status.Success || !status.Failure && !status.Cancelled { + return "success" + } + if status.Cancelled { + return "cancelled" + } + return "failure" +} + +func expressionEnvironment(environment map[string]string) map[string]any { + values := make(map[string]any, len(environment)) + for name, value := range environment { + values[name] = value + } + return values +} + func runnerExpressionValues(environment []string) map[string]any { values := map[string]any{ "name": environmentValue(environment, RunnerNameEnvVar), @@ -262,17 +332,6 @@ func githubExpressionEvent(plan *Plan) map[string]any { return result } -func environmentContext(environment []string) map[string]any { - result := make(map[string]any, len(environment)) - for _, entry := range environment { - name, value, found := strings.Cut(entry, "=") - if found { - result[name] = value - } - } - return result -} - func stringMapValue(values map[string]string, name string) (string, bool) { if value, found := values[name]; found { return value, true diff --git a/internal/runner/runner.go b/internal/runner/runner.go index fe472cf..2ee5a0b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -21,12 +21,13 @@ import ( "github.com/kelos-dev/open-actions/internal/eventsnapshot" "github.com/kelos-dev/open-actions/internal/expression" "github.com/kelos-dev/open-actions/internal/workflow" + "github.com/kelos-dev/open-actions/internal/workflowcontext" "github.com/kelos-dev/open-actions/internal/workflowenv" ) const ( minimumPlanVersion = 1 - PlanVersion = 8 + PlanVersion = 9 ContainerName = "runner" RunnerNameEnvVar = "RUNNER_NAME" GitHubTokenEnvVar = "OPEN_ACTIONS_GITHUB_TOKEN" @@ -48,8 +49,10 @@ type Plan struct { Revision Revision `json:"revision"` Inputs map[string]any `json:"inputs,omitempty"` WorkflowName string `json:"workflowName"` + WorkflowPath string `json:"workflowPath,omitempty"` JobID string `json:"jobID"` Matrix map[string]any `json:"matrix,omitempty"` + Strategy map[string]any `json:"strategy,omitempty"` Needs Needs `json:"-"` Env map[string]string `json:"env,omitempty"` Outputs map[string]string `json:"outputs,omitempty"` @@ -62,12 +65,13 @@ type Plan struct { } type Run struct { - ID int64 `json:"id"` - Number int64 `json:"number"` - Attempt int32 `json:"attempt"` - Actor string `json:"actor"` - URL string `json:"url,omitempty"` - QueryURL string `json:"queryURL,omitempty"` + ID int64 `json:"id"` + Number int64 `json:"number"` + Attempt int32 `json:"attempt"` + Actor string `json:"actor"` + TriggeringActor string `json:"triggeringActor,omitempty"` + URL string `json:"url,omitempty"` + QueryURL string `json:"queryURL,omitempty"` } type Repository struct { @@ -181,12 +185,14 @@ type executionState struct { workspace string temporaryDirectory string environment []string + contextEnvironment map[string]string githubToken string secrets map[string]string variables map[string]string resolver *actionResolver posts []*actionInvocation stepOutputs map[string]map[string]any + jobStatus expression.Status resolvedContent int contexts *executionContexts } @@ -320,6 +326,9 @@ func DecodePlan(data []byte) (*Plan, error) { if plan.Version >= 8 && !validGitHubTokenPermissions(plan.GitHubTokenPermissions) { return nil, errors.New("job plan GitHub token permissions are incomplete") } + if plan.Version >= 9 && (plan.WorkflowPath == "" || (plan.Matrix != nil) != (plan.Strategy != nil)) { + return nil, errors.New("job plan expression context is incomplete") + } return plan, nil } @@ -451,17 +460,24 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string "GITHUB_EVENT_ACTION="+plan.Event.Action, "GITHUB_EVENT_NAME="+plan.Event.Name, "GITHUB_EVENT_PATH="+eventPath, + "GITHUB_GRAPHQL_URL="+workflowcontext.GraphQLURL(plan.Repository.APIURL), "GITHUB_HEAD_REF="+planPullRequestRefs(plan).head, "GITHUB_JOB="+plan.JobID, "GITHUB_REF="+plan.Revision.Ref, "GITHUB_REF_NAME="+plan.Revision.RefName, + "GITHUB_REF_TYPE="+workflowcontext.RefType(plan.Revision.Ref), "GITHUB_REPOSITORY="+plan.Repository.Owner+"/"+plan.Repository.Name, + "GITHUB_REPOSITORY_ID="+strconv.FormatInt(plan.Repository.ID, 10), + "GITHUB_REPOSITORY_OWNER="+plan.Repository.Owner, "GITHUB_RUN_ATTEMPT="+strconv.FormatInt(int64(plan.Run.Attempt), 10), "GITHUB_RUN_ID="+strconv.FormatInt(plan.Run.ID, 10), "GITHUB_RUN_NUMBER="+strconv.FormatInt(plan.Run.Number, 10), "GITHUB_SERVER_URL="+strings.TrimSuffix(plan.Repository.ServerURL, "/"), "GITHUB_SHA="+plan.Revision.SHA, + "GITHUB_TRIGGERING_ACTOR="+triggeringActor(plan.Run), "GITHUB_WORKFLOW="+plan.WorkflowName, + "GITHUB_WORKFLOW_REF="+workflowcontext.WorkflowRef(plan.Repository.Owner+"/"+plan.Repository.Name, plan.WorkflowPath, plan.Revision.Ref), + "GITHUB_WORKFLOW_SHA="+plan.Revision.SHA, "GITHUB_WORKSPACE="+workspace, "OPEN_ACTIONS_RUN_QUERY_URL="+plan.Run.QueryURL, "OPEN_ACTIONS_RUN_URL="+plan.Run.URL, @@ -492,11 +508,13 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string workspace: workspace, temporaryDirectory: temporaryDirectory, environment: environment, + contextEnvironment: cloneStringMap(jobEnvironment), githubToken: e.githubToken, secrets: e.secrets, variables: e.variables, resolver: newActionResolver(plan.Repository.ActionCloneBaseURL, filepath.Join(temporaryDirectory, "actions"), environment, actionTokenForClone(plan, e.actionToken), e.executeCommand), stepOutputs: map[string]map[string]any{}, + jobStatus: workflowStepStatus(false, false), resolvedContent: jobContentBytes, contexts: contexts, } @@ -508,6 +526,7 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string var executionErrors error for index, rawStep := range plan.Steps { status := workflowStepStatus(failed, ctx.Err() != nil) + state.jobStatus = status runStep, err := workflowStepCondition(rawStep.If, rawStep.Env, status, state) if err != nil { executionErrors = errors.Join(executionErrors, fmt.Errorf("step %d condition: %w", index+1, err)) @@ -594,7 +613,11 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string executionErrors = errors.Join(executionErrors, ctx.Err()) } status := workflowStepStatus(failed, ctx.Err() != nil) + state.jobStatus = status postError := e.runPostActions(contexts.command(), state, status) + if postError != nil { + state.jobStatus = workflowStepStatus(true, ctx.Err() != nil) + } outputs, outputError := e.resolveJobOutputs(state) executionError := errors.Join(executionErrors, postError, outputError) result, resultError := newResult(outputs, executionConclusion(ctx, executionError), resultVersionForPlan(plan.Version)) @@ -774,7 +797,7 @@ func (e *Executor) runPostActions(ctx context.Context, state *executionState, st continue } e.logger.Info("starting post action", "action", invocation.step.Uses) - err := e.runJavaScriptHook(ctx, invocation, "post", invocation.definition.Runs.Post, state.temporaryDirectory, state.workspace, &state.environment) + err := e.runJavaScriptHook(ctx, invocation, "post", invocation.definition.Runs.Post, state) e.logger.Info("completed post action", "action", invocation.step.Uses) if err != nil { result = errors.Join(result, fmt.Errorf("post action %s: %w", invocation.step.Uses, err)) @@ -802,6 +825,7 @@ func (e *Executor) runScript(ctx context.Context, state *executionState, step St return nil, errors.Join(executionError, commandError) } applyEnvironmentUpdates(&state.environment, updates) + applyContextEnvironmentUpdates(&state.contextEnvironment, updates) e.logCommandNames("workflow step output", updates.outputs) return updates.outputs, executionError } @@ -822,7 +846,7 @@ func (e *Executor) resolveJobOutputs(state *executionState) (map[string]string, if len(state.plan.Outputs) == 0 { return nil, nil } - context := workflowExpressionContext(state, state.environment, runnerStepAvailability, nil) + context := workflowExpressionContext(state, state.contextEnvironment, runnerJobOutputAvailability, nil) outputs := make(map[string]string, len(state.plan.Outputs)) for name, input := range state.plan.Outputs { program, err := expression.Parse(input) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index fb97cfe..2bb1a2a 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -385,12 +385,17 @@ func TestExecuteResolvesProblemMatchersFromWorkspace(t *testing.T) { func TestExecuteEvaluatesWorkflowExpressions(t *testing.T) { workspace := t.TempDir() plan := testPlan() + plan.Run.TriggeringActor = "hubot" plan.Matrix = map[string]any{"arch": "arm64"} + plan.Strategy = map[string]any{"job-index": int32(1), "job-total": int32(2), "max-parallel": int32(1), "fail-fast": true} plan.Env = map[string]string{ "ARCH": "${{ matrix.arch }}", "BRANCH": "${{ github.ref_name }}", "JOB_TOKEN": "${{ github.token }}", + "REPOSITORY_OWNER": "${{ github.repository_owner }}", "REF_TYPE": "${{ github.ref_type }}", + "WORKFLOW_REF": "${{ github.workflow_ref }}", "RUN_ID_CONTEXT": "${{ github.run_id }}", "RUN_NUMBER_CONTEXT": "${{ github.run_number }}", "RUN_ATTEMPT_CONTEXT": "${{ github.run_attempt }}", "ACTOR_CONTEXT": "${{ github.actor }}", - "RUN_URL_CONTEXT": "${{ open_actions.run_url }}", "RUN_QUERY_URL_CONTEXT": "${{ open_actions.run_query_url }}", + "TRIGGERING_ACTOR_CONTEXT": "${{ github.triggering_actor }}", + "RUN_URL_CONTEXT": "${{ open_actions.run_url }}", "RUN_QUERY_URL_CONTEXT": "${{ open_actions.run_query_url }}", } plan.Outputs = map[string]string{"image": "${{ matrix.arch }}-${{ steps.build.outputs.image }}"} plan.Steps = []Step{ @@ -408,8 +413,10 @@ func TestExecuteEvaluatesWorkflowExpressions(t *testing.T) { "REPOSITORY": "${{ github.repository }}", "STEP_TOKEN": "${{ github.token }}", "TARGET": "main", + "JOB_STATUS": "${{ job.status }}", + "JOB_INDEX": "${{ strategy.job-index }}", }, - Run: "test \"$JOB_TOKEN\" = installation-token && test \"$STEP_TOKEN\" = installation-token && test \"$RUN_ID_CONTEXT\" = \"$GITHUB_RUN_ID\" && test \"$RUN_NUMBER_CONTEXT\" = \"$GITHUB_RUN_NUMBER\" && test \"$RUN_ATTEMPT_CONTEXT\" = \"$GITHUB_RUN_ATTEMPT\" && test \"$ACTOR_CONTEXT\" = \"$GITHUB_ACTOR\" && test \"$RUN_URL_CONTEXT\" = \"$OPEN_ACTIONS_RUN_URL\" && test \"$RUN_QUERY_URL_CONTEXT\" = \"$OPEN_ACTIONS_RUN_QUERY_URL\" && printf '%s/%s/${{ github.sha }}/${{ env.TARGET }}/${{ matrix.arch }}' \"$BRANCH\" \"$REPOSITORY\" > result && echo 'image=ready' >> \"$GITHUB_OUTPUT\"", + Run: "test \"$JOB_TOKEN\" = installation-token && test \"$STEP_TOKEN\" = installation-token && test \"$RUN_ID_CONTEXT\" = \"$GITHUB_RUN_ID\" && test \"$RUN_NUMBER_CONTEXT\" = \"$GITHUB_RUN_NUMBER\" && test \"$RUN_ATTEMPT_CONTEXT\" = \"$GITHUB_RUN_ATTEMPT\" && test \"$ACTOR_CONTEXT\" = \"$GITHUB_ACTOR\" && test \"$TRIGGERING_ACTOR_CONTEXT\" = \"$GITHUB_TRIGGERING_ACTOR\" && test \"$TRIGGERING_ACTOR_CONTEXT\" = hubot && test \"$RUN_URL_CONTEXT\" = \"$OPEN_ACTIONS_RUN_URL\" && test \"$RUN_QUERY_URL_CONTEXT\" = \"$OPEN_ACTIONS_RUN_QUERY_URL\" && test \"$REPOSITORY_OWNER\" = acme && test \"$REF_TYPE\" = branch && test \"$WORKFLOW_REF\" = 'acme/example/.github/workflows/ci.yml@refs/heads/main' && test \"$JOB_STATUS\" = success && test \"$JOB_INDEX\" = 1 && printf '%s/%s/${{ github.sha }}/${{ env.TARGET }}/${{ matrix.arch }}' \"$BRANCH\" \"$REPOSITORY\" > result && echo 'image=ready' >> \"$GITHUB_OUTPUT\"", }, } executor := testExecutor(t, io.Discard, io.Discard) @@ -432,6 +439,52 @@ func TestExecuteEvaluatesWorkflowExpressions(t *testing.T) { } } +func TestEnvContextContainsOnlyWorkflowDeclaredValues(t *testing.T) { + plan := testPlan() + plan.Env = map[string]string{"DECLARED": "workflow"} + plan.Steps = []Step{ + { + Env: map[string]string{ + "DECLARED_CONTEXT": "${{ env.DECLARED }}", + "INHERITED_CONTEXT": "${{ env.POD_SENTINEL }}", + "DEFAULT_CONTEXT": "${{ env.GITHUB_SHA }}", + }, + Run: "test \"$DECLARED_CONTEXT\" = workflow && test -z \"$INHERITED_CONTEXT\" && test -z \"$DEFAULT_CONTEXT\" && test \"$POD_SENTINEL\" = inherited && echo 'EXPORTED=command' >> \"$GITHUB_ENV\"", + }, + { + Env: map[string]string{"EXPORTED_CONTEXT": "${{ env.EXPORTED }}"}, + Run: "test \"$EXPORTED_CONTEXT\" = command", + }, + } + environment := setEnvironment(os.Environ(), "POD_SENTINEL", "inherited") + if err := testExecutorWithEnvironment(t, environment, io.Discard, io.Discard).Execute(context.Background(), plan, t.TempDir()); err != nil { + t.Fatal(err) + } +} + +func TestJobContextTracksCurrentStatus(t *testing.T) { + workspace := t.TempDir() + plan := testPlan() + plan.Steps = []Step{ + {Run: "exit 1"}, + { + If: "failure()", + Env: map[string]string{"STATUS": "${{ job.status }}"}, + Run: "printf '%s' \"$STATUS\" > status", + }, + } + if err := testExecutor(t, io.Discard, io.Discard).Execute(context.Background(), plan, workspace); err == nil { + t.Fatal("Execute() succeeded after a failed step") + } + data, err := os.ReadFile(filepath.Join(workspace, "status")) + if err != nil { + t.Fatal(err) + } + if string(data) != "failure" { + t.Fatalf("job.status = %q", data) + } +} + func TestExecuteResolvesAndMasksRepositoryValues(t *testing.T) { secret := "repository-secret-value" encodedSecret := base64.StdEncoding.EncodeToString([]byte(secret)) @@ -891,7 +944,11 @@ func TestLoadPlanSupportsCompatibleVersions(t *testing.T) { if version >= 8 { permissions = `,"githubTokenPermissions":{"contents":"read"}` } - data := fmt.Sprintf(`{"version":%d%s,"repository":{"id":1,"owner":"acme","name":"example","serverURL":"https://github.com","apiURL":"https://api.github.com","actionCloneBaseURL":"https://github.com"},"event":{"name":"push","deliveryID":"delivery"},"revision":{"sha":"abc","ref":"refs/heads/main","refName":"main"},"workflowName":"CI","jobID":"build"%s%s,"steps":[{"run":"true"}]}`, version, run, timeouts, permissions) + expressionContext := "" + if version >= 9 { + expressionContext = `,"workflowPath":".github/workflows/ci.yml"` + } + data := fmt.Sprintf(`{"version":%d%s,"repository":{"id":1,"owner":"acme","name":"example","serverURL":"https://github.com","apiURL":"https://api.github.com","actionCloneBaseURL":"https://github.com"},"event":{"name":"push","deliveryID":"delivery"},"revision":{"sha":"abc","ref":"refs/heads/main","refName":"main"},"workflowName":"CI"%s,"jobID":"build"%s%s,"steps":[{"run":"true"}]}`, version, run, expressionContext, timeouts, permissions) if err := os.WriteFile(path, []byte(data), 0o600); err != nil { t.Fatal(err) } @@ -984,7 +1041,7 @@ func TestExpressionContextsPreserveTriggerInputTypes(t *testing.T) { plan := testPlan() plan.Inputs = map[string]any{"enabled": false, "retries": float64(2)} plan.Event.Schedule = "0 6 * * *" - context := expressionContext(plan, nil, "", nil, runnerConditionAvailability, nil, "token", nil, nil) + context := expressionContext(plan, nil, nil, "", nil, runnerConditionAvailability, nil, "token", nil, nil) enabled, err := evaluateCondition("${{ inputs.enabled }}", context, true) if err != nil { t.Fatal(err) @@ -1048,7 +1105,7 @@ func TestLoadEventSnapshotUsesProviderPayload(t *testing.T) { pullRequest := event["pull_request"].(map[string]any) head := pullRequest["head"].(map[string]any) base := pullRequest["base"].(map[string]any) - if pullRequest["number"] != float64(42) || pullRequest["html_url"] != "https://github.com/acme/example/pull/42" || + if pullRequest["number"] != json.Number("42") || pullRequest["html_url"] != "https://github.com/acme/example/pull/42" || head["sha"] != strings.Repeat("2", 40) || base["sha"] != strings.Repeat("1", 40) || head["repo"].(map[string]any)["full_name"] != "acme/example" { t.Fatalf("github.event.pull_request = %#v", pullRequest) @@ -1057,7 +1114,7 @@ func TestLoadEventSnapshotUsesProviderPayload(t *testing.T) { t.Fatalf("loaded event snapshot = path %q, event %#v", plan.eventPath, event) } plan.Env = map[string]string{"EXPECTED_EVENT_PATH": absolutePath} - plan.Steps = []Step{{Run: `cmp "$GITHUB_EVENT_PATH" "$EXPECTED_EVENT_PATH" && test "${{ github.event.sender.login }}" = octocat`}} + plan.Steps = []Step{{Run: `cmp "$GITHUB_EVENT_PATH" "$EXPECTED_EVENT_PATH" && test "${{ github.event.sender.login }}" = octocat && test "${{ github.event.pull_request.number == 42 }}" = true`}} if err := testExecutor(t, io.Discard, io.Discard).Execute(context.Background(), plan, t.TempDir()); err != nil { t.Fatal(err) } @@ -1728,6 +1785,8 @@ func TestExecuteNestedCompositeAction(t *testing.T) { inputs: message: required: true + metadata: + default: ${{ github.action_repository }}@${{ github.action_ref }} runs: using: node20 main: index.js @@ -1737,6 +1796,7 @@ runs: if (process.env.STATE_nested === 'true') { console.log('nested post ran'); } else { + if (process.env.INPUT_METADATA !== 'actions/nested@v1') throw new Error('unexpected action metadata: ' + process.env.INPUT_METADATA); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'value=' + process.env['INPUT_MESSAGE'] + '\n'); fs.appendFileSync(process.env.GITHUB_STATE, 'nested=true\n'); } @@ -1747,6 +1807,8 @@ if (process.env.STATE_nested === 'true') { inputs: message: required: true + action-path: + default: ${{ github.action_path }} outputs: result: value: ${{ steps.nested.outputs.value }} @@ -1755,10 +1817,10 @@ runs: steps: - name: Skip unavailable fields if: false - run: echo "${{ secrets.TOKEN }}" + run: echo "${{ github.repository }}" shell: bash env: - TOKEN: ${{ secrets.TOKEN }} + REPOSITORY: ${{ github.repository }} - id: ignored-failure run: exit 1 shell: bash @@ -1777,6 +1839,14 @@ runs: test "$STEP_SCOPE" = "workflow action step env" test "$EXPECTED_STEP_SCOPE" = "workflow action step env" test "$GITHUB_TOKEN" = "installation-token" + test "$ACTION_PATH_DEFAULT" = "${{ github.action_path }}" + test "${{ github.action_ref }}" = v1 + test "${{ github.action_repository }}" = actions/composite + test "${{ github.action_status }}" = success + test "${{ steps.ignored-failure.outcome }}" = failure + test "${{ steps.ignored-failure.conclusion }}" = success + test "${{ job.status }}" = success + test "${{ strategy.job-index }}" = 0 test "$GITHUB_WORKSPACE" != "untrusted" test "$github_workspace" = "composite-lower" test "${{ env.LOCAL }}" = "composite-local" @@ -1784,6 +1854,7 @@ runs: shell: bash env: EXPECTED: ${{ inputs.message }} + ACTION_PATH_DEFAULT: ${{ inputs['action-path'] }} EXPECTED_OUTER: ${{ env.OUTER }} EXPECTED_STEP_SCOPE: ${{ env.STEP_SCOPE }} LOCAL: composite-local @@ -1811,6 +1882,8 @@ runs: }) plan := testPlan() plan.Repository.ActionCloneBaseURL = "file://" + repositories + plan.Matrix = map[string]any{"arch": "amd64"} + plan.Strategy = map[string]any{"job-index": int32(0), "job-total": int32(1), "max-parallel": int32(1), "fail-fast": true} plan.Env = map[string]string{"OUTER": "workflow action env"} plan.Steps = []Step{ {Uses: "actions/parent@v1", With: map[string]string{"message": "composite value"}, Env: map[string]string{"STEP_SCOPE": "workflow action step env"}}, @@ -1826,6 +1899,47 @@ runs: } } +func TestCompositeActionUsesCurrentJobStatusAfterFailure(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + repositories := t.TempDir() + createActionRepository(t, repositories, "actions", "job-status", "v1", map[string]string{ + "action.yml": `name: Job status fixture +inputs: + invocation-status: + default: ${{ job.status }} +outputs: + observed: + value: ${{ steps.capture.outputs.status }}-${{ job.status }} +runs: + using: composite + steps: + - id: capture + run: | + test "${{ inputs.invocation-status }}" = failure + test "${{ job.status }}" = failure + printf 'status=%s\n' "${{ job.status }}" >> "$GITHUB_OUTPUT" + shell: bash +`, + }) + plan := testPlan() + plan.Repository.ActionCloneBaseURL = "file://" + repositories + plan.Steps = []Step{ + {Run: "exit 1"}, + {ID: "cleanup", Uses: "actions/job-status@v1", If: "always()"}, + {If: "always()", Run: `test "${{ steps.cleanup.outputs.observed }}" = failure-failure && touch "$GITHUB_WORKSPACE/job-status-observed"`}, + } + workspace := t.TempDir() + err := testExecutor(t, io.Discard, io.Discard).Execute(context.Background(), plan, workspace) + if err == nil { + t.Fatal("failed workflow completed successfully") + } + if _, statErr := os.Stat(filepath.Join(workspace, "job-status-observed")); statErr != nil { + t.Fatalf("cleanup action did not observe the failed job status: %v", statErr) + } +} + func TestActionDownloadUsesActionTokenOnGitHubOrigin(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") @@ -1886,7 +2000,7 @@ func TestResolveCompositeStepKeepsUsesLiteral(t *testing.T) { state := &executionState{plan: testPlan(), environment: os.Environ()} context := &compositeContext{ inputs: map[string]string{"name": "resolved name", "version": "v2"}, - stepOutput: map[string]map[string]string{}, + stepOutput: map[string]map[string]any{}, state: state, } step, _, err := resolveCompositeStep(compositeStep{ @@ -1946,6 +2060,24 @@ runs: } } +func TestLoadActionDefinitionRejectsUnavailableCompositeContext(t *testing.T) { + directory := t.TempDir() + metadata := `name: Invalid composite fixture +runs: + using: composite + steps: + - run: echo "${{ secrets.TOKEN }}" + shell: bash +` + if err := os.WriteFile(filepath.Join(directory, "action.yml"), []byte(metadata), 0o600); err != nil { + t.Fatal(err) + } + _, err := loadActionDefinition(directory) + if err == nil || !strings.Contains(err.Error(), `context "secrets" is unavailable`) { + t.Fatalf("loadActionDefinition() error = %v", err) + } +} + func TestActionInputsResolveGitHubDefaults(t *testing.T) { plan := testPlan() environment := []string{"GITHUB_WORKSPACE=/workspace"} @@ -1990,6 +2122,47 @@ func TestActionInputsResolveRunnerDefaults(t *testing.T) { } } +func TestActionInputsResolveMatrixAndJobDefaults(t *testing.T) { + plan := testPlan() + plan.Matrix = map[string]any{"arch": "arm64"} + plan.Strategy = map[string]any{"job-index": int32(1), "job-total": int32(2), "max-parallel": int32(1), "fail-fast": true} + inputs, err := actionInputs(map[string]actionInput{ + "arch": {Default: "${{ matrix.arch }}"}, + "index": {Default: "${{ strategy.job-index }}"}, + "status": {Default: "${{ job.status }}"}, + }, nil, plan, []string{"GITHUB_WORKSPACE=/workspace"}, "installation-token") + if err != nil { + t.Fatal(err) + } + if inputs["arch"] != "arm64" || inputs["index"] != "1" || inputs["status"] != "success" { + t.Fatalf("inputs = %#v", inputs) + } +} + +func TestActionInputsResolveActionMetadataDefaults(t *testing.T) { + inputs, err := actionInputsWithContext( + map[string]actionInput{ + "path": {Default: "${{ github.action_path }}"}, + "repository": {Default: "${{ github.action_repository }}"}, + "ref": {Default: "${{ github.action_ref }}"}, + }, + nil, + testPlan(), + []string{"GITHUB_WORKSPACE=/workspace"}, + "installation-token", + "/actions/example", + "actions/example", + "v1", + nil, + ) + if err != nil { + t.Fatal(err) + } + if inputs["path"] != "/actions/example" || inputs["repository"] != "actions/example" || inputs["ref"] != "v1" { + t.Fatalf("inputs = %#v", inputs) + } +} + func TestActionInputsMergeCaseInsensitively(t *testing.T) { inputs, err := actionInputs( map[string]actionInput{"message": {Default: "default"}}, @@ -2779,6 +2952,7 @@ func testPlan() *Plan { Event: Event{Name: "push", DeliveryID: "delivery"}, Revision: Revision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main", RefName: "main"}, WorkflowName: "CI", + WorkflowPath: ".github/workflows/ci.yml", JobID: "test", GitHubTokenPermissions: map[string]string{"contents": "read"}, TimeoutSeconds: int64((6 * time.Hour) / time.Second), diff --git a/internal/webhook/delivery.go b/internal/webhook/delivery.go index 20e9e0a..02a78c0 100644 --- a/internal/webhook/delivery.go +++ b/internal/webhook/delivery.go @@ -685,7 +685,7 @@ func (r *DeliveryReconciler) reconcileRerun(ctx context.Context, object *corev1. if latest.Spec.Rerun != nil { attempt = latest.Spec.Rerun.Attempt + 1 } - if err := r.createRerunWorkflowRun(ctx, root, latest, attempt, delivery.DeliveryID, jobIDs); err != nil { + if err := r.createRerunWorkflowRun(ctx, root, latest, attempt, delivery.DeliveryID, delivery.Rerun.TriggeringActor, jobIDs); err != nil { if errors.Is(err, errRerunAttemptClaimed) { return ctrl.Result{RequeueAfter: 2 * time.Second}, nil } @@ -764,12 +764,13 @@ func (r *DeliveryReconciler) rerunWorkflowJobIDs(ctx context.Context, run *actio return workflowrun.FailedJobIDs(run, jobs.Items) } -func (r *DeliveryReconciler) createRerunWorkflowRun(ctx context.Context, root, previous *actionsv1alpha1.WorkflowRun, attempt int32, requestID string, jobIDs []string) error { +func (r *DeliveryReconciler) createRerunWorkflowRun(ctx context.Context, root, previous *actionsv1alpha1.WorkflowRun, attempt int32, requestID, triggeringActor string, jobIDs []string) error { snapshotName, err := r.rerunEventSnapshot(ctx, root) if err != nil { return err } desired := workflowrun.NewRerun(root, previous, attempt, requestID, jobIDs) + desired.Spec.Rerun.TriggeringActor = triggeringActor if snapshotName != "" { desired.Annotations = map[string]string{eventsnapshot.Annotation: snapshotName} } @@ -825,6 +826,7 @@ func matchingRerunRequest(existing, desired *actionsv1alpha1.WorkflowRun) bool { existingCopy := existing.DeepCopy() desiredCopy := desired.DeepCopy() existingCopy.Spec.Rerun.RequestID = desiredCopy.Spec.Rerun.RequestID + existingCopy.Spec.Rerun.TriggeringActor = desiredCopy.Spec.Rerun.TriggeringActor return matchingWorkflowRun(existingCopy, desiredCopy) == nil } diff --git a/internal/webhook/delivery_test.go b/internal/webhook/delivery_test.go index 12fc353..7ae8f39 100644 --- a/internal/webhook/delivery_test.go +++ b/internal/webhook/delivery_test.go @@ -291,7 +291,7 @@ func TestConcurrentRerunRequestsDoNotShareAnAttempt(t *testing.T) { clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(snapshot).Build() reconciler := &DeliveryReconciler{Client: clusterClient, APIReader: clusterClient} - if err := reconciler.createRerunWorkflowRun(context.Background(), root, root, 2, "delivery-a", []string{"unit"}); err != nil { + if err := reconciler.createRerunWorkflowRun(context.Background(), root, root, 2, "delivery-a", "octocat", []string{"unit"}); err != nil { t.Fatal(err) } retry := &actionsv1alpha1.WorkflowRun{} @@ -301,6 +301,9 @@ func TestConcurrentRerunRequestsDoNotShareAnAttempt(t *testing.T) { if retry.Spec.CancelRequested { t.Fatal("rerun retained the previous cancellation request") } + if retry.Spec.Rerun.TriggeringActor != "octocat" { + t.Fatalf("rerun triggering actor = %q", retry.Spec.Rerun.TriggeringActor) + } if retry.Annotations[eventsnapshot.Annotation] != snapshot.Name { t.Fatalf("rerun event snapshot = %q", retry.Annotations[eventsnapshot.Annotation]) } @@ -314,7 +317,7 @@ func TestConcurrentRerunRequestsDoNotShareAnAttempt(t *testing.T) { if !foundOwner { t.Fatalf("snapshot owners = %#v", snapshot.OwnerReferences) } - if err := reconciler.createRerunWorkflowRun(context.Background(), root, root, 2, "delivery-b", []string{"unit"}); !errors.Is(err, errRerunAttemptClaimed) { + if err := reconciler.createRerunWorkflowRun(context.Background(), root, root, 2, "delivery-b", "hubot", []string{"unit"}); !errors.Is(err, errRerunAttemptClaimed) { t.Fatalf("second rerun error = %v", err) } } diff --git a/internal/webhook/github.go b/internal/webhook/github.go index 6ffd5cc..c35dcaa 100644 --- a/internal/webhook/github.go +++ b/internal/webhook/github.go @@ -180,9 +180,10 @@ type normalizedEvent struct { } type normalizedRerun struct { - CheckRunID int64 `json:"checkRunID"` - RootRunUID string `json:"rootRunUID"` - HeadSHA string `json:"headSHA"` + CheckRunID int64 `json:"checkRunID"` + RootRunUID string `json:"rootRunUID"` + HeadSHA string `json:"headSHA"` + TriggeringActor string `json:"triggeringActor,omitempty"` } func (h *GitHubHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { @@ -301,7 +302,7 @@ func normalizeRerun(project *actionsv1alpha1.Project, event *payload) (*normaliz if event.CheckRun.ID < 1 || event.CheckRun.ExternalID == "" || len(validation.IsValidLabelValue(event.CheckRun.ExternalID)) > 0 || !validGitSHA(event.CheckRun.HeadSHA) { return nil, false, errors.New("GitHub check run event is incomplete") } - return &normalizedRerun{CheckRunID: event.CheckRun.ID, RootRunUID: event.CheckRun.ExternalID, HeadSHA: event.CheckRun.HeadSHA}, true, nil + return &normalizedRerun{CheckRunID: event.CheckRun.ID, RootRunUID: event.CheckRun.ExternalID, HeadSHA: event.CheckRun.HeadSHA, TriggeringActor: event.Sender.Login}, true, nil } func (h *GitHubHandler) projectForInstallation(ctx context.Context, installationID int64) (*actionsv1alpha1.Project, []byte, error) { diff --git a/internal/webhook/github_test.go b/internal/webhook/github_test.go index 0154805..a77fa05 100644 --- a/internal/webhook/github_test.go +++ b/internal/webhook/github_test.go @@ -314,9 +314,10 @@ func TestNormalizeRerunAcceptsOnlyProjectCheckRuns(t *testing.T) { event.CheckRun.App.ID = 17 event.CheckRun.ExternalID = "workflow-run-uid" event.CheckRun.HeadSHA = strings.Repeat("a", 40) + event.Sender.Login = "octocat" rerun, supported, err := normalizeRerun(project, event) - if err != nil || !supported || rerun.CheckRunID != 42 || rerun.RootRunUID != "workflow-run-uid" || rerun.HeadSHA != event.CheckRun.HeadSHA { + if err != nil || !supported || rerun.CheckRunID != 42 || rerun.RootRunUID != "workflow-run-uid" || rerun.HeadSHA != event.CheckRun.HeadSHA || rerun.TriggeringActor != "octocat" { t.Fatalf("normalized rerun = %#v, supported = %v, error = %v", rerun, supported, err) } event.Action = "completed" diff --git a/internal/workflow/expression_context.go b/internal/workflow/expression_context.go new file mode 100644 index 0000000..bc86822 --- /dev/null +++ b/internal/workflow/expression_context.go @@ -0,0 +1,58 @@ +package workflow + +import "github.com/kelos-dev/open-actions/internal/expression" + +// ExpressionSite identifies a workflow or action metadata field with a +// distinct GitHub Actions context contract. +type ExpressionSite uint8 + +const ( + ExpressionWorkflowConcurrency ExpressionSite = iota + ExpressionWorkflowEnvironment + ExpressionJobCondition + ExpressionJobStrategy + ExpressionJobConfiguration + ExpressionJobEnvironment + ExpressionJobOutput + ExpressionStep + ExpressionStepCondition + ExpressionActionInputDefault + ExpressionCompositeStep + ExpressionCompositeCondition + ExpressionCompositeOutput +) + +// ExpressionAvailability returns the contexts and special functions available +// at an expression site. open_actions is an Open Actions extension. +func ExpressionAvailability(site ExpressionSite) expression.Availability { + switch site { + case ExpressionWorkflowConcurrency: + return expression.NewAvailability("github", "inputs", "vars") + case ExpressionWorkflowEnvironment: + return expression.NewAvailability("github", "open_actions", "secrets", "inputs", "vars") + case ExpressionJobCondition: + return expression.NewAvailability("github", "open_actions", "needs", "vars", "inputs").WithStatusFunctions() + case ExpressionJobStrategy: + return expression.NewAvailability("github", "open_actions", "needs", "vars", "inputs") + case ExpressionJobConfiguration: + return expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "vars", "inputs") + case ExpressionJobEnvironment: + return expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "vars", "secrets", "inputs") + case ExpressionJobOutput: + return expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "secrets", "steps", "inputs") + case ExpressionStep: + return expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "secrets", "steps", "inputs").WithHashFiles() + case ExpressionStepCondition: + return expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "steps", "inputs").WithStatusFunctions().WithHashFiles() + case ExpressionActionInputDefault: + return expression.NewAvailability("github", "open_actions", "strategy", "matrix", "job", "runner").WithHashFiles() + case ExpressionCompositeStep: + return expression.NewAvailability("github", "open_actions", "strategy", "matrix", "job", "runner", "env", "inputs", "steps").WithHashFiles() + case ExpressionCompositeCondition: + return expression.NewAvailability("github", "open_actions", "strategy", "matrix", "job", "runner", "env", "inputs", "steps").WithStatusFunctions().WithHashFiles() + case ExpressionCompositeOutput: + return expression.NewAvailability("github", "open_actions", "strategy", "matrix", "job", "runner", "env", "inputs", "steps") + default: + return expression.NewAvailability() + } +} diff --git a/internal/workflow/expression_context_test.go b/internal/workflow/expression_context_test.go new file mode 100644 index 0000000..cc1b777 --- /dev/null +++ b/internal/workflow/expression_context_test.go @@ -0,0 +1,94 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/kelos-dev/open-actions/internal/expression" +) + +func TestExpressionAvailabilityMatchesGitHubContextTable(t *testing.T) { + tests := []struct { + name string + site ExpressionSite + contexts string + status bool + hashFiles bool + }{ + {name: "workflow concurrency", site: ExpressionWorkflowConcurrency, contexts: "github inputs vars"}, + {name: "workflow environment", site: ExpressionWorkflowEnvironment, contexts: "github open_actions secrets inputs vars"}, + {name: "job condition", site: ExpressionJobCondition, contexts: "github open_actions needs vars inputs", status: true}, + {name: "job strategy", site: ExpressionJobStrategy, contexts: "github open_actions needs vars inputs"}, + {name: "job configuration", site: ExpressionJobConfiguration, contexts: "github open_actions needs strategy matrix vars inputs"}, + {name: "job environment", site: ExpressionJobEnvironment, contexts: "github open_actions needs strategy matrix vars secrets inputs"}, + {name: "job output", site: ExpressionJobOutput, contexts: "github open_actions needs strategy matrix job runner env vars secrets steps inputs"}, + {name: "step", site: ExpressionStep, contexts: "github open_actions needs strategy matrix job runner env vars secrets steps inputs", hashFiles: true}, + {name: "step condition", site: ExpressionStepCondition, contexts: "github open_actions needs strategy matrix job runner env vars steps inputs", status: true, hashFiles: true}, + {name: "action input default", site: ExpressionActionInputDefault, contexts: "github open_actions strategy matrix job runner", hashFiles: true}, + {name: "composite step", site: ExpressionCompositeStep, contexts: "github open_actions strategy matrix job runner env inputs steps", hashFiles: true}, + {name: "composite condition", site: ExpressionCompositeCondition, contexts: "github open_actions strategy matrix job runner env inputs steps", status: true, hashFiles: true}, + {name: "composite output", site: ExpressionCompositeOutput, contexts: "github open_actions strategy matrix job runner env inputs steps"}, + } + allContexts := strings.Fields("github open_actions needs strategy matrix job runner env vars secrets steps inputs jobs") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + availability := ExpressionAvailability(test.site) + allowed := map[string]bool{} + for _, name := range strings.Fields(test.contexts) { + allowed[name] = true + } + for _, name := range allContexts { + program, err := expression.Parse("${{ " + name + ".value }}") + if err != nil { + t.Fatal(err) + } + err = program.Validate(availability) + if allowed[name] && err != nil { + t.Errorf("context %q is unavailable: %v", name, err) + } + if !allowed[name] && err == nil { + t.Errorf("context %q is available", name) + } + } + assertFunctionAvailability(t, availability, "success()", test.status) + assertFunctionAvailability(t, availability, "hashFiles('**')", test.hashFiles) + }) + } +} + +func assertFunctionAvailability(t *testing.T, availability expression.Availability, function string, allowed bool) { + t.Helper() + program, err := expression.Parse("${{ " + function + " }}") + if err != nil { + t.Fatal(err) + } + err = program.Validate(availability) + if allowed && err != nil { + t.Errorf("%s is unavailable: %v", function, err) + } + if !allowed && err == nil { + t.Errorf("%s is available", function) + } +} + +func TestJobPlanningUsesNeeds(t *testing.T) { + tests := []struct { + name string + job Job + want bool + }{ + {name: "name", job: Job{Name: "${{ needs.prepare.result }}"}, want: true}, + {name: "runner label", job: Job{RunsOn: StringList{"${{ needs.prepare.outputs.runner }}"}}, want: true}, + {name: "timeout", job: Job{TimeoutMinutes: JobTimeout{expression: "${{ needs.prepare.outputs.timeout }}"}}, want: true}, + {name: "matrix", job: Job{Strategy: Strategy{Matrix: MatrixDefinition{Expression: "${{ needs.prepare.outputs.matrix }}"}}}, want: true}, + {name: "job environment", job: Job{Env: map[string]any{"VALUE": "${{ needs.prepare.result }}"}}}, + {name: "job concurrency", job: Job{Concurrency: Concurrency{Group: "${{ needs.prepare.result }}"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := JobPlanningUsesNeeds(test.job); got != test.want { + t.Errorf("JobPlanningUsesNeeds() = %t, want %t", got, test.want) + } + }) + } +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index cb2a8fc..80ff54c 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -340,16 +340,16 @@ type Repository struct { } var ( - workflowConcurrencyAvailability = expression.NewAvailability("github", "inputs", "vars") - workflowEnvironmentAvailability = expression.NewAvailability("github", "open_actions", "secrets", "inputs", "vars") - jobNameAvailability = expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "vars", "inputs") - jobEnvironmentAvailability = expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "vars", "secrets", "inputs") - jobConditionAvailability = expression.NewAvailability("github", "open_actions", "needs", "vars", "inputs").WithStatusFunctions() - jobConcurrencyAvailability = expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "vars", "inputs") - matrixAvailability = expression.NewAvailability("github", "open_actions", "needs", "vars", "inputs") - stepAvailability = expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "secrets", "steps", "inputs").WithHashFiles() - stepConditionAvailability = expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "steps", "inputs").WithStatusFunctions().WithHashFiles() - jobOutputAvailability = expression.NewAvailability("github", "open_actions", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "secrets", "steps", "inputs") + workflowConcurrencyAvailability = ExpressionAvailability(ExpressionWorkflowConcurrency) + workflowEnvironmentAvailability = ExpressionAvailability(ExpressionWorkflowEnvironment) + jobNameAvailability = ExpressionAvailability(ExpressionJobConfiguration) + jobEnvironmentAvailability = ExpressionAvailability(ExpressionJobEnvironment) + jobConditionAvailability = ExpressionAvailability(ExpressionJobCondition) + jobConcurrencyAvailability = ExpressionAvailability(ExpressionJobConfiguration) + matrixAvailability = ExpressionAvailability(ExpressionJobStrategy) + stepAvailability = ExpressionAvailability(ExpressionStep) + stepConditionAvailability = ExpressionAvailability(ExpressionStepCondition) + jobOutputAvailability = ExpressionAvailability(ExpressionJobOutput) ) func Parse(data []byte) (*Definition, error) { @@ -484,8 +484,8 @@ func validateJob(id string, job *Job, workflowEnv map[string]any) error { if err := job.TimeoutMinutes.validate(id); err != nil { return err } - if MatrixUsesNeeds(job.Strategy) && len(job.Needs) == 0 { - return fmt.Errorf("job %q matrix uses needs but the job declares no dependencies", id) + if JobPlanningUsesNeeds(*job) && len(job.Needs) == 0 { + return fmt.Errorf("job %q planning expressions use needs but the job declares no dependencies", id) } if job.If != "" { if len(job.If) > MaxConditionBytes { @@ -831,6 +831,22 @@ func MatrixUsesNeeds(strategy Strategy) bool { return false } +// JobPlanningUsesNeeds reports whether fields resolved before a WorkflowJob is +// created read the needs context. +func JobPlanningUsesNeeds(job Job) bool { + if MatrixUsesNeeds(job.Strategy) { + return true + } + inputs := append([]string{job.Name, job.TimeoutMinutes.expression}, job.RunsOn...) + for _, input := range inputs { + program, err := expression.Parse(input) + if err == nil && program.UsesContext("needs") { + return true + } + } + return false +} + func matrixExpressions(matrix MatrixDefinition) []string { inputs := []string{matrix.Expression, matrix.Include.Expression, matrix.Exclude.Expression} for _, axis := range matrix.Axes { @@ -1355,6 +1371,7 @@ func scalarString(value any) (string, bool) { // EvaluateJob resolves fields needed before a WorkflowJob can be created. func EvaluateJob(id string, job Job, context expression.Context) (Job, error) { + context.Availability = jobNameAvailability name, err := expression.Parse(job.Name) if err != nil { return Job{}, fmt.Errorf("job %q name: %w", id, err) @@ -1462,16 +1479,8 @@ func EvaluateJobConcurrency(id string, concurrency Concurrency, context expressi } func EvaluateConcurrency(definition *Definition, event Event, variables any) (string, bool, error) { - if definition.Concurrency.Group == "" { - return "", false, nil - } - program, err := expression.Parse(definition.Concurrency.Group) - if err != nil { - return "", false, fmt.Errorf("parse concurrency group: %w", err) - } eventValues := eventExpressionValue(event) context := expression.Context{ - Availability: workflowConcurrencyAvailability, Values: map[string]any{ "inputs": event.InputValues, "vars": variables, @@ -1486,6 +1495,20 @@ func EvaluateConcurrency(definition *Definition, event Event, variables any) (st }, }, } + return EvaluateConcurrencyContext(definition, context) +} + +// EvaluateConcurrencyContext evaluates workflow concurrency with a complete +// planning context supplied by the caller. +func EvaluateConcurrencyContext(definition *Definition, context expression.Context) (string, bool, error) { + if definition.Concurrency.Group == "" { + return "", false, nil + } + program, err := expression.Parse(definition.Concurrency.Group) + if err != nil { + return "", false, fmt.Errorf("parse concurrency group: %w", err) + } + context.Availability = workflowConcurrencyAvailability result, err := program.Evaluate(context) if err != nil { return "", false, fmt.Errorf("evaluate concurrency group: %w", err) diff --git a/internal/workflowcontext/context.go b/internal/workflowcontext/context.go new file mode 100644 index 0000000..b56a250 --- /dev/null +++ b/internal/workflowcontext/context.go @@ -0,0 +1,257 @@ +package workflowcontext + +import ( + "encoding/json" + "net/url" + "strconv" + "strings" +) + +// GitHubValues contains the data used to construct the github context at a +// planning or execution phase. +type GitHubValues struct { + Action string + ActionPath string + ActionRef string + ActionRepository string + ActionStatus string + Actor string + ActorID string + APIURL string + BaseRef string + EnvironmentFile string + Event map[string]any + EventName string + EventPath string + HeadRef string + JobID string + PathFile string + Ref string + RefName string + RefProtected *bool + RepositoryID int64 + RepositoryName string + RepositoryOwner string + RepositoryOwnerID string + RepositoryURL string + RetentionDays string + RunAttempt int32 + RunID int64 + RunNumber int64 + SecretSource string + ServerURL string + SHA string + Token any + TriggeringActor string + WorkflowName string + WorkflowPath string + WorkflowSHA string + Workspace string +} + +// GitHub returns the documented github context properties whose values Open +// Actions can determine at the current phase. +func GitHub(input GitHubValues) map[string]any { + serverURL := strings.TrimSuffix(input.ServerURL, "/") + apiURL := strings.TrimSuffix(input.APIURL, "/") + repository := "" + if input.RepositoryOwner != "" && input.RepositoryName != "" { + repository = input.RepositoryOwner + "/" + input.RepositoryName + } + workflowSHA := input.WorkflowSHA + if workflowSHA == "" { + workflowSHA = input.SHA + } + triggeringActor := input.TriggeringActor + if triggeringActor == "" { + triggeringActor = input.Actor + } + secretSource := input.SecretSource + if secretSource == "" { + secretSource = "Actions" + if strings.EqualFold(input.Actor, "dependabot[bot]") { + secretSource = "Dependabot" + } + } + repositoryURL := input.RepositoryURL + if repositoryURL == "" { + repositoryURL = RepositoryURL(serverURL, repository) + } + values := map[string]any{ + "action": input.Action, + "action_ref": input.ActionRef, + "action_repository": input.ActionRepository, + "action_status": input.ActionStatus, + "actor": input.Actor, + "api_url": apiURL, + "base_ref": input.BaseRef, + "env": input.EnvironmentFile, + "event": input.Event, + "event_name": input.EventName, + "event_path": input.EventPath, + "graphql_url": GraphQLURL(apiURL), + "head_ref": input.HeadRef, + "job": input.JobID, + "path": input.PathFile, + "ref": input.Ref, + "ref_name": input.RefName, + "ref_type": RefType(input.Ref), + "repository": repository, + "repository_id": positiveNumber(input.RepositoryID), + "repository_owner": input.RepositoryOwner, + "repositoryUrl": repositoryURL, + "retention_days": input.RetentionDays, + "run_attempt": positiveNumber(int64(input.RunAttempt)), + "run_id": positiveNumber(input.RunID), + "run_number": positiveNumber(input.RunNumber), + "secret_source": secretSource, + "server_url": serverURL, + "sha": input.SHA, + "token": input.Token, + "triggering_actor": triggeringActor, + "workflow": input.WorkflowName, + "workflow_ref": WorkflowRef(repository, input.WorkflowPath, input.Ref), + "workflow_sha": workflowSHA, + "workspace": input.Workspace, + } + if input.ActionPath != "" { + values["action_path"] = input.ActionPath + } + if input.ActorID != "" { + values["actor_id"] = input.ActorID + } + if input.RepositoryOwnerID != "" { + values["repository_owner_id"] = input.RepositoryOwnerID + } + if input.RefProtected != nil { + values["ref_protected"] = *input.RefProtected + } + return values +} + +// EventID returns a numeric webhook property using GitHub's documented string +// representation for IDs. +func EventID(event map[string]any, path ...string) string { + value, found := eventValue(event, path...) + if !found { + return "" + } + switch typed := value.(type) { + case json.Number: + if parsed, err := typed.Int64(); err == nil { + return positiveNumber(parsed) + } + case float64: + if typed > 0 && typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + case int64: + return positiveNumber(typed) + case int: + return positiveNumber(int64(typed)) + case string: + if parsed, err := strconv.ParseInt(typed, 10, 64); err == nil { + return positiveNumber(parsed) + } + } + return "" +} + +// EventString returns a string webhook property. +func EventString(event map[string]any, path ...string) string { + value, found := eventValue(event, path...) + if !found { + return "" + } + result, _ := value.(string) + return result +} + +func eventValue(event map[string]any, path ...string) (any, bool) { + var current any = event + for _, name := range path { + values, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = values[name] + if !ok { + return nil, false + } + } + return current, true +} + +func positiveNumber(value int64) string { + if value < 1 { + return "" + } + return strconv.FormatInt(value, 10) +} + +// GraphQLURL derives GitHub's GraphQL endpoint from the REST API endpoint. +func GraphQLURL(apiURL string) string { + apiURL = strings.TrimSuffix(apiURL, "/") + if apiURL == "" { + return "" + } + if strings.HasSuffix(apiURL, "/api/v3") { + return strings.TrimSuffix(apiURL, "/api/v3") + "/api/graphql" + } + return apiURL + "/graphql" +} + +// RefType returns the GitHub Actions ref_type value for a workflow revision. +func RefType(ref string) string { + if ref == "" { + return "" + } + if strings.HasPrefix(ref, "refs/tags/") { + return "tag" + } + return "branch" +} + +// RepositoryURL returns the clone URL exposed as github.repositoryUrl. +func RepositoryURL(serverURL, repository string) string { + if serverURL == "" || repository == "" { + return "" + } + server, err := url.Parse(serverURL) + if err != nil || server.Host == "" { + return "" + } + return "git://" + server.Host + "/" + repository + ".git" +} + +// WorkflowRef returns the fully qualified workflow file reference. +func WorkflowRef(repository, path, ref string) string { + if repository == "" || path == "" || ref == "" { + return "" + } + return repository + "/" + path + "@" + ref +} + +// Job returns the job context for a directly defined workflow job. +func Job(status, workflowRef, workflowSHA, workflowRepository, workflowPath string) map[string]any { + return map[string]any{ + "status": status, + "workflow_ref": workflowRef, + "workflow_sha": workflowSHA, + "workflow_repository": workflowRepository, + "workflow_file_path": workflowPath, + } +} + +// Strategy returns the strategy context for one expanded matrix job. +func Strategy(jobIndex, jobTotal, maxParallel int32, failFast bool) map[string]any { + if maxParallel == 0 { + maxParallel = jobTotal + } + return map[string]any{ + "job-index": jobIndex, + "job-total": jobTotal, + "max-parallel": maxParallel, + "fail-fast": failFast, + } +} diff --git a/internal/workflowcontext/context_test.go b/internal/workflowcontext/context_test.go new file mode 100644 index 0000000..e489209 --- /dev/null +++ b/internal/workflowcontext/context_test.go @@ -0,0 +1,138 @@ +package workflowcontext + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestGitHubContextIncludesDocumentedProperties(t *testing.T) { + protected := true + values := GitHub(GitHubValues{ + Action: "__run", + ActionPath: "/actions/example", + ActionRef: "v1", + ActionRepository: "actions/example", + ActionStatus: "success", + Actor: "octocat", + ActorID: "1", + APIURL: "https://github.example/api/v3/", + BaseRef: "main", + EnvironmentFile: "/commands/env", + Event: map[string]any{"action": "opened"}, + EventName: "pull_request", + EventPath: "/events/payload.json", + HeadRef: "feature", + JobID: "build", + PathFile: "/commands/path", + Ref: "refs/pull/42/merge", + RefName: "42/merge", + RefProtected: &protected, + RepositoryID: 2, + RepositoryName: "example", + RepositoryOwner: "acme", + RepositoryOwnerID: "3", + RetentionDays: "30", + RunAttempt: 2, + RunID: 101, + RunNumber: 7, + ServerURL: "https://github.example/", + SHA: strings.Repeat("a", 40), + Token: "token", + TriggeringActor: "hubot", + WorkflowName: "CI", + WorkflowPath: ".github/workflows/ci.yml", + Workspace: "/workspace", + }) + properties := []string{ + "action", "action_path", "action_ref", "action_repository", "action_status", + "actor", "actor_id", "api_url", "base_ref", "env", "event", "event_name", + "event_path", "graphql_url", "head_ref", "job", "path", "ref", "ref_name", + "ref_protected", "ref_type", "repository", "repository_id", "repository_owner", + "repository_owner_id", "repositoryUrl", "retention_days", "run_id", "run_number", + "run_attempt", "secret_source", "server_url", "sha", "token", "triggering_actor", + "workflow", "workflow_ref", "workflow_sha", "workspace", + } + for _, property := range properties { + if _, found := values[property]; !found { + t.Errorf("github.%s is absent", property) + } + } + if len(values) != len(properties) { + t.Errorf("github context has %d properties, want %d: %#v", len(values), len(properties), values) + } + for _, property := range properties { + if property == "event" || property == "ref_protected" { + continue + } + if _, ok := values[property].(string); !ok { + t.Errorf("github.%s type = %T, want string", property, values[property]) + } + } + if values["repository_owner"] != "acme" || values["repository_id"] != "2" || + values["ref_type"] != "branch" || values["graphql_url"] != "https://github.example/api/graphql" || + values["repositoryUrl"] != "git://github.example/acme/example.git" || + values["workflow_ref"] != "acme/example/.github/workflows/ci.yml@refs/pull/42/merge" || + values["workflow_sha"] != strings.Repeat("a", 40) { + t.Fatalf("github context = %#v", values) + } + if _, ok := values["ref_protected"].(bool); !ok { + t.Errorf("github.ref_protected type = %T", values["ref_protected"]) + } + + available := GitHub(GitHubValues{}) + for _, property := range []string{"api_url", "graphql_url", "ref", "ref_type", "repository", "repositoryUrl", "workflow_ref"} { + if available[property] != "" { + t.Errorf("github.%s = %#v without a source value, want empty string", property, available[property]) + } + } + for _, property := range []string{"action_path", "actor_id", "repository_owner_id", "ref_protected"} { + if _, found := available[property]; found { + t.Errorf("github.%s is present without a source value", property) + } + } +} + +func TestStrategyContextPreservesDocumentedTypes(t *testing.T) { + values := Strategy(1, 4, 0, false) + want := map[string]any{ + "job-index": int32(1), "job-total": int32(4), "max-parallel": int32(4), "fail-fast": false, + } + if !reflect.DeepEqual(values, want) { + t.Fatalf("strategy = %#v, want %#v", values, want) + } +} + +func TestJobContextIncludesAvailableMetadata(t *testing.T) { + values := Job("failure", "acme/example/.github/workflows/ci.yml@refs/heads/main", "sha", "acme/example", ".github/workflows/ci.yml") + if len(values) != 5 { + t.Fatalf("job context has %d properties: %#v", len(values), values) + } + if values["status"] != "failure" || + values["workflow_repository"] != "acme/example" || values["workflow_file_path"] != ".github/workflows/ci.yml" { + t.Fatalf("job context = %#v", values) + } +} + +func TestRefType(t *testing.T) { + if RefType("refs/tags/v1") != "tag" || RefType("refs/heads/main") != "branch" || RefType("refs/pull/42/merge") != "branch" { + t.Fatal("ref type does not match GitHub Actions") + } +} + +func TestRepositoryURLUsesGitProtocol(t *testing.T) { + if got := RepositoryURL("https://github.example/", "acme/example"); got != "git://github.example/acme/example.git" { + t.Fatalf("RepositoryURL() = %q", got) + } + if got := RepositoryURL("github.example", "acme/example"); got != "" { + t.Fatalf("RepositoryURL() with invalid server URL = %q, want empty string", got) + } +} + +func TestEventIDPreservesDecodedNumbers(t *testing.T) { + event := map[string]any{"sender": map[string]any{"id": json.Number("9007199254740993")}} + if got := EventID(event, "sender", "id"); got != "9007199254740993" { + t.Fatalf("EventID() = %q", got) + } +} diff --git a/internal/workflowenv/environment.go b/internal/workflowenv/environment.go index b3f69ed..753498a 100644 --- a/internal/workflowenv/environment.go +++ b/internal/workflowenv/environment.go @@ -7,23 +7,35 @@ var runnerOwnedNames = map[string]struct{}{ "GITHUB_ACTION_REPOSITORY": {}, "GITHUB_ACTIONS": {}, "GITHUB_API_URL": {}, + "GITHUB_ACTOR": {}, "GITHUB_BASE_REF": {}, "GITHUB_ENV": {}, "GITHUB_EVENT_ACTION": {}, "GITHUB_EVENT_NAME": {}, "GITHUB_EVENT_PATH": {}, + "GITHUB_GRAPHQL_URL": {}, "GITHUB_HEAD_REF": {}, "GITHUB_JOB": {}, "GITHUB_OUTPUT": {}, "GITHUB_PATH": {}, "GITHUB_REF": {}, "GITHUB_REF_NAME": {}, + "GITHUB_REF_TYPE": {}, "GITHUB_REPOSITORY": {}, + "GITHUB_REPOSITORY_ID": {}, + "GITHUB_REPOSITORY_OWNER": {}, + "GITHUB_RETENTION_DAYS": {}, + "GITHUB_RUN_ATTEMPT": {}, + "GITHUB_RUN_ID": {}, + "GITHUB_RUN_NUMBER": {}, "GITHUB_SERVER_URL": {}, "GITHUB_SHA": {}, "GITHUB_STATE": {}, "GITHUB_STEP_SUMMARY": {}, + "GITHUB_TRIGGERING_ACTOR": {}, "GITHUB_WORKFLOW": {}, + "GITHUB_WORKFLOW_REF": {}, + "GITHUB_WORKFLOW_SHA": {}, "GITHUB_WORKSPACE": {}, "RUNNER_ARCH": {}, "RUNNER_DEBUG": {}, diff --git a/internal/workflowenv/environment_test.go b/internal/workflowenv/environment_test.go index 993717d..e6aad2b 100644 --- a/internal/workflowenv/environment_test.go +++ b/internal/workflowenv/environment_test.go @@ -8,23 +8,35 @@ func TestIsRunnerOwned(t *testing.T) { "GITHUB_ACTION_REPOSITORY", "GITHUB_ACTIONS", "GITHUB_API_URL", + "GITHUB_ACTOR", "GITHUB_BASE_REF", "GITHUB_ENV", "GITHUB_EVENT_ACTION", "GITHUB_EVENT_NAME", "GITHUB_EVENT_PATH", + "GITHUB_GRAPHQL_URL", "GITHUB_HEAD_REF", "GITHUB_JOB", "GITHUB_OUTPUT", "GITHUB_PATH", "GITHUB_REF", "GITHUB_REF_NAME", + "GITHUB_REF_TYPE", "GITHUB_REPOSITORY", + "GITHUB_REPOSITORY_ID", + "GITHUB_REPOSITORY_OWNER", + "GITHUB_RETENTION_DAYS", + "GITHUB_RUN_ATTEMPT", + "GITHUB_RUN_ID", + "GITHUB_RUN_NUMBER", "GITHUB_SERVER_URL", "GITHUB_SHA", "GITHUB_STATE", "GITHUB_STEP_SUMMARY", + "GITHUB_TRIGGERING_ACTOR", "GITHUB_WORKFLOW", + "GITHUB_WORKFLOW_REF", + "GITHUB_WORKFLOW_SHA", "GITHUB_WORKSPACE", "RUNNER_ARCH", "RUNNER_DEBUG",