diff --git a/README.md b/README.md
index 2c74d48..526a3f6 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,8 @@ Kubernetes 1.29 or newer is required.
Open Actions supports event, manual, and scheduled triggers; independent jobs
selected by runner labels; Bash steps; step and declared job outputs; Node 20,
Node 24, and composite actions; expressions and concurrency; optional
-job-scoped Docker; GitHub Check Runs; and live logs. See the
+job-scoped Docker; approval-gated environments and environment secrets; GitHub
+Check Runs; and live logs. See the
[Workflow API](docs/reference.md#workflow-api) for the exact supported syntax
and execution constraints. Unsupported workflows fail explicitly rather than
run with different semantics.
diff --git a/api/v1alpha1/labels.go b/api/v1alpha1/labels.go
index 33aa48f..023bb79 100644
--- a/api/v1alpha1/labels.go
+++ b/api/v1alpha1/labels.go
@@ -12,4 +12,5 @@ const (
AnnotationRunnerName = "actions.kelos.dev/runner-name"
AnnotationRunnerResultVersion = "actions.kelos.dev/runner-result-version"
AnnotationProjectName = "actions.kelos.dev/project-name"
+ AnnotationEnvironmentApproved = "actions.kelos.dev/environment-approved"
)
diff --git a/api/v1alpha1/project_types.go b/api/v1alpha1/project_types.go
index 46e84c2..040a437 100644
--- a/api/v1alpha1/project_types.go
+++ b/api/v1alpha1/project_types.go
@@ -13,6 +13,7 @@ const SourceTypeGitHub SourceType = "GitHub"
type SourceType string
// ProjectSpec describes the workflow source for an Open Actions Project.
+// +kubebuilder:validation:XValidation:rule="!has(self.environments) || self.environments.all(e, self.environments.exists_one(other, e.name.lowerAscii() == other.name.lowerAscii()))",message="environment names must be unique ignoring ASCII case"
type ProjectSpec struct {
// Source selects and configures the external workflow source.
// +required
@@ -27,6 +28,53 @@ type ProjectSpec struct {
// +kubebuilder:validation:XValidation:rule="self != '..' && !self.startsWith('../') && !self.contains('/../') && !self.endsWith('/..')",message="must not contain '..' path segments"
// +optional
WorkflowDirectory string `json:"workflowDirectory,omitempty"`
+
+ // Environments defines the environment names workflows may select. Each
+ // environment may expose one Secret and require approval before its jobs can
+ // be assigned to a Runner.
+ // +listType=map
+ // +listMapKey=name
+ // +kubebuilder:validation:MaxItems=100
+ // +optional
+ Environments []ProjectEnvironment `json:"environments,omitempty"`
+}
+
+// ProjectEnvironment configures one workflow environment. Environment names
+// are matched without regard to ASCII case.
+type ProjectEnvironment struct {
+ // Name is the GitHub-compatible environment name selected by a workflow job.
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=255
+ // +kubebuilder:validation:Pattern=`^[^\x00-\x1f\x7f]+$`
+ // +required
+ Name string `json:"name"`
+
+ // SecretRef identifies a Secret in the Project namespace whose data keys
+ // populate the secrets expression context for jobs in this environment.
+ // +optional
+ SecretRef *EnvironmentSecretReference `json:"secretRef,omitempty"`
+
+ // Protection configures the gate enforced before a job can be assigned.
+ // +optional
+ Protection *EnvironmentProtection `json:"protection,omitempty"`
+}
+
+// EnvironmentSecretReference identifies a Secret in the same namespace.
+type EnvironmentSecretReference struct {
+ // Name is the Secret resource name.
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=253
+ // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?([.][a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$`
+ // +required
+ Name string `json:"name"`
+}
+
+// EnvironmentProtection configures the Open Actions environment gate.
+type EnvironmentProtection struct {
+ // RequiredApproval requires an authorized user to approve each WorkflowJob
+ // before a Runner can claim it.
+ // +optional
+ RequiredApproval bool `json:"requiredApproval,omitempty"`
}
// ProjectSource is a discriminated union of supported workflow sources.
diff --git a/api/v1alpha1/workflowjob_types.go b/api/v1alpha1/workflowjob_types.go
index 4b52315..90a5470 100644
--- a/api/v1alpha1/workflowjob_types.go
+++ b/api/v1alpha1/workflowjob_types.go
@@ -6,8 +6,9 @@ import (
)
const (
- WorkflowJobConditionScheduled = "Scheduled"
- WorkflowJobConditionSucceeded = "Succeeded"
+ WorkflowJobConditionEnvironmentApproved = "EnvironmentApproved"
+ WorkflowJobConditionScheduled = "Scheduled"
+ WorkflowJobConditionSucceeded = "Succeeded"
)
// WorkflowJobSpec describes one immutable job expanded from a WorkflowRun.
@@ -52,6 +53,37 @@ type WorkflowJobSpec struct {
// Matrix describes the matrix combination represented by this job.
// +optional
Matrix *WorkflowJobMatrix `json:"matrix,omitempty"`
+
+ // Environment contains the selected workflow environment and the Project
+ // policy resolved for this job.
+ // +optional
+ Environment *WorkflowJobEnvironment `json:"environment,omitempty"`
+}
+
+// WorkflowJobEnvironment contains the environment configuration frozen when a
+// WorkflowJob is planned.
+type WorkflowJobEnvironment struct {
+ // Name is the configured Project environment name.
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=255
+ // +kubebuilder:validation:Pattern=`^[^\x00-\x1f\x7f]+$`
+ // +required
+ Name string `json:"name"`
+
+ // URL is the optional deployment target URL resolved from the workflow.
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=2048
+ // +optional
+ URL string `json:"url,omitempty"`
+
+ // SecretRef identifies the Project Secret whose data keys are available to
+ // this job through the secrets expression context.
+ // +optional
+ SecretRef *EnvironmentSecretReference `json:"secretRef,omitempty"`
+
+ // Protection is the Project environment gate resolved for this job.
+ // +optional
+ Protection *EnvironmentProtection `json:"protection,omitempty"`
}
// WorkflowJobMatrix identifies one expanded combination of a logical workflow
@@ -110,9 +142,10 @@ type WorkflowJobStatus struct {
// +optional
Outputs map[string]string `json:"outputs,omitempty"`
- // Conditions describe Runner assignment and the terminal result. Scheduled
- // is true after the scheduler assigns status.runnerRef. Known condition types
- // are Scheduled and Succeeded.
+ // Conditions describe environment approval, Runner assignment, and the
+ // terminal result. EnvironmentApproved is present for jobs that select an
+ // environment. Scheduled is true after the scheduler assigns status.runnerRef.
+ // Known condition types are EnvironmentApproved, Scheduled, and Succeeded.
// +listType=map
// +listMapKey=type
// +kubebuilder:validation:MaxItems=16
@@ -126,6 +159,8 @@ type WorkflowJobStatus struct {
// +kubebuilder:printcolumn:name="Job",type=string,JSONPath=`.spec.displayName`
// +kubebuilder:printcolumn:name="WorkflowRun",type=string,JSONPath=`.spec.workflowRunRef.name`
// +kubebuilder:printcolumn:name="Runner",type=string,JSONPath=`.status.runnerRef.name`
+// +kubebuilder:printcolumn:name="Environment",type=string,JSONPath=`.spec.environment.name`
+// +kubebuilder:printcolumn:name="Approved",type=string,JSONPath=`.status.conditions[?(@.type=="EnvironmentApproved")].status`
// +kubebuilder:printcolumn:name="Scheduled",type=string,JSONPath=`.status.conditions[?(@.type=="Scheduled")].status`
// +kubebuilder:printcolumn:name="Succeeded",type=string,JSONPath=`.status.conditions[?(@.type=="Succeeded")].status`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
diff --git a/api/v1alpha1/workflowrun_types.go b/api/v1alpha1/workflowrun_types.go
index e1b8c7c..c53f940 100644
--- a/api/v1alpha1/workflowrun_types.go
+++ b/api/v1alpha1/workflowrun_types.go
@@ -361,6 +361,13 @@ type WorkflowRunJobStatus struct {
// +optional
Total int32 `json:"total,omitempty"`
+ // WaitingForApproval is the number of jobs blocked by an environment
+ // approval gate and therefore not yet queued for a Runner.
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=100000
+ // +optional
+ WaitingForApproval int32 `json:"waitingForApproval,omitempty"`
+
// Queued is the number of jobs waiting for a matching Runner.
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=100000
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 575fed2..52b8aba 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -10,6 +10,36 @@ import (
"k8s.io/apimachinery/pkg/runtime"
)
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *EnvironmentProtection) DeepCopyInto(out *EnvironmentProtection) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentProtection.
+func (in *EnvironmentProtection) DeepCopy() *EnvironmentProtection {
+ if in == nil {
+ return nil
+ }
+ out := new(EnvironmentProtection)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *EnvironmentSecretReference) DeepCopyInto(out *EnvironmentSecretReference) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentSecretReference.
+func (in *EnvironmentSecretReference) DeepCopy() *EnvironmentSecretReference {
+ if in == nil {
+ return nil
+ }
+ out := new(EnvironmentSecretReference)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GitHubAppConfiguration) DeepCopyInto(out *GitHubAppConfiguration) {
*out = *in
@@ -260,6 +290,31 @@ func (in *Project) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProjectEnvironment) DeepCopyInto(out *ProjectEnvironment) {
+ *out = *in
+ if in.SecretRef != nil {
+ in, out := &in.SecretRef, &out.SecretRef
+ *out = new(EnvironmentSecretReference)
+ **out = **in
+ }
+ if in.Protection != nil {
+ in, out := &in.Protection, &out.Protection
+ *out = new(EnvironmentProtection)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectEnvironment.
+func (in *ProjectEnvironment) DeepCopy() *ProjectEnvironment {
+ if in == nil {
+ return nil
+ }
+ out := new(ProjectEnvironment)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProjectList) DeepCopyInto(out *ProjectList) {
*out = *in
@@ -316,6 +371,13 @@ func (in *ProjectSource) DeepCopy() *ProjectSource {
func (in *ProjectSpec) DeepCopyInto(out *ProjectSpec) {
*out = *in
in.Source.DeepCopyInto(&out.Source)
+ if in.Environments != nil {
+ in, out := &in.Environments, &out.Environments
+ *out = make([]ProjectEnvironment, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectSpec.
@@ -580,6 +642,31 @@ func (in *WorkflowJob) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WorkflowJobEnvironment) DeepCopyInto(out *WorkflowJobEnvironment) {
+ *out = *in
+ if in.SecretRef != nil {
+ in, out := &in.SecretRef, &out.SecretRef
+ *out = new(EnvironmentSecretReference)
+ **out = **in
+ }
+ if in.Protection != nil {
+ in, out := &in.Protection, &out.Protection
+ *out = new(EnvironmentProtection)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowJobEnvironment.
+func (in *WorkflowJobEnvironment) DeepCopy() *WorkflowJobEnvironment {
+ if in == nil {
+ return nil
+ }
+ out := new(WorkflowJobEnvironment)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WorkflowJobList) DeepCopyInto(out *WorkflowJobList) {
*out = *in
@@ -648,6 +735,11 @@ func (in *WorkflowJobSpec) DeepCopyInto(out *WorkflowJobSpec) {
*out = new(WorkflowJobMatrix)
(*in).DeepCopyInto(*out)
}
+ if in.Environment != nil {
+ in, out := &in.Environment, &out.Environment
+ *out = new(WorkflowJobEnvironment)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowJobSpec.
diff --git a/cmd/open-actions-runner/main.go b/cmd/open-actions-runner/main.go
index a4c3732..13f6b5d 100644
--- a/cmd/open-actions-runner/main.go
+++ b/cmd/open-actions-runner/main.go
@@ -26,6 +26,7 @@ func main() {
func run(ctx context.Context, arguments []string) error {
flags := flag.NewFlagSet("open-actions-runner", flag.ContinueOnError)
jobFile := flags.String("job-file", "/var/run/open-actions/job.json", "Path to the workflow job plan")
+ secretsFile := flags.String("secrets-file", "/var/run/open-actions-credentials/secrets.json", "Path to the environment-scoped workflow job secrets")
resultFile := flags.String("result-file", "/dev/termination-log", "Path used to report the workflow job result")
workspace := flags.String("workspace", "/workspace", "Path to the job workspace")
if err := flags.Parse(arguments); err != nil {
@@ -35,10 +36,19 @@ func run(ctx context.Context, arguments []string) error {
if err != nil {
return err
}
+ secrets, err := runner.LoadSecrets(*secretsFile)
+ if err != nil {
+ if plan.Version < runner.PlanVersion && errors.Is(err, os.ErrNotExist) {
+ secrets = map[string]string{}
+ } else {
+ return err
+ }
+ }
githubToken := os.Getenv("OPEN_ACTIONS_GITHUB_TOKEN")
executor, err := runner.NewExecutor(runner.ExecutorConfig{
Logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
GitHubToken: githubToken,
+ Secrets: secrets,
Environment: withoutEnvironmentVariable(os.Environ(), "OPEN_ACTIONS_GITHUB_TOKEN"),
Stdout: os.Stdout,
Stderr: os.Stderr,
diff --git a/cmd/open-actions-runner/main_test.go b/cmd/open-actions-runner/main_test.go
index 152c3ad..b757c18 100644
--- a/cmd/open-actions-runner/main_test.go
+++ b/cmd/open-actions-runner/main_test.go
@@ -37,8 +37,12 @@ func TestRunWritesWorkflowJobResult(t *testing.T) {
t.Fatal(err)
}
resultPath := filepath.Join(directory, "result.json")
+ secretsPath := filepath.Join(directory, "secrets.json")
+ if err := os.WriteFile(secretsPath, []byte(`{}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
t.Setenv("OPEN_ACTIONS_GITHUB_TOKEN", "installation-token")
- if err := run(context.Background(), []string{"--job-file=" + planPath, "--result-file=" + resultPath, "--workspace=" + filepath.Join(directory, "workspace")}); err != nil {
+ if err := run(context.Background(), []string{"--job-file=" + planPath, "--secrets-file=" + secretsPath, "--result-file=" + resultPath, "--workspace=" + filepath.Join(directory, "workspace")}); err != nil {
t.Fatal(err)
}
resultData, err := os.ReadFile(resultPath)
@@ -67,3 +71,33 @@ func TestWithoutEnvironmentVariable(t *testing.T) {
t.Fatalf("filtered environment = %#v", environment)
}
}
+
+func TestRunCompatiblePlanWithoutSecretsFile(t *testing.T) {
+ directory := t.TempDir()
+ plan := runner.Plan{
+ Version: runner.PlanVersion - 1,
+ Repository: runner.Repository{
+ ID: 1, Owner: "acme", Name: "example", ServerURL: "https://github.com", APIURL: "https://api.github.com", ActionCloneBaseURL: "https://github.com",
+ },
+ Event: runner.Event{Name: "push", DeliveryID: "delivery"}, Revision: runner.Revision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main", RefName: "main"},
+ WorkflowName: "CI", JobID: "build", Steps: []runner.Step{{Run: "true"}},
+ }
+ planData, err := json.Marshal(plan)
+ if err != nil {
+ t.Fatal(err)
+ }
+ planPath := filepath.Join(directory, "plan.json")
+ if err := os.WriteFile(planPath, planData, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("OPEN_ACTIONS_GITHUB_TOKEN", "installation-token")
+ err = run(context.Background(), []string{
+ "--job-file=" + planPath,
+ "--secrets-file=" + filepath.Join(directory, "missing.json"),
+ "--result-file=" + filepath.Join(directory, "result.json"),
+ "--workspace=" + filepath.Join(directory, "workspace"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/cmd/open-actions/run_command.go b/cmd/open-actions/run_command.go
index dbce0cc..982f6a5 100644
--- a/cmd/open-actions/run_command.go
+++ b/cmd/open-actions/run_command.go
@@ -376,7 +376,7 @@ func writeWorkflowRun(writer io.Writer, run *actionsv1alpha1.WorkflowRun, jobs [
fmt.Fprintf(table, "Started:\t%s\n", optionalTime(run.Status.StartTime))
fmt.Fprintf(table, "Completed:\t%s\n", optionalTime(run.Status.CompletionTime))
fmt.Fprintln(table)
- fmt.Fprintln(table, "JOB\tRESOURCE\tDISPLAY NAME\tSTATUS\tRUNNER\tSTARTED\tCOMPLETED")
+ fmt.Fprintln(table, "JOB\tRESOURCE\tDISPLAY NAME\tENVIRONMENT\tSTATUS\tRUNNER\tSTARTED\tCOMPLETED")
for index := range jobs {
job := &jobs[index]
displayName := job.Spec.DisplayName
@@ -384,13 +384,18 @@ func writeWorkflowRun(writer io.Writer, run *actionsv1alpha1.WorkflowRun, jobs [
displayName = "-"
}
runnerName := "-"
+ environmentName := "-"
+ if job.Spec.Environment != nil {
+ environmentName = job.Spec.Environment.Name
+ }
if job.Status.RunnerRef != nil {
runnerName = job.Status.RunnerRef.Name
}
- fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
job.Spec.JobID,
job.Name,
tableCell(displayName),
+ tableCell(environmentName),
workflowstatus.Job(job),
runnerName,
optionalTime(job.Status.StartTime),
diff --git a/config/samples/actions_v1alpha1_project-environment.yaml b/config/samples/actions_v1alpha1_project-environment.yaml
new file mode 100644
index 0000000..65ce05b
--- /dev/null
+++ b/config/samples/actions_v1alpha1_project-environment.yaml
@@ -0,0 +1,24 @@
+apiVersion: actions.kelos.dev/v1alpha1
+kind: Project
+metadata:
+ name: environment-example
+ namespace: open-actions
+spec:
+ source:
+ type: GitHub
+ github:
+ appID: 12345
+ installationID: 67890
+ privateKeySecretRef:
+ name: open-actions-github-app
+ key: private-key.pem
+ webhookSecretRef:
+ name: open-actions-github-app
+ key: webhook-secret
+ workflowDirectory: .open-actions/workflows
+ environments:
+ - name: ok-to-test
+ secretRef:
+ name: open-actions-e2e-secrets
+ protection:
+ requiredApproval: true
diff --git a/config/samples/actions_v1alpha1_workflowjob.yaml b/config/samples/actions_v1alpha1_workflowjob.yaml
index 4b679ca..c9a723a 100644
--- a/config/samples/actions_v1alpha1_workflowjob.yaml
+++ b/config/samples/actions_v1alpha1_workflowjob.yaml
@@ -11,3 +11,9 @@ spec:
displayName: Build and test
runsOn:
- ubuntu-latest
+ environment:
+ name: ok-to-test
+ secretRef:
+ name: open-actions-e2e-secrets
+ protection:
+ requiredApproval: true
diff --git a/docs/reference.md b/docs/reference.md
index 598ffac..72146ed 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -116,6 +116,71 @@ Runner labels are canonical lowercase ASCII in Kubernetes resources. Workflow
execution slot and accepts one queued `WorkflowJob` from its `spec.projectRef`
whose `runs-on` labels are all present in `spec.labels`.
+### Environments and environment secrets
+
+`Project.spec.environments` is the allow-list of environments that workflows
+may select. Names contain 1 to 255 characters, are matched without regard to
+ASCII case, and must be unique under that comparison. A workflow job may use
+either GitHub form:
+
+```yaml
+environment: staging
+```
+
+```yaml
+environment:
+ name: ${{ inputs.environment-name }}
+ url: https://deploy.example/${{ matrix.arch }}
+```
+
+The name and optional URL accept the planning contexts `github`, `inputs`, and
+`matrix`. The evaluated name must match a configured Project environment;
+otherwise planning fails. The controller writes the configured spelling,
+optional URL, Secret reference, and protection policy to the immutable
+`WorkflowJob.spec.environment`. This prevents an expression or untrusted
+workflow input from selecting an unconfigured secret source or bypassing the
+configured policy.
+
+An environment's optional `secretRef` selects one Kubernetes Secret in the
+Project namespace. Its data keys populate the `secrets` expression context only
+for jobs assigned to that environment. Keys follow GitHub secret naming rules,
+are unique ignoring ASCII case, may not start with `GITHUB_`, and are limited to
+100 entries and 8,192 UTF-8 bytes per value. `secrets.GITHUB_TOKEN` remains the
+job's repository-scoped installation token. The controller copies environment
+values into a job-owned Secret only after the approval gate and Runner
+assignment, mounts it only in that job's runner container, masks the values in
+runner output, and deletes the copy when the job is finalized. Environment
+secrets are not stored in the ConfigMap job plan or `WorkflowJob.status`.
+
+Open Actions does not import or evaluate GitHub Environment protection rules.
+Its Kubernetes-native equivalent is
+`Project.spec.environments[].protection.requiredApproval`. When enabled, every
+expanded WorkflowJob remains in `Waiting for approval` and is excluded from the
+Runner queue. An operator with Kubernetes permission to update WorkflowJobs
+approves that exact immutable job by setting this annotation to its environment
+name:
+
+```console
+kubectl annotate workflowjob WORKFLOW_JOB \
+ --namespace PROJECT_NAMESPACE \
+ actions.kelos.dev/environment-approved=ok-to-test
+```
+
+Kubernetes authorization and audit logging define who may perform this action;
+do not grant untrusted workflow identities permission to update WorkflowJobs.
+Approval applies only to that WorkflowJob, so a new delivery, revision, or
+matrix child requires its own approval. Removing the annotation before Runner
+assignment revokes the gate. After assignment, delete the WorkflowJob or its
+WorkflowRun to cancel it.
+
+The `EnvironmentApproved` condition records `ApprovalRequired`,
+`EnvironmentApproved`, or `ApprovalNotRequired`. The WorkflowRun job summary
+counts `waitingForApproval` separately from `queued`, and the Console and GitHub
+Check show the selected environments and approval state.
+See
+[`config/samples/actions_v1alpha1_project-environment.yaml`](../config/samples/actions_v1alpha1_project-environment.yaml)
+for a complete Project example.
+
### Docker execution
`spec.execution.docker` enables a job-scoped Docker daemon. Its required
@@ -164,7 +229,7 @@ The resources expose these condition contracts:
| Resource | Condition | Status | Reasons |
| --- | --- | --- | --- |
| `Project` | `Configured` | `True` | `ConfigurationValid` |
-| `Project` | `Configured` | `False` | `DuplicateInstallation`, `CredentialsUnavailable`, `InvalidCredentials` |
+| `Project` | `Configured` | `False` | `DuplicateInstallation`, `CredentialsUnavailable`, `InvalidCredentials`, `EnvironmentInvalid`, `EnvironmentSecretsUnavailable` |
| `Runner` | `Ready` | `True` | `Ready` |
| `Runner` | `Ready` | `False` | `ProjectUnavailable`, `ProjectNotConfigured` |
| `Runner` | `Busy` | `False` | `Idle` |
@@ -172,11 +237,13 @@ The resources expose these condition contracts:
| `WorkflowRun` | `Planned` | `True` | `JobsPlanned` |
| `WorkflowRun` | `Planned` | `Unknown` | `WaitingForConcurrency`, `WaitingForConcurrencyCancellation`, `ProjectUnavailable`, `CredentialsUnavailable`, `GitHubAuthenticationFailed`, `WorkflowFetchFailed`, `ChildCreationFailed`, `ConcurrencyCheckFailed` |
| `WorkflowRun` | `Planned` | `False` | `ProjectUnavailable`, `WorkflowFetchFailed`, `WorkflowInvalid`, `TriggerInvalid`, `ChildCreationFailed`, `ExecutionStateLost` |
-| `WorkflowRun` | `Succeeded` | `Unknown` | `JobsQueued`, `JobsRunning` |
+| `WorkflowRun` | `Succeeded` | `Unknown` | `JobsWaitingForApproval`, `JobsQueued`, `JobsRunning` |
| `WorkflowRun` | `Succeeded` | `True` | `JobsSucceeded` |
| `WorkflowRun` | `Succeeded` | `False` | `ProjectUnavailable`, `WorkflowFetchFailed`, `WorkflowInvalid`, `TriggerInvalid`, `ChildCreationFailed`, `JobFailed`, `ExecutionStateLost` |
| `WorkflowJob` | `Scheduled` | `True` | `RunnerAssigned` |
| `WorkflowJob` | `Scheduled` | `False` | `ProjectRecreated` |
+| `WorkflowJob` | `EnvironmentApproved` | `True` | `EnvironmentApproved`, `ApprovalNotRequired` |
+| `WorkflowJob` | `EnvironmentApproved` | `False` | `ApprovalRequired` |
| `WorkflowJob` | `Succeeded` | `Unknown` | `JobRunning` |
| `WorkflowJob` | `Succeeded` | `True` | `JobSucceeded` |
| `WorkflowJob` | `Succeeded` | `False` | `JobFailed`, `JobResultInvalid`, `PlanUnavailable`, `JobStartFailed`, `ExecutionStateLost`, `ProjectRecreated` |
@@ -205,6 +272,9 @@ distinguished from the original object. Workflow jobs that declare outputs,
and their native Jobs and Pods, carry the
`actions.kelos.dev/runner-result-version` annotation. Its value identifies the
runner result format required to complete that job.
+The `actions.kelos.dev/environment-approved` annotation is the operator-owned
+approval input for protected environments; workflow definitions cannot set
+resource metadata on their controller-owned WorkflowJobs.
Webhook-created WorkflowRuns use the workflow filename followed by a stable
20-character digest of the project, delivery replay, and workflow path.
@@ -247,8 +317,9 @@ evaluation when the corresponding execution feature has not supplied it.
| --- | --- | --- |
| Workflow concurrency | `github`, `inputs`, `vars` | `github`, `inputs` |
| Job name and runner labels | `github`, `needs`, `strategy`, `matrix`, `vars`, `inputs` | `github`, `inputs`, and `matrix` for matrix jobs |
-| Job environment | `github`, `needs`, `strategy`, `matrix`, `vars`, `secrets`, `inputs` | `github`, `inputs`, and `matrix` for matrix jobs |
-| Workflow step name, run script, working directory, environment, and inputs | `github`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs` | `github`, `matrix`, `runner`, `env`, `inputs`, `steps` |
+| Environment name and URL | `github`, `needs`, `strategy`, `matrix`, `vars`, `inputs` | `github`, `inputs`, and `matrix` for matrix jobs |
+| Job `env` values | `github`, `needs`, `strategy`, `matrix`, `vars`, `secrets`, `inputs` | `github`, `inputs`, `secrets`, and `matrix` for matrix jobs |
+| Workflow step name, run script, working directory, environment, and inputs | `github`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs` | `github`, `matrix`, `runner`, `env`, `inputs`, `secrets`, and `steps` |
| Workflow step condition | Step contexts except `secrets`, plus status functions | `github`, `matrix`, `runner`, `env`, `inputs`, `steps`, and status functions |
| Job outputs | Workflow step contexts | `github`, `matrix`, `runner`, `env`, `inputs`, `steps` |
| Composite step fields and outputs | `github`, `runner`, `env`, `inputs`, `steps` | All listed contexts |
@@ -256,7 +327,9 @@ evaluation when the corresponding execution feature has not supplied it.
| Action input default | `github` | `github` |
Dependency scheduling and its `needs` context, and repository secret and
-variable sources remain separate execution features. Values derived
+variable sources remain separate execution features. The `secrets` context
+contains the selected environment's Kubernetes Secret plus the automatic
+`GITHUB_TOKEN`; it does not merge repository or organization secrets. Values derived
from `github.token` or the `secrets` context are marked sensitive through
interpolation and function calls, and evaluation diagnostics do not include
resolved values. The runner maps interrupt and termination signals to cancelled
@@ -461,8 +534,8 @@ reusable inputs, the selected cron expression, and revision fields used by the
supported event. Actions that require other fields from GitHub's raw webhook
payload are not supported.
-The controller emits job-plan version 4, and the runner accepts versions 1
-through 4. When a release changes the job-plan version, update every Runner
+The controller emits job-plan version 5, and the runner accepts versions 1
+through 5. 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. A runner that accepts more
diff --git a/internal/apischema/crd_test.go b/internal/apischema/crd_test.go
index 6002ef5..9b8c6db 100644
--- a/internal/apischema/crd_test.go
+++ b/internal/apischema/crd_test.go
@@ -546,6 +546,13 @@ func TestCRDConventions(t *testing.T) {
t.Error("spec.execution.docker.image is not required")
}
}
+ if tt.kind == "Project" {
+ validateSample(t, crd, "actions_v1alpha1_project-environment.yaml")
+ environments := spec.Properties["environments"]
+ if environments.MaxItems == nil || *environments.MaxItems != 100 || environments.XListType == nil || *environments.XListType != "map" || !slices.Contains(environments.XListMapKeys, "name") {
+ t.Errorf("spec.environments schema = %#v", environments)
+ }
+ }
if tt.kind == "WorkflowJob" {
outputs := status.Properties["outputs"]
if outputs.MaxProperties == nil || *outputs.MaxProperties != 100 {
@@ -554,6 +561,10 @@ func TestCRDConventions(t *testing.T) {
if len(outputs.XValidations) != 2 {
t.Errorf("status.outputs validation rules = %d, want 2", len(outputs.XValidations))
}
+ environment := spec.Properties["environment"]
+ if !slices.Contains(environment.Required, "name") {
+ t.Error("spec.environment.name is not required")
+ }
}
if tt.kind == "WorkflowRun" {
ttl := spec.Properties["ttlSecondsAfterFinished"]
@@ -563,6 +574,10 @@ func TestCRDConventions(t *testing.T) {
if ttl.Format != "int32" || ttl.Minimum == nil || *ttl.Minimum != 0 || ttl.Maximum == nil || *ttl.Maximum != 2147483647 {
t.Errorf("spec.ttlSecondsAfterFinished schema = %#v", ttl)
}
+ waiting := status.Properties["jobs"].Properties["waitingForApproval"]
+ if waiting.Minimum == nil || *waiting.Minimum != 0 || waiting.Maximum == nil || *waiting.Maximum != 100000 {
+ t.Errorf("status.jobs.waitingForApproval schema = %#v", waiting)
+ }
}
})
}
@@ -597,6 +612,30 @@ func TestCRDRejectsInvalidCELValues(t *testing.T) {
object["spec"].(map[string]any)["workflowDirectory"] = ".open-actions/workflows/"
},
},
+ {
+ name: "Project environment secret reference with empty DNS label",
+ crd: "actions.kelos.dev_projects.yaml", sample: "actions_v1alpha1_project-environment.yaml",
+ mutate: func(object map[string]any) {
+ environments := object["spec"].(map[string]any)["environments"].([]any)
+ environments[0].(map[string]any)["secretRef"].(map[string]any)["name"] = "invalid..name"
+ },
+ },
+ {
+ name: "Project environment name with control character",
+ crd: "actions.kelos.dev_projects.yaml", sample: "actions_v1alpha1_project-environment.yaml",
+ mutate: func(object map[string]any) {
+ environments := object["spec"].(map[string]any)["environments"].([]any)
+ environments[0].(map[string]any)["name"] = "invalid\nenvironment"
+ },
+ },
+ {
+ name: "Project environment names duplicated with different case",
+ crd: "actions.kelos.dev_projects.yaml", sample: "actions_v1alpha1_project-environment.yaml",
+ mutate: func(object map[string]any) {
+ environments := object["spec"].(map[string]any)["environments"].([]any)
+ object["spec"].(map[string]any)["environments"] = append(environments, map[string]any{"name": "OK-TO-TEST"})
+ },
+ },
{
name: "Runner project reference with long DNS label",
crd: "actions.kelos.dev_runners.yaml", sample: "actions_v1alpha1_runner.yaml",
@@ -744,6 +783,13 @@ func TestCRDRejectsInvalidCELValues(t *testing.T) {
object["status"] = map[string]any{"outputs": map[string]any{"invalid.name": "value"}}
},
},
+ {
+ name: "WorkflowJob environment secret reference with empty DNS label",
+ crd: "actions.kelos.dev_workflowjobs.yaml", sample: "actions_v1alpha1_workflowjob.yaml",
+ mutate: func(object map[string]any) {
+ object["spec"].(map[string]any)["environment"].(map[string]any)["secretRef"].(map[string]any)["name"] = "invalid..name"
+ },
+ },
{
name: "WorkflowJob output exceeding value bound",
crd: "actions.kelos.dev_workflowjobs.yaml", sample: "actions_v1alpha1_workflowjob.yaml",
diff --git a/internal/console/handler.go b/internal/console/handler.go
index a1fe93f..b3dd699 100644
--- a/internal/console/handler.go
+++ b/internal/console/handler.go
@@ -105,6 +105,7 @@ type runPageData struct {
type jobPageData struct {
ID string
DisplayName string
+ Environment string
Runner string
Status string
StatusClass string
@@ -121,6 +122,7 @@ type logPageData struct {
Status string
JobName string
Runner string
+ Environment string
Duration string
RunURL string
StreamURL string
@@ -372,6 +374,9 @@ func (h *Handler) loadRunPageData(ctx context.Context, run *actionsv1alpha1.Work
for index := range jobs.Items {
job := &jobs.Items[index]
item := jobPageData{ID: job.Spec.JobID, DisplayName: job.Spec.DisplayName, Status: workflowstatus.Job(job), URL: runPath(run) + "/jobs/" + url.PathEscape(job.Name)}
+ if job.Spec.Environment != nil {
+ item.Environment = job.Spec.Environment.Name
+ }
if item.DisplayName == "" {
item.DisplayName = item.ID
}
@@ -409,13 +414,20 @@ func (h *Handler) jobLogs(writer http.ResponseWriter, request *http.Request, run
runData.Jobs[index].Selected = runData.Jobs[index].ID == job.Spec.JobID
}
runnerName := "Waiting for a runner"
+ environmentName := ""
+ if job.Spec.Environment != nil {
+ environmentName = job.Spec.Environment.Name
+ if jobStatus == "Waiting for approval" {
+ runnerName = "Environment approval required"
+ }
+ }
if job.Status.RunnerRef != nil {
runnerName = job.Status.RunnerRef.Name
}
h.writeHTML(writer, h.logPage, logPageData{
Repository: runData.Repository, WorkflowName: runData.WorkflowName,
ShortRevision: runData.ShortRevision, Status: jobStatus,
- JobName: displayName, Runner: runnerName, Duration: elapsedTime(job.Status.StartTime, job.Status.CompletionTime),
+ JobName: displayName, Runner: runnerName, Environment: environmentName, Duration: elapsedTime(job.Status.StartTime, job.Status.CompletionTime),
RunURL: path, StreamURL: path + "/jobs/" + url.PathEscape(job.Name) + "/stream", Jobs: runData.Jobs,
})
}
@@ -586,7 +598,7 @@ func (h *Handler) waitForPod(ctx context.Context, job *actionsv1alpha1.WorkflowJ
}
func statusClass(status string) string {
- return strings.ToLower(status)
+ return strings.ReplaceAll(strings.ToLower(status), " ", "-")
}
func shortRevision(revision string) string {
diff --git a/internal/console/handler_test.go b/internal/console/handler_test.go
index e1307af..4e2cbce 100644
--- a/internal/console/handler_test.go
+++ b/internal/console/handler_test.go
@@ -13,6 +13,7 @@ import (
actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
@@ -72,7 +73,7 @@ func TestConsoleAuthenticatesWithStaticTokenAndStreamsLogs(t *testing.T) {
runRequest.AddCookie(sessionCookie)
runResponse := httptest.NewRecorder()
handler.ServeHTTP(runResponse, runRequest)
- if runResponse.Code != http.StatusOK || !strings.Contains(runResponse.Body.String(), "CI") || !strings.Contains(runResponse.Body.String(), "build") || !strings.Contains(runResponse.Body.String(), "Workflow run Queued") {
+ if runResponse.Code != http.StatusOK || !strings.Contains(runResponse.Body.String(), "CI") || !strings.Contains(runResponse.Body.String(), "build") || !strings.Contains(runResponse.Body.String(), "Workflow run Queued") || !strings.Contains(runResponse.Body.String(), "ok-to-test") || !strings.Contains(runResponse.Body.String(), "Waiting for approval") {
t.Fatalf("run page = %d, %q", runResponse.Code, runResponse.Body.String())
}
@@ -302,8 +303,12 @@ func newTestHandler(t *testing.T, secureCookie bool) *Handler {
job := &actionsv1alpha1.WorkflowJob{
TypeMeta: metav1.TypeMeta{APIVersion: actionsv1alpha1.GroupVersion.String(), Kind: "WorkflowJob"},
ObjectMeta: metav1.ObjectMeta{Name: "build", Namespace: "default", UID: "job-uid", Labels: map[string]string{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}},
- Spec: actionsv1alpha1.WorkflowJobSpec{WorkflowRunRef: corev1.LocalObjectReference{Name: run.Name}, JobID: "build"},
+ Spec: actionsv1alpha1.WorkflowJobSpec{
+ WorkflowRunRef: corev1.LocalObjectReference{Name: run.Name}, JobID: "build",
+ Environment: &actionsv1alpha1.WorkflowJobEnvironment{Name: "ok-to-test"},
+ },
}
+ meta.SetStatusCondition(&job.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionFalse, Reason: "ApprovalRequired"})
if err := controllerutil.SetControllerReference(run, job, scheme); err != nil {
t.Fatal(err)
}
diff --git a/internal/console/templates.go b/internal/console/templates.go
index e55d7ab..b1e95bb 100644
--- a/internal/console/templates.go
+++ b/internal/console/templates.go
@@ -69,7 +69,7 @@ const runPageTemplate = `
{{.WorkflowName}} · Open Actions
@@ -90,7 +90,7 @@ const runPageTemplate = `
{{range .Jobs}}
{{.DisplayName}}{{if ne .DisplayName .ID}}{{.ID}}{{end}}
-
{{.Status}}{{if .Runner}}{{.Runner}}{{else}}—{{end}}{{if .Duration}}{{.Duration}}{{else if .Started}}In progress{{else}}Not started{{end}}
+
{{if .Environment}}{{.Environment}}{{else}}—{{end}}{{.Status}}{{if .Runner}}{{.Runner}}{{else}}—{{end}}{{if .Duration}}{{.Duration}}{{else if .Started}}In progress{{else}}Not started{{end}}
{{else}}No jobs have been created.
{{end}}
@@ -105,7 +105,7 @@ const logPageTemplate = `
{{.JobName}} · {{.WorkflowName}} · Open Actions
@@ -120,7 +120,7 @@ const logPageTemplate = `
diff --git a/internal/controller/project_controller.go b/internal/controller/project_controller.go
index 736ae29..c75526c 100644
--- a/internal/controller/project_controller.go
+++ b/internal/controller/project_controller.go
@@ -3,10 +3,12 @@ package controller
import (
"context"
"fmt"
+ "strings"
"time"
actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1"
githubclient "github.com/kelos-dev/open-actions/internal/github"
+ corev1 "k8s.io/api/core/v1"
apiEquality "k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -64,13 +66,28 @@ func (r *ProjectReconciler) validate(ctx context.Context, project *actionsv1alph
github := project.Spec.Source.GitHub
privateKey, err := secretValue(ctx, r.APIReader, project.Namespace, github.PrivateKeySecretRef)
if err != nil {
- return "CredentialsUnavailable", err
+ return "CredentialsUnavailable", fmt.Errorf("validate Project %q credentials: %w", project.Name, err)
}
if err := githubclient.ValidatePrivateKey(privateKey); err != nil {
- return "InvalidCredentials", fmt.Errorf("validate GitHub App private key: %w", err)
+ return "InvalidCredentials", fmt.Errorf("validate Project %q GitHub App private key: %w", project.Name, err)
}
if _, err := secretValue(ctx, r.APIReader, project.Namespace, github.WebhookSecretRef); err != nil {
- return "CredentialsUnavailable", err
+ return "CredentialsUnavailable", fmt.Errorf("validate Project %q credentials: %w", project.Name, err)
+ }
+ environments := make(map[string]string, len(project.Spec.Environments))
+ for index := range project.Spec.Environments {
+ environment := &project.Spec.Environments[index]
+ canonicalName := strings.ToLower(environment.Name)
+ if other := environments[canonicalName]; other != "" {
+ return "EnvironmentInvalid", fmt.Errorf("Project %q contains case-insensitive duplicate environments %q and %q", project.Name, other, environment.Name)
+ }
+ environments[canonicalName] = environment.Name
+ if environment.SecretRef != nil && environment.SecretRef.Name == "" {
+ return "EnvironmentInvalid", fmt.Errorf("Project %q environment %q has an empty secretRef name", project.Name, environment.Name)
+ }
+ if _, err := environmentSecretValues(ctx, r.APIReader, project.Namespace, environment.SecretRef); err != nil {
+ return "EnvironmentSecretsUnavailable", fmt.Errorf("validate Project %q environment %q: %w", project.Name, environment.Name, err)
+ }
}
return "", nil
}
@@ -110,9 +127,31 @@ func (r *ProjectReconciler) SetupWithManager(manager ctrl.Manager) error {
return ctrl.NewControllerManagedBy(manager).
For(&actionsv1alpha1.Project{}).
Watches(&actionsv1alpha1.Project{}, handler.EnqueueRequestsFromMapFunc(r.projectsForInstallation)).
+ Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.projectsForSecret)).
Complete(r)
}
+func (r *ProjectReconciler) projectsForSecret(ctx context.Context, object client.Object) []reconcile.Request {
+ projects := &actionsv1alpha1.ProjectList{}
+ if err := r.List(ctx, projects, client.InNamespace(object.GetNamespace())); err != nil {
+ return nil
+ }
+ requests := []reconcile.Request{}
+ for index := range projects.Items {
+ project := &projects.Items[index]
+ github := project.Spec.Source.GitHub
+ matched := github != nil && (github.PrivateKeySecretRef.Name == object.GetName() || github.WebhookSecretRef.Name == object.GetName())
+ for environmentIndex := range project.Spec.Environments {
+ reference := project.Spec.Environments[environmentIndex].SecretRef
+ matched = matched || reference != nil && reference.Name == object.GetName()
+ }
+ if matched {
+ requests = append(requests, requestFor(project))
+ }
+ }
+ return requests
+}
+
func (r *ProjectReconciler) projectsForInstallation(ctx context.Context, object client.Object) []reconcile.Request {
project, ok := object.(*actionsv1alpha1.Project)
if !ok || project.Spec.Source.GitHub == nil {
diff --git a/internal/controller/project_controller_test.go b/internal/controller/project_controller_test.go
index c8c53e0..71ebcb9 100644
--- a/internal/controller/project_controller_test.go
+++ b/internal/controller/project_controller_test.go
@@ -6,6 +6,7 @@ import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
+ "strings"
"testing"
"time"
@@ -40,7 +41,7 @@ func TestProjectConfiguredConditionDescribesLocalValidation(t *testing.T) {
PrivateKeySecretRef: corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "github"}, Key: "private-key"},
WebhookSecretRef: corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "github"}, Key: "webhook-secret"},
},
- }},
+ }, Environments: []actionsv1alpha1.ProjectEnvironment{{Name: "production", SecretRef: &actionsv1alpha1.EnvironmentSecretReference{Name: "production-secrets"}}}},
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "github", Namespace: "default"},
@@ -49,7 +50,8 @@ func TestProjectConfiguredConditionDescribesLocalValidation(t *testing.T) {
"webhook-secret": []byte("secret"),
},
}
- clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&actionsv1alpha1.Project{}).WithObjects(project, secret).Build()
+ environmentSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "production-secrets", Namespace: "default"}, Data: map[string][]byte{"DEPLOY_TOKEN": []byte("secret")}}
+ clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&actionsv1alpha1.Project{}).WithObjects(project, secret, environmentSecret).Build()
reconciler := &ProjectReconciler{Client: clusterClient, APIReader: clusterClient}
if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "default"}}); err != nil {
t.Fatal(err)
@@ -64,6 +66,19 @@ func TestProjectConfiguredConditionDescribesLocalValidation(t *testing.T) {
}
}
+func TestProjectRejectsInvalidEnvironmentSecrets(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "environment", Namespace: "default"}, Data: map[string][]byte{"GITHUB_TOKEN": []byte("override")}}
+ clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build()
+ _, err := environmentSecretValues(context.Background(), clusterClient, "default", &actionsv1alpha1.EnvironmentSecretReference{Name: secret.Name})
+ if err == nil || !strings.Contains(err.Error(), `invalid GitHub secret name "GITHUB_TOKEN"`) {
+ t.Fatalf("environmentSecretValues() error = %v", err)
+ }
+}
+
func TestEarlierProjectOwnsInstallationAcrossNamespaces(t *testing.T) {
scheme := runtime.NewScheme()
if err := actionsv1alpha1.AddToScheme(scheme); err != nil {
diff --git a/internal/controller/runner_controller.go b/internal/controller/runner_controller.go
index 57fe92d..c8bb715 100644
--- a/internal/controller/runner_controller.go
+++ b/internal/controller/runner_controller.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/binary"
+ "encoding/json"
"errors"
"fmt"
"sort"
@@ -33,10 +34,13 @@ var (
const (
jobPlanVolume = "open-actions-job"
+ jobCredentialsVolume = "open-actions-credentials"
workspaceVolume = "open-actions-workspace"
dockerSocketVolume = "open-actions-docker-socket"
dockerStorageVolume = "open-actions-docker-storage"
jobPlanMountPath = "/var/run/open-actions"
+ jobCredentialsMountPath = "/var/run/open-actions-credentials"
+ jobSecretsKey = "secrets.json"
workspaceVolumeMountPath = "/workspace"
jobResultPath = "/dev/termination-log"
dockerSocketDirectory = "/var/run/open-actions-docker"
@@ -391,6 +395,9 @@ func (r *RunnerReconciler) executeWorkflowJob(ctx context.Context, runnerObject
if workflowJob.Labels[actionsv1alpha1.LabelProjectUID] != string(project.UID) {
return true, r.failAssignedWorkflowJob(ctx, workflowJob, "ProjectRecreated", fmt.Sprintf("Project %q was recreated before execution started", project.Name))
}
+ if !workflowJobEnvironmentApproved(workflowJob) {
+ return false, fmt.Errorf("WorkflowJob %q environment is not approved", workflowJob.Name)
+ }
if workflowJobStarted(workflowJob) {
active, err := r.activeWorkflowJobPods(ctx, workflowJob)
if err != nil {
@@ -427,7 +434,15 @@ func (r *RunnerReconciler) executeWorkflowJob(ctx context.Context, runnerObject
} else if canceled {
return true, r.cancelWorkflowJob(ctx, workflowJob)
}
- if err := r.ensureAuthSecret(ctx, workflowJob, installation.Token()); err != nil {
+ var secretsReference *actionsv1alpha1.EnvironmentSecretReference
+ if workflowJob.Spec.Environment != nil {
+ secretsReference = workflowJob.Spec.Environment.SecretRef
+ }
+ environmentSecrets, err := environmentSecretValues(ctx, r.APIReader, project.Namespace, secretsReference)
+ if err != nil {
+ return false, fmt.Errorf("read environment secrets for WorkflowJob %q: %w", workflowJob.Name, err)
+ }
+ if err := r.ensureAuthSecret(ctx, workflowJob, installation.Token(), environmentSecrets); err != nil {
return false, err
}
nativeJob, err := r.buildJob(workflowJob, run, project, runnerObject)
@@ -615,7 +630,11 @@ func (r *RunnerReconciler) cleanupAuthSecret(ctx context.Context, workflowJob *a
return nil
}
-func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *actionsv1alpha1.WorkflowJob, token string) error {
+func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *actionsv1alpha1.WorkflowJob, token string, environmentSecrets map[string]string) error {
+ secretsData, err := json.Marshal(environmentSecrets)
+ if err != nil {
+ return fmt.Errorf("encode environment secrets for WorkflowJob %q: %w", workflowJob.Name, err)
+ }
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
Name: childName(workflowJob.Name, "auth"),
Namespace: workflowJob.Namespace,
@@ -623,7 +642,7 @@ func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *ac
actionsv1alpha1.LabelWorkflowRunUID: workflowJob.Labels[actionsv1alpha1.LabelWorkflowRunUID],
actionsv1alpha1.LabelWorkflowJobUID: string(workflowJob.UID),
},
- }, Data: map[string][]byte{"token": []byte(token)}}
+ }, Data: map[string][]byte{"token": []byte(token), jobSecretsKey: secretsData}}
if err := controllerutil.SetControllerReference(workflowJob, secret, r.Scheme()); err != nil {
return err
}
@@ -651,6 +670,7 @@ func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *ac
existing.Data = map[string][]byte{}
}
existing.Data["token"] = []byte(token)
+ existing.Data[jobSecretsKey] = secretsData
if apiEquality.Semantic.DeepEqual(before, existing) {
return nil
}
@@ -703,11 +723,13 @@ func (r *RunnerReconciler) buildJob(workflowJob *actionsv1alpha1.WorkflowJob, ru
}},
VolumeMounts: []corev1.VolumeMount{
{Name: jobPlanVolume, MountPath: jobPlanMountPath, ReadOnly: true},
+ {Name: jobCredentialsVolume, MountPath: jobCredentialsMountPath, ReadOnly: true},
{Name: workspaceVolume, MountPath: workspaceVolumeMountPath},
},
}},
Volumes: []corev1.Volume{
{Name: jobPlanVolume, VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: childName(workflowJob.Name, "plan")}}}},
+ {Name: jobCredentialsVolume, VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: childName(workflowJob.Name, "auth")}}},
{Name: workspaceVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
},
},
@@ -997,12 +1019,19 @@ func (r *RunnerReconciler) runnersForWorkflowRun(ctx context.Context, object cli
func indexQueuedWorkflowJob(object client.Object) []string {
workflowJob := object.(*actionsv1alpha1.WorkflowJob)
- if workflowJob.DeletionTimestamp.IsZero() && workflowJob.Status.RunnerRef == nil && !terminalWorkflowJob(workflowJob) {
+ if workflowJob.DeletionTimestamp.IsZero() && workflowJob.Status.RunnerRef == nil && !terminalWorkflowJob(workflowJob) && workflowJobEnvironmentApproved(workflowJob) {
return []string{"true"}
}
return nil
}
+func workflowJobEnvironmentApproved(workflowJob *actionsv1alpha1.WorkflowJob) bool {
+ if workflowJob.Spec.Environment == nil {
+ return true
+ }
+ return meta.IsStatusConditionTrue(workflowJob.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved)
+}
+
func indexWorkflowJobRunnerName(object client.Object) []string {
workflowJob := object.(*actionsv1alpha1.WorkflowJob)
if workflowJob.Status.RunnerRef == nil {
diff --git a/internal/controller/runner_controller_test.go b/internal/controller/runner_controller_test.go
index c75088c..afe08ec 100644
--- a/internal/controller/runner_controller_test.go
+++ b/internal/controller/runner_controller_test.go
@@ -2,6 +2,7 @@ package controller
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"slices"
@@ -115,7 +116,7 @@ func TestRunnerBuildsOwnedJob(t *testing.T) {
container.SecurityContext.Capabilities == nil || !slices.Equal(container.SecurityContext.Capabilities.Drop, []corev1.Capability{"ALL"}) {
t.Errorf("container security context = %#v", container.SecurityContext)
}
- if len(job.Spec.Template.Spec.Volumes) != 2 {
+ if len(job.Spec.Template.Spec.Volumes) != 3 {
t.Errorf("volumes = %#v", job.Spec.Template.Spec.Volumes)
}
}
@@ -178,6 +179,7 @@ func TestRunnerBuildsDockerEnabledJob(t *testing.T) {
}
expectedRunnerMounts := []corev1.VolumeMount{
{Name: jobPlanVolume, MountPath: jobPlanMountPath, ReadOnly: true},
+ {Name: jobCredentialsVolume, MountPath: jobCredentialsMountPath, ReadOnly: true},
{Name: workspaceVolume, MountPath: workspaceVolumeMountPath},
{Name: dockerSocketVolume, MountPath: dockerSocketDirectory},
}
@@ -518,6 +520,26 @@ func TestDeletingWorkflowJobIsNotQueued(t *testing.T) {
}
}
+func TestEnvironmentApprovalControlsWorkflowJobQueue(t *testing.T) {
+ workflowJob := &actionsv1alpha1.WorkflowJob{Spec: actionsv1alpha1.WorkflowJobSpec{
+ Environment: &actionsv1alpha1.WorkflowJobEnvironment{
+ Name: "ok-to-test", Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true},
+ },
+ }}
+ meta.SetStatusCondition(&workflowJob.Status.Conditions, metav1.Condition{
+ Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionFalse, Reason: "ApprovalRequired",
+ })
+ if values := indexQueuedWorkflowJob(workflowJob); len(values) != 0 {
+ t.Fatalf("unapproved WorkflowJob queue index = %v", values)
+ }
+ meta.SetStatusCondition(&workflowJob.Status.Conditions, metav1.Condition{
+ Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionTrue, Reason: "EnvironmentApproved",
+ })
+ if values := indexQueuedWorkflowJob(workflowJob); !slices.Equal(values, []string{"true"}) {
+ t.Fatalf("approved WorkflowJob queue index = %v", values)
+ }
+}
+
func TestTerminalWorkflowJobStatusIsDurableBeforeCredentialCleanup(t *testing.T) {
scheme := runnerTestScheme(t)
workflowJob := &actionsv1alpha1.WorkflowJob{
@@ -1119,7 +1141,7 @@ func TestEnsureAuthSecretRejectsUnownedCollision(t *testing.T) {
}
clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workflowJob, secret).Build()
reconciler := &RunnerReconciler{Client: clusterClient, APIReader: clusterClient}
- if err := reconciler.ensureAuthSecret(context.Background(), workflowJob, "replacement"); err == nil {
+ if err := reconciler.ensureAuthSecret(context.Background(), workflowJob, "replacement", nil); err == nil {
t.Fatal("unowned authentication Secret was accepted")
}
stored := &corev1.Secret{}
@@ -1131,6 +1153,30 @@ func TestEnsureAuthSecretRejectsUnownedCollision(t *testing.T) {
}
}
+func TestEnsureAuthSecretStoresEnvironmentSecretsSeparatelyFromToken(t *testing.T) {
+ scheme := runnerTestScheme(t)
+ workflowJob := &actionsv1alpha1.WorkflowJob{
+ TypeMeta: metav1.TypeMeta{APIVersion: actionsv1alpha1.GroupVersion.String(), Kind: "WorkflowJob"},
+ ObjectMeta: metav1.ObjectMeta{Name: "build", Namespace: "default", UID: types.UID("job-uid")},
+ }
+ clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workflowJob).Build()
+ reconciler := &RunnerReconciler{Client: clusterClient, APIReader: clusterClient}
+ if err := reconciler.ensureAuthSecret(context.Background(), workflowJob, "installation-token", map[string]string{"DEPLOY_TOKEN": "environment-token"}); err != nil {
+ t.Fatal(err)
+ }
+ stored := &corev1.Secret{}
+ if err := clusterClient.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: childName(workflowJob.Name, "auth")}, stored); err != nil {
+ t.Fatal(err)
+ }
+ secrets := map[string]string{}
+ if err := json.Unmarshal(stored.Data[jobSecretsKey], &secrets); err != nil {
+ t.Fatal(err)
+ }
+ if string(stored.Data["token"]) != "installation-token" || secrets["DEPLOY_TOKEN"] != "environment-token" {
+ t.Fatalf("authentication Secret data = %#v, secrets = %#v", stored.Data, secrets)
+ }
+}
+
func TestDeletingBusyRunnerFinalizesItsWorkflowJob(t *testing.T) {
scheme := runnerTestScheme(t)
deletionTime := metav1.Now()
diff --git a/internal/controller/secrets.go b/internal/controller/secrets.go
index c6546c7..3b75233 100644
--- a/internal/controller/secrets.go
+++ b/internal/controller/secrets.go
@@ -3,11 +3,23 @@ package controller
import (
"context"
"fmt"
+ "regexp"
+ "sort"
+ "strings"
+ "unicode/utf8"
+ actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
+const (
+ maxEnvironmentSecrets = 100
+ maxEnvironmentSecretBytes = 8 << 10
+)
+
+var environmentSecretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+
func secretValue(ctx context.Context, reader client.Reader, namespace string, selector corev1.SecretKeySelector) ([]byte, error) {
secret := &corev1.Secret{}
if err := reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: selector.Name}, secret); err != nil {
@@ -19,3 +31,39 @@ func secretValue(ctx context.Context, reader client.Reader, namespace string, se
}
return value, nil
}
+
+func environmentSecretValues(ctx context.Context, reader client.Reader, namespace string, reference *actionsv1alpha1.EnvironmentSecretReference) (map[string]string, error) {
+ if reference == nil {
+ return map[string]string{}, nil
+ }
+ secret := &corev1.Secret{}
+ if err := reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: reference.Name}, secret); err != nil {
+ return nil, fmt.Errorf("get environment Secret %q: %w", reference.Name, err)
+ }
+ if len(secret.Data) > maxEnvironmentSecrets {
+ return nil, fmt.Errorf("environment Secret %q contains %d keys; maximum is %d", secret.Name, len(secret.Data), maxEnvironmentSecrets)
+ }
+ names := make([]string, 0, len(secret.Data))
+ for name := range secret.Data {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ values := make(map[string]string, len(names))
+ canonicalNames := make(map[string]string, len(names))
+ for _, name := range names {
+ value := secret.Data[name]
+ canonical := strings.ToUpper(name)
+ if !environmentSecretNamePattern.MatchString(name) || strings.HasPrefix(canonical, "GITHUB_") {
+ return nil, fmt.Errorf("environment Secret %q contains invalid GitHub secret name %q", secret.Name, name)
+ }
+ if other := canonicalNames[canonical]; other != "" {
+ return nil, fmt.Errorf("environment Secret %q contains case-insensitive duplicate keys %q and %q", secret.Name, other, name)
+ }
+ if len(value) > maxEnvironmentSecretBytes || !utf8.Valid(value) {
+ return nil, fmt.Errorf("environment Secret %q value %q must be valid UTF-8 no larger than %d bytes", secret.Name, name, maxEnvironmentSecretBytes)
+ }
+ canonicalNames[canonical] = name
+ values[name] = string(value)
+ }
+ return values, nil
+}
diff --git a/internal/controller/workflowrun_controller.go b/internal/controller/workflowrun_controller.go
index b42cb42..7a12022 100644
--- a/internal/controller/workflowrun_controller.go
+++ b/internal/controller/workflowrun_controller.go
@@ -284,7 +284,11 @@ func (r *WorkflowRunReconciler) reconcileGitHubCheck(ctx context.Context, run *a
if !r.githubCheckEnabled(run) {
return nil
}
- report := workflowRunCheckReport(run)
+ jobs := &actionsv1alpha1.WorkflowJobList{}
+ if err := r.APIReader.List(ctx, jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil {
+ return fmt.Errorf("list WorkflowJobs for WorkflowRun %q GitHub check: %w", run.Name, err)
+ }
+ report := workflowRunCheckReportWithJobs(run, jobs.Items)
project := &actionsv1alpha1.Project{}
projectKey := client.ObjectKey{Namespace: run.Namespace, Name: run.Spec.ProjectRef.Name}
if err := r.APIReader.Get(ctx, projectKey, project); err != nil {
@@ -376,6 +380,10 @@ func checkRunReportDigest(request githubclient.CreateCheckRunRequest) string {
}
func workflowRunCheckReport(run *actionsv1alpha1.WorkflowRun) checkRunReport {
+ return workflowRunCheckReportWithJobs(run, nil)
+}
+
+func workflowRunCheckReportWithJobs(run *actionsv1alpha1.WorkflowRun, workflowJobs []actionsv1alpha1.WorkflowJob) checkRunReport {
title := run.Status.WorkflowName
if title == "" {
title = run.Spec.WorkflowPath
@@ -422,11 +430,59 @@ func workflowRunCheckReport(run *actionsv1alpha1.WorkflowRun) checkRunReport {
}
if run.Status.Jobs != nil {
jobs := run.Status.Jobs
- report.Output.Text = fmt.Sprintf("Jobs: %d total, %d queued, %d active, %d succeeded, %d failed.", jobs.Total, jobs.Queued, jobs.Active, jobs.Succeeded, jobs.Failed)
+ report.Output.Text = fmt.Sprintf("Jobs: %d total, %d waiting for approval, %d queued, %d active, %d succeeded, %d failed.", jobs.Total, jobs.WaitingForApproval, jobs.Queued, jobs.Active, jobs.Succeeded, jobs.Failed)
+ }
+ if environmentText := checkRunEnvironmentText(workflowJobs); environmentText != "" {
+ if report.Output.Text != "" {
+ report.Output.Text += "\n\n"
+ }
+ report.Output.Text += environmentText
}
return report
}
+func checkRunEnvironmentText(workflowJobs []actionsv1alpha1.WorkflowJob) string {
+ type environmentState struct {
+ name string
+ required bool
+ waiting bool
+ }
+ states := map[string]environmentState{}
+ for index := range workflowJobs {
+ job := &workflowJobs[index]
+ if job.Spec.Environment == nil {
+ continue
+ }
+ key := strings.ToLower(job.Spec.Environment.Name)
+ state := states[key]
+ state.name = job.Spec.Environment.Name
+ state.required = state.required || job.Spec.Environment.Protection != nil && job.Spec.Environment.Protection.RequiredApproval
+ state.waiting = state.waiting || !workflowJobEnvironmentApproved(job)
+ states[key] = state
+ }
+ if len(states) == 0 {
+ return ""
+ }
+ keys := make([]string, 0, len(states))
+ for key := range states {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ lines := make([]string, 0, len(keys))
+ for _, key := range keys {
+ state := states[key]
+ approval := "approval not required"
+ if state.required {
+ approval = "approved"
+ }
+ if state.waiting {
+ approval = "waiting for approval"
+ }
+ lines = append(lines, fmt.Sprintf("Environment %q: %s.", state.name, approval))
+ }
+ return strings.Join(lines, "\n")
+}
+
func (r *checkRunReport) summaryFromCondition(condition *metav1.Condition) {
if condition.Message != "" {
r.Output.Summary = condition.Message
@@ -466,6 +522,7 @@ type plannedWorkflowJob struct {
displayName string
runsOn []string
matrix *actionsv1alpha1.WorkflowJobMatrix
+ environment *workflow.JobEnvironment
plan string
resultVersion string
}
@@ -486,6 +543,10 @@ func (r *WorkflowRunReconciler) ensureWorkflowJobs(ctx context.Context, run *act
for _, item := range plannedJobs {
id := item.id
+ environment, err := workflowJobEnvironment(project, item.environment)
+ if err != nil {
+ return &terminalPlanningError{cause: fmt.Errorf("job %q: %w", id, err)}
+ }
labels := workflowJobLabels(run, project, id)
annotations := map[string]string{actionsv1alpha1.AnnotationProjectName: project.Name}
if item.resultVersion != "" {
@@ -504,6 +565,7 @@ func (r *WorkflowRunReconciler) ensureWorkflowJobs(ctx context.Context, run *act
DisplayName: item.displayName,
RunsOn: append([]string(nil), item.runsOn...),
Matrix: item.matrix.DeepCopy(),
+ Environment: environment,
},
}
if err := controllerutil.SetControllerReference(run, workflowJob, r.Scheme()); err != nil {
@@ -531,10 +593,71 @@ func (r *WorkflowRunReconciler) ensureWorkflowJobs(ctx context.Context, run *act
if err := r.ensurePlanConfigMap(ctx, workflowJob, item.plan); err != nil {
return err
}
+ if err := r.reconcileEnvironmentApproval(ctx, workflowJob); err != nil {
+ return err
+ }
}
return nil
}
+func workflowJobEnvironment(project *actionsv1alpha1.Project, requested *workflow.JobEnvironment) (*actionsv1alpha1.WorkflowJobEnvironment, error) {
+ if requested == nil {
+ return nil, nil
+ }
+ var matched *actionsv1alpha1.ProjectEnvironment
+ for index := range project.Spec.Environments {
+ configured := &project.Spec.Environments[index]
+ if !strings.EqualFold(configured.Name, requested.Name) {
+ continue
+ }
+ if matched != nil {
+ return nil, fmt.Errorf("environment %q is configured more than once on Project %q", requested.Name, project.Name)
+ }
+ matched = configured
+ }
+ if matched != nil {
+ return &actionsv1alpha1.WorkflowJobEnvironment{
+ Name: matched.Name, URL: requested.URL,
+ SecretRef: matched.SecretRef.DeepCopy(), Protection: matched.Protection.DeepCopy(),
+ }, nil
+ }
+ return nil, fmt.Errorf("environment %q is not configured on Project %q", requested.Name, project.Name)
+}
+
+func (r *WorkflowRunReconciler) reconcileEnvironmentApproval(ctx context.Context, workflowJob *actionsv1alpha1.WorkflowJob) error {
+ environment := workflowJob.Spec.Environment
+ if environment == nil {
+ return nil
+ }
+ required := environment.Protection != nil && environment.Protection.RequiredApproval
+ approved := !required || strings.EqualFold(workflowJob.Annotations[actionsv1alpha1.AnnotationEnvironmentApproved], environment.Name)
+ current := meta.FindStatusCondition(workflowJob.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved)
+ if workflowJob.Status.RunnerRef != nil && current != nil && current.Status == metav1.ConditionTrue {
+ approved = true
+ }
+ status := metav1.ConditionFalse
+ reason := "ApprovalRequired"
+ message := fmt.Sprintf("Environment %q requires approval", environment.Name)
+ if approved {
+ status = metav1.ConditionTrue
+ reason = "EnvironmentApproved"
+ message = fmt.Sprintf("Environment %q was approved", environment.Name)
+ if !required {
+ reason = "ApprovalNotRequired"
+ message = fmt.Sprintf("Environment %q does not require approval", environment.Name)
+ }
+ }
+ before := workflowJob.Status.DeepCopy()
+ meta.SetStatusCondition(&workflowJob.Status.Conditions, metav1.Condition{
+ Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: status,
+ ObservedGeneration: workflowJob.Generation, Reason: reason, Message: message,
+ })
+ if apiEquality.Semantic.DeepEqual(before, &workflowJob.Status) {
+ return nil
+ }
+ return r.Status().Update(ctx, workflowJob)
+}
+
func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRun, definition *workflow.Definition, inputValues map[string]any) ([]plannedWorkflowJob, error) {
jobIDs := make([]string, 0, len(definition.Jobs))
for id := range definition.Jobs {
@@ -601,7 +724,8 @@ func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRu
resultVersion = jobResultVersion
}
plannedJobs = append(plannedJobs, plannedWorkflowJob{
- id: expandedID, displayName: displayName, runsOn: append([]string(nil), resolvedJob.RunsOn...), matrix: matrixSpec, plan: string(data), resultVersion: resultVersion,
+ id: expandedID, displayName: displayName, runsOn: append([]string(nil), resolvedJob.RunsOn...), matrix: matrixSpec,
+ environment: resolvedJob.Environment, plan: string(data), resultVersion: resultVersion,
})
}
}
@@ -1017,6 +1141,9 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac
}
for index := range jobs.Items {
job := &jobs.Items[index]
+ if err := r.reconcileEnvironmentApproval(ctx, job); err != nil {
+ return ctrl.Result{}, err
+ }
condition := meta.FindStatusCondition(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionSucceeded)
if condition == nil || (condition.Status != metav1.ConditionTrue && condition.Status != metav1.ConditionFalse) {
plan := &corev1.ConfigMap{}
@@ -1052,6 +1179,8 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac
status.Failed++
case job.Status.RunnerRef != nil:
status.Active++
+ case !workflowJobEnvironmentApproved(job):
+ status.WaitingForApproval++
default:
status.Queued++
}
@@ -1110,6 +1239,8 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac
now := metav1.Now()
run.Status.CompletionTime = &now
meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowRunConditionSucceeded, Status: metav1.ConditionTrue, ObservedGeneration: run.Generation, Reason: "JobsSucceeded", Message: "All WorkflowJobs succeeded"})
+ case status.WaitingForApproval > 0:
+ meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowRunConditionSucceeded, Status: metav1.ConditionUnknown, ObservedGeneration: run.Generation, Reason: "JobsWaitingForApproval", Message: "WorkflowJobs are waiting for environment approval"})
case status.Queued > 0:
meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowRunConditionSucceeded, Status: metav1.ConditionUnknown, ObservedGeneration: run.Generation, Reason: "JobsQueued", Message: "WorkflowJobs are waiting for matching Runners"})
default:
diff --git a/internal/controller/workflowrun_controller_test.go b/internal/controller/workflowrun_controller_test.go
index a9283e1..845d8aa 100644
--- a/internal/controller/workflowrun_controller_test.go
+++ b/internal/controller/workflowrun_controller_test.go
@@ -22,6 +22,7 @@ import (
"github.com/kelos-dev/open-actions/internal/workflow"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
+ apiEquality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -523,6 +524,22 @@ func TestWorkflowRunCheckReportMapsLifecycle(t *testing.T) {
}
}
+func TestWorkflowRunCheckReportIncludesEnvironmentApproval(t *testing.T) {
+ run := &actionsv1alpha1.WorkflowRun{Status: actionsv1alpha1.WorkflowRunStatus{Jobs: &actionsv1alpha1.WorkflowRunJobStatus{Total: 1, WaitingForApproval: 1}}}
+ job := actionsv1alpha1.WorkflowJob{Spec: actionsv1alpha1.WorkflowJobSpec{Environment: &actionsv1alpha1.WorkflowJobEnvironment{
+ Name: "ok-to-test", Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true},
+ }}}
+ report := workflowRunCheckReportWithJobs(run, []actionsv1alpha1.WorkflowJob{job})
+ if !strings.Contains(report.Output.Text, "1 waiting for approval") || !strings.Contains(report.Output.Text, `Environment "ok-to-test": waiting for approval.`) {
+ t.Fatalf("check output = %q", report.Output.Text)
+ }
+ meta.SetStatusCondition(&job.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionTrue, Reason: "EnvironmentApproved"})
+ report = workflowRunCheckReportWithJobs(run, []actionsv1alpha1.WorkflowJob{job})
+ if !strings.Contains(report.Output.Text, `Environment "ok-to-test": approved.`) {
+ t.Fatalf("approved check output = %q", report.Output.Text)
+ }
+}
+
func TestCompletedWorkflowRunTTL(t *testing.T) {
now := time.Date(2026, time.August, 10, 12, 0, 0, 0, time.UTC)
completedAt := metav1.NewTime(now.Add(-2 * time.Hour))
@@ -1021,6 +1038,81 @@ func TestEnsureWorkflowJobsCreatesReadableNames(t *testing.T) {
}
}
+func TestEnsureWorkflowJobsFreezesEnvironmentAndRequiresApproval(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := actionsv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ run := &actionsv1alpha1.WorkflowRun{ObjectMeta: metav1.ObjectMeta{Name: "deploy", Namespace: "default", UID: "run-uid"}}
+ project := &actionsv1alpha1.Project{
+ ObjectMeta: metav1.ObjectMeta{Name: "project", Namespace: "default", UID: "project-uid"},
+ Spec: actionsv1alpha1.ProjectSpec{Environments: []actionsv1alpha1.ProjectEnvironment{{
+ Name: "ok-to-test", SecretRef: &actionsv1alpha1.EnvironmentSecretReference{Name: "e2e-secrets"},
+ Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true},
+ }}},
+ }
+ clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&actionsv1alpha1.WorkflowJob{}).WithObjects(run, project).Build()
+ reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient}
+ planned := []plannedWorkflowJob{{
+ id: "e2e", displayName: "E2E", runsOn: []string{"linux"}, plan: "{}",
+ environment: &workflow.JobEnvironment{Name: "OK-TO-TEST", URL: "https://deploy.example/e2e"},
+ }}
+ if err := reconciler.ensureWorkflowJobs(context.Background(), run, project, planned); err != nil {
+ t.Fatal(err)
+ }
+ job := &actionsv1alpha1.WorkflowJob{}
+ if err := clusterClient.Get(context.Background(), client.ObjectKey{Namespace: run.Namespace, Name: workflowJobName(run.Name, "e2e")}, job); err != nil {
+ t.Fatal(err)
+ }
+ if job.Spec.Environment == nil || job.Spec.Environment.Name != "ok-to-test" || job.Spec.Environment.URL != "https://deploy.example/e2e" || job.Spec.Environment.SecretRef == nil || job.Spec.Environment.SecretRef.Name != "e2e-secrets" {
+ t.Fatalf("WorkflowJob environment = %#v", job.Spec.Environment)
+ }
+ approved := meta.FindStatusCondition(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved)
+ if approved == nil || approved.Status != metav1.ConditionFalse || approved.Reason != "ApprovalRequired" {
+ t.Fatalf("environment approval condition = %#v", approved)
+ }
+ beforeSpec := job.Spec.DeepCopy()
+ if job.Annotations == nil {
+ job.Annotations = map[string]string{}
+ }
+ job.Annotations[actionsv1alpha1.AnnotationEnvironmentApproved] = "ok-to-test"
+ if err := clusterClient.Update(context.Background(), job); err != nil {
+ t.Fatal(err)
+ }
+ if err := reconciler.reconcileEnvironmentApproval(context.Background(), job); err != nil {
+ t.Fatal(err)
+ }
+ if !meta.IsStatusConditionTrue(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved) {
+ t.Fatalf("environment approval condition = %#v", job.Status.Conditions)
+ }
+ if !apiEquality.Semantic.DeepEqual(beforeSpec, &job.Spec) {
+ t.Fatalf("approval changed immutable WorkflowJob spec: %#v", job.Spec)
+ }
+}
+
+func TestEnsureWorkflowJobsRejectsUnconfiguredEnvironment(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := actionsv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ run := &actionsv1alpha1.WorkflowRun{ObjectMeta: metav1.ObjectMeta{Name: "deploy", Namespace: "default", UID: "run-uid"}}
+ project := &actionsv1alpha1.Project{ObjectMeta: metav1.ObjectMeta{Name: "project", Namespace: "default", UID: "project-uid"}}
+ clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(run, project).Build()
+ reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient}
+ err := reconciler.ensureWorkflowJobs(context.Background(), run, project, []plannedWorkflowJob{{
+ id: "deploy", runsOn: []string{"linux"}, plan: "{}", environment: &workflow.JobEnvironment{Name: "production"},
+ }})
+ if err == nil || !strings.Contains(err.Error(), `environment "production" is not configured on Project "project"`) {
+ t.Fatalf("ensureWorkflowJobs() error = %v", err)
+ }
+}
+
func TestEnsureWorkflowJobsPreservesMatrixIdentity(t *testing.T) {
scheme := runtime.NewScheme()
if err := actionsv1alpha1.AddToScheme(scheme); err != nil {
@@ -1191,6 +1283,48 @@ func TestPlannedWorkflowRunIsObservedWithoutPlanningDependencies(t *testing.T) {
}
}
+func TestPlannedWorkflowRunReportsEnvironmentApprovalWait(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := actionsv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ run := &actionsv1alpha1.WorkflowRun{
+ ObjectMeta: metav1.ObjectMeta{Name: "ci", Namespace: "default", UID: types.UID("run-uid")},
+ Status: actionsv1alpha1.WorkflowRunStatus{
+ WorkflowName: "CI", Jobs: &actionsv1alpha1.WorkflowRunJobStatus{Total: 1},
+ Conditions: []metav1.Condition{plannedCondition(metav1.ConditionTrue, "JobsPlanned")},
+ },
+ }
+ job := &actionsv1alpha1.WorkflowJob{
+ ObjectMeta: metav1.ObjectMeta{Name: "e2e", Namespace: "default", UID: types.UID("job-uid"), Labels: map[string]string{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}},
+ Spec: actionsv1alpha1.WorkflowJobSpec{Environment: &actionsv1alpha1.WorkflowJobEnvironment{
+ Name: "ok-to-test", Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true},
+ }},
+ }
+ plan := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: childName(job.Name, "plan"), Namespace: job.Namespace}}
+ if err := controllerutil.SetControllerReference(job, plan, scheme); err != nil {
+ t.Fatal(err)
+ }
+ clusterClient := fake.NewClientBuilder().WithScheme(scheme).
+ WithStatusSubresource(&actionsv1alpha1.WorkflowRun{}, &actionsv1alpha1.WorkflowJob{}).
+ WithObjects(run, job, plan).Build()
+ reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient}
+ if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(run)}); err != nil {
+ t.Fatal(err)
+ }
+ stored := &actionsv1alpha1.WorkflowRun{}
+ if err := clusterClient.Get(context.Background(), client.ObjectKeyFromObject(run), stored); err != nil {
+ t.Fatal(err)
+ }
+ condition := meta.FindStatusCondition(stored.Status.Conditions, actionsv1alpha1.WorkflowRunConditionSucceeded)
+ if stored.Status.Jobs == nil || stored.Status.Jobs.WaitingForApproval != 1 || stored.Status.Jobs.Queued != 0 || condition == nil || condition.Reason != "JobsWaitingForApproval" {
+ t.Fatalf("WorkflowRun status = %#v", stored.Status)
+ }
+}
+
func TestPlannedWorkflowRunFailsWhenAChildIsMissing(t *testing.T) {
scheme := runtime.NewScheme()
if err := actionsv1alpha1.AddToScheme(scheme); err != nil {
diff --git a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml
index 4b9bbe5..7fe0030 100644
--- a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml
+++ b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml
@@ -53,6 +53,55 @@ spec:
description: ProjectSpec describes the workflow source for an Open Actions
Project.
properties:
+ environments:
+ description: |-
+ Environments defines the environment names workflows may select. Each
+ environment may expose one Secret and require approval before its jobs can
+ be assigned to a Runner.
+ items:
+ description: |-
+ ProjectEnvironment configures one workflow environment. Environment names
+ are matched without regard to ASCII case.
+ properties:
+ name:
+ description: Name is the GitHub-compatible environment name
+ selected by a workflow job.
+ maxLength: 255
+ minLength: 1
+ pattern: ^[^\x00-\x1f\x7f]+$
+ type: string
+ protection:
+ description: Protection configures the gate enforced before
+ a job can be assigned.
+ properties:
+ requiredApproval:
+ description: |-
+ RequiredApproval requires an authorized user to approve each WorkflowJob
+ before a Runner can claim it.
+ type: boolean
+ type: object
+ secretRef:
+ description: |-
+ SecretRef identifies a Secret in the Project namespace whose data keys
+ populate the secrets expression context for jobs in this environment.
+ properties:
+ name:
+ description: Name is the Secret resource name.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?([.][a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$
+ type: string
+ required:
+ - name
+ type: object
+ required:
+ - name
+ type: object
+ maxItems: 100
+ type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
source:
description: Source selects and configures the external workflow source.
properties:
@@ -202,6 +251,10 @@ spec:
required:
- source
type: object
+ x-kubernetes-validations:
+ - message: environment names must be unique ignoring ASCII case
+ rule: '!has(self.environments) || self.environments.all(e, self.environments.exists_one(other,
+ e.name.lowerAscii() == other.name.lowerAscii()))'
status:
description: ProjectStatus contains observations made by the project controller.
properties:
diff --git a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml
index 57f6c2f..03fc494 100644
--- a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml
+++ b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml
@@ -25,6 +25,12 @@ spec:
- jsonPath: .status.runnerRef.name
name: Runner
type: string
+ - jsonPath: .spec.environment.name
+ name: Environment
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="EnvironmentApproved")].status
+ name: Approved
+ type: string
- jsonPath: .status.conditions[?(@.type=="Scheduled")].status
name: Scheduled
type: string
@@ -71,6 +77,50 @@ spec:
maxLength: 256
minLength: 1
type: string
+ environment:
+ description: |-
+ Environment contains the selected workflow environment and the Project
+ policy resolved for this job.
+ properties:
+ name:
+ description: Name is the configured Project environment name.
+ maxLength: 255
+ minLength: 1
+ pattern: ^[^\x00-\x1f\x7f]+$
+ type: string
+ protection:
+ description: Protection is the Project environment gate resolved
+ for this job.
+ properties:
+ requiredApproval:
+ description: |-
+ RequiredApproval requires an authorized user to approve each WorkflowJob
+ before a Runner can claim it.
+ type: boolean
+ type: object
+ secretRef:
+ description: |-
+ SecretRef identifies the Project Secret whose data keys are available to
+ this job through the secrets expression context.
+ properties:
+ name:
+ description: Name is the Secret resource name.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?([.][a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$
+ type: string
+ required:
+ - name
+ type: object
+ url:
+ description: URL is the optional deployment target URL resolved
+ from the workflow.
+ maxLength: 2048
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
jobID:
description: |-
JobID is the stable workflow-local identifier of this expanded job. For a
@@ -170,9 +220,10 @@ spec:
type: string
conditions:
description: |-
- Conditions describe Runner assignment and the terminal result. Scheduled
- is true after the scheduler assigns status.runnerRef. Known condition types
- are Scheduled and Succeeded.
+ Conditions describe environment approval, Runner assignment, and the
+ terminal result. EnvironmentApproved is present for jobs that select an
+ environment. Scheduled is true after the scheduler assigns status.runnerRef.
+ Known condition types are EnvironmentApproved, Scheduled, and Succeeded.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
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 1358dd6..e57f995 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
@@ -731,6 +731,14 @@ spec:
maximum: 100000
minimum: 0
type: integer
+ waitingForApproval:
+ description: |-
+ WaitingForApproval is the number of jobs blocked by an environment
+ approval gate and therefore not yet queued for a Runner.
+ format: int32
+ maximum: 100000
+ minimum: 0
+ type: integer
type: object
observedGeneration:
description: |-
diff --git a/internal/runner/expression.go b/internal/runner/expression.go
index 6140bef..1738c2d 100644
--- a/internal/runner/expression.go
+++ b/internal/runner/expression.go
@@ -10,19 +10,20 @@ import (
)
var (
- runnerJobAvailability = workflowexpression.NewAvailability("github", "matrix", "inputs")
- runnerStepAvailability = workflowexpression.NewAvailability("github", "matrix", "runner", "env", "inputs", "steps")
+ runnerJobAvailability = workflowexpression.NewAvailability("github", "matrix", "inputs", "secrets")
+ runnerStepAvailability = workflowexpression.NewAvailability("github", "matrix", "runner", "env", "inputs", "secrets", "steps")
runnerConditionAvailability = workflowexpression.NewAvailability("github", "matrix", "runner", "env", "inputs", "steps").WithStatusFunctions()
compositeAvailability = workflowexpression.NewAvailability("github", "runner", "env", "inputs", "steps")
compositeConditionAvailability = workflowexpression.NewAvailability("github", "runner", "env", "inputs", "steps").WithStatusFunctions()
+ actionDefaultAvailability = workflowexpression.NewAvailability("github", "matrix", "inputs")
)
-func resolveJobEnvironment(values map[string]string, plan *Plan, environment []string, token string) (map[string]string, error) {
- return resolveExpressionMap(values, expressionContext(plan, environment, "", nil, runnerJobAvailability, nil, token))
+func resolveJobEnvironment(values map[string]string, plan *Plan, environment []string, token string, secrets map[string]string) (map[string]string, error) {
+ return resolveExpressionMap(values, expressionContext(plan, environment, "", nil, runnerJobAvailability, nil, token, secrets))
}
func resolveActionDefaultExpression(input string, plan *Plan, environment []string, token string) (string, error) {
- context := expressionContext(plan, environment, "", nil, runnerJobAvailability, nil, token)
+ context := expressionContext(plan, environment, "", nil, actionDefaultAvailability, nil, token, nil)
return resolveExpressionString(input, context)
}
@@ -77,7 +78,7 @@ func workflowStepCondition(input string, environment map[string]string, status w
func workflowExpressionContext(state *executionState, environment []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)
+ return expressionContext(state.plan, environment, "", values, availability, status, state.githubToken, state.secrets)
}
func compositeExpressionContext(compositeContext *compositeContext, availability workflowexpression.Availability, status *workflowexpression.Status) workflowexpression.Context {
@@ -90,10 +91,10 @@ func compositeExpressionContext(compositeContext *compositeContext, availability
"inputs": compositeContext.inputs,
"steps": steps,
}
- return expressionContext(compositeContext.state.plan, environment, compositeContext.actionPath, values, availability, status, compositeContext.state.githubToken)
+ return expressionContext(compositeContext.state.plan, environment, compositeContext.actionPath, values, availability, status, compositeContext.state.githubToken, nil)
}
-func expressionContext(plan *Plan, environment []string, actionPath string, extra map[string]any, availability workflowexpression.Availability, status *workflowexpression.Status, token string) workflowexpression.Context {
+func expressionContext(plan *Plan, environment []string, actionPath string, extra map[string]any, availability workflowexpression.Availability, status *workflowexpression.Status, token string, secrets map[string]string) workflowexpression.Context {
pullRequestRefs := planPullRequestRefs(plan)
github := map[string]any{
"workflow": plan.WorkflowName,
@@ -125,6 +126,12 @@ func expressionContext(plan *Plan, environment []string, actionPath string, extr
},
"env": environmentContext(environment),
}
+ secretValues := make(map[string]any, len(secrets)+1)
+ for name, value := range secrets {
+ secretValues[name] = workflowexpression.Secret(value)
+ }
+ secretValues["GITHUB_TOKEN"] = workflowexpression.Secret(token)
+ values["secrets"] = secretValues
for name, value := range extra {
values[name] = value
}
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
index 095da60..a58f75b 100644
--- a/internal/runner/runner.go
+++ b/internal/runner/runner.go
@@ -23,7 +23,7 @@ import (
const (
minimumPlanVersion = 1
- PlanVersion = 4
+ PlanVersion = 5
ContainerName = "runner"
)
@@ -120,6 +120,7 @@ type Step struct {
type ExecutorConfig struct {
Logger *slog.Logger
GitHubToken string
+ Secrets map[string]string
Environment []string
Stdout io.Writer
Stderr io.Writer
@@ -128,6 +129,7 @@ type ExecutorConfig struct {
type Executor struct {
logger *slog.Logger
githubToken string
+ secrets map[string]string
environment []string
stdout io.Writer
stderr io.Writer
@@ -142,6 +144,7 @@ type executionState struct {
temporaryDirectory string
environment []string
githubToken string
+ secrets map[string]string
resolver *actionResolver
posts []*actionInvocation
compositeStack map[string]bool
@@ -155,9 +158,18 @@ func NewExecutor(config ExecutorConfig) (*Executor, error) {
}
masker := newOutputMasker(config.GitHubToken)
masker.add(base64.StdEncoding.EncodeToString([]byte("x-access-token:" + config.GitHubToken)))
+ secrets := make(map[string]string, len(config.Secrets))
+ for name, value := range config.Secrets {
+ if len(value) > maxMaskValueBytes {
+ return nil, fmt.Errorf("workflow job secret %q exceeds %d bytes", name, maxMaskValueBytes)
+ }
+ secrets[name] = value
+ masker.add(value)
+ }
return &Executor{
logger: config.Logger.With(runnerLogMarker, true),
githubToken: config.GitHubToken,
+ secrets: secrets,
environment: append([]string(nil), config.Environment...),
stdout: config.Stdout,
stderr: config.Stderr,
@@ -166,6 +178,24 @@ func NewExecutor(config ExecutorConfig) (*Executor, error) {
}, nil
}
+// LoadSecrets reads the environment-scoped secret values supplied separately
+// from the non-secret workflow plan.
+func LoadSecrets(path string) (map[string]string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read workflow job secrets: %w", err)
+ }
+ secrets := map[string]string{}
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ if err := decoder.Decode(&secrets); err != nil {
+ return nil, fmt.Errorf("decode workflow job secrets: %w", err)
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ return nil, errors.New("decode workflow job secrets: trailing JSON value")
+ }
+ return secrets, nil
+}
+
func LoadPlan(path string) (*Plan, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -264,7 +294,7 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string
"RUNNER_TEMP="+filepath.Join(temporaryDirectory, "temp"),
"RUNNER_TOOL_CACHE="+filepath.Join(temporaryDirectory, "tool-cache"),
)
- jobEnvironment, err := resolveJobEnvironment(plan.Env, plan, environment, e.githubToken)
+ jobEnvironment, err := resolveJobEnvironment(plan.Env, plan, environment, e.githubToken, e.secrets)
if err != nil {
return emptyResult, fmt.Errorf("resolve job environment: %w", err)
}
@@ -282,6 +312,7 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string
temporaryDirectory: temporaryDirectory,
environment: environment,
githubToken: e.githubToken,
+ secrets: e.secrets,
resolver: newActionResolver(plan.Repository.ActionCloneBaseURL, filepath.Join(temporaryDirectory, "actions"), environment, e.executeCommand),
compositeStack: map[string]bool{},
stepOutputs: map[string]map[string]any{},
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
index 11530f1..1c86916 100644
--- a/internal/runner/runner_test.go
+++ b/internal/runner/runner_test.go
@@ -391,13 +391,50 @@ func TestResolvedStepBytesEnforcesFieldLimits(t *testing.T) {
func TestExecuteRejectsUnavailableExpressionContext(t *testing.T) {
plan := testPlan()
- plan.Env = map[string]string{"TOKEN": "${{ secrets.TOKEN }}"}
+ plan.Steps[0].If = "${{ secrets.TOKEN }}"
err := testExecutor(t, io.Discard, io.Discard).Execute(context.Background(), plan, t.TempDir())
if err == nil || !strings.Contains(err.Error(), `context "secrets" is unavailable`) {
t.Fatalf("error = %v, want unavailable secrets context", err)
}
}
+func TestExecuteResolvesAndMasksEnvironmentSecrets(t *testing.T) {
+ plan := testPlan()
+ plan.Env = map[string]string{
+ "DEPLOY_TOKEN": "${{ secrets.DEPLOY_TOKEN }}",
+ "GITHUB_SECRET": "${{ secrets.GITHUB_TOKEN }}",
+ }
+ plan.Steps = []Step{{Run: `printf '%s %s\n' "$DEPLOY_TOKEN" "$GITHUB_SECRET"`}}
+ var output bytes.Buffer
+ executor, err := NewExecutor(ExecutorConfig{
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), GitHubToken: "installation-token",
+ Secrets: map[string]string{"DEPLOY_TOKEN": "environment-token"}, Environment: os.Environ(), Stdout: &output, Stderr: &output,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := executor.Execute(context.Background(), plan, t.TempDir()); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(output.String(), "environment-token") || strings.Contains(output.String(), "installation-token") || !strings.Contains(output.String(), "***") {
+ t.Fatalf("secret output was not masked: %q", output.String())
+ }
+}
+
+func TestLoadSecrets(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "secrets.json")
+ if err := os.WriteFile(path, []byte(`{"DEPLOY_TOKEN":"value"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ secrets, err := LoadSecrets(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if secrets["DEPLOY_TOKEN"] != "value" {
+ t.Fatalf("secrets = %#v", secrets)
+ }
+}
+
func TestLoadPlanSupportsCompatibleVersions(t *testing.T) {
for version := minimumPlanVersion; version <= PlanVersion; version++ {
t.Run(fmt.Sprintf("version %d", version), func(t *testing.T) {
@@ -421,7 +458,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")
+ context := expressionContext(plan, nil, "", nil, runnerConditionAvailability, nil, "token", nil)
enabled, err := evaluateCondition("${{ inputs.enabled }}", context, true)
if err != nil {
t.Fatal(err)
@@ -537,6 +574,16 @@ func TestNewExecutorRequiresGitHubToken(t *testing.T) {
}
}
+func TestNewExecutorRejectsSecretTooLargeToMask(t *testing.T) {
+ _, err := NewExecutor(ExecutorConfig{
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), GitHubToken: "token",
+ Secrets: map[string]string{"TOKEN": strings.Repeat("x", maxMaskValueBytes+1)}, Environment: os.Environ(), Stdout: io.Discard, Stderr: io.Discard,
+ })
+ if err == nil || !strings.Contains(err.Error(), "exceeds") {
+ t.Fatalf("NewExecutor() error = %v", err)
+ }
+}
+
func TestActionCloneTokenIsLimitedToWorkflowRepository(t *testing.T) {
plan := testPlan()
sameRepository := actionref.Reference{Owner: "ACME", Repository: "Example"}
diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go
index d5283a1..5637375 100644
--- a/internal/workflow/workflow.go
+++ b/internal/workflow/workflow.go
@@ -27,6 +27,8 @@ const (
maxMatrixValueLength = 1024
maxJobIDLength = 256
maxJobNameLength = 256
+ maxEnvironmentNameLength = 255
+ maxEnvironmentURLLength = 2048
maxRunnerLabels = 16
maxSteps = 100
MaxStepIDLength = 256
@@ -121,16 +123,57 @@ func (schedule *Schedule) UnmarshalYAML(node *yaml.Node) error {
}
type Job struct {
- Name string `yaml:"name"`
- RunsOn StringList `yaml:"runs-on"`
- Needs StringList `yaml:"needs"`
- Outputs map[string]any `yaml:"outputs"`
- Steps []Step `yaml:"steps"`
- Strategy Strategy `yaml:"strategy"`
- Container yaml.Node `yaml:"container"`
- Services yaml.Node `yaml:"services"`
- If string `yaml:"if"`
- Env map[string]any `yaml:"env"`
+ Name string `yaml:"name"`
+ RunsOn StringList `yaml:"runs-on"`
+ Needs StringList `yaml:"needs"`
+ Outputs map[string]any `yaml:"outputs"`
+ Steps []Step `yaml:"steps"`
+ Strategy Strategy `yaml:"strategy"`
+ Container yaml.Node `yaml:"container"`
+ Services yaml.Node `yaml:"services"`
+ If string `yaml:"if"`
+ Env map[string]any `yaml:"env"`
+ Environment *JobEnvironment `yaml:"environment"`
+}
+
+// JobEnvironment is the environment selected by a workflow job.
+type JobEnvironment struct {
+ Name string `yaml:"name"`
+ URL string `yaml:"url"`
+ urlSet bool
+}
+
+func (environment *JobEnvironment) UnmarshalYAML(node *yaml.Node) error {
+ switch node.Kind {
+ case yaml.ScalarNode:
+ if err := node.Decode(&environment.Name); err != nil {
+ return fmt.Errorf("environment must be a string or mapping")
+ }
+ return nil
+ case yaml.MappingNode:
+ if err := rejectDuplicateMappingKeys(node, "environment"); err != nil {
+ return err
+ }
+ for index := 0; index < len(node.Content); index += 2 {
+ name := node.Content[index].Value
+ switch name {
+ case "name":
+ if err := node.Content[index+1].Decode(&environment.Name); err != nil {
+ return fmt.Errorf("environment name must be a string")
+ }
+ case "url":
+ environment.urlSet = true
+ if err := node.Content[index+1].Decode(&environment.URL); err != nil {
+ return fmt.Errorf("environment url must be a string")
+ }
+ default:
+ return fmt.Errorf("unsupported environment field %q", name)
+ }
+ }
+ return nil
+ default:
+ return fmt.Errorf("environment must be a string or mapping")
+ }
}
type Strategy struct {
@@ -208,6 +251,8 @@ type Repository struct {
var (
workflowConcurrencyAvailability = expression.NewAvailability("github", "inputs", "vars")
jobNameAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "inputs")
+ jobEnvironmentNameAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "inputs")
+ jobEnvironmentURLAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "inputs")
jobEnvironmentAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "secrets", "inputs")
stepAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "secrets", "steps", "inputs")
stepConditionAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "steps", "inputs").WithStatusFunctions()
@@ -287,6 +332,26 @@ func validateJob(id string, job *Job) error {
if err := validateTemplate(fmt.Sprintf("job %q name", id), job.Name, jobNameAvailability); err != nil {
return err
}
+ if job.Environment != nil {
+ if job.Environment.Name == "" {
+ return fmt.Errorf("job %q environment name must not be empty", id)
+ }
+ if utf8.RuneCountInString(job.Environment.Name) > maxEnvironmentNameLength {
+ return fmt.Errorf("job %q environment name exceeds %d characters", id, maxEnvironmentNameLength)
+ }
+ if err := validateTemplate(fmt.Sprintf("job %q environment name", id), job.Environment.Name, jobEnvironmentNameAvailability); err != nil {
+ return err
+ }
+ if job.Environment.urlSet && job.Environment.URL == "" {
+ return fmt.Errorf("job %q environment url must not be empty", id)
+ }
+ if utf8.RuneCountInString(job.Environment.URL) > maxEnvironmentURLLength {
+ return fmt.Errorf("job %q environment url exceeds %d characters", id, maxEnvironmentURLLength)
+ }
+ if err := validateTemplate(fmt.Sprintf("job %q environment url", id), job.Environment.URL, jobEnvironmentURLAvailability); err != nil {
+ return err
+ }
+ }
if len(job.RunsOn) == 0 {
return fmt.Errorf("job %q must define runs-on", id)
}
@@ -583,6 +648,28 @@ func EvaluateJob(id string, job Job, context expression.Context) (Job, error) {
if utf8.RuneCountInString(job.Name) > maxJobNameLength {
return Job{}, fmt.Errorf("job %q evaluated name exceeds %d characters", id, maxJobNameLength)
}
+ if job.Environment != nil {
+ environment := *job.Environment
+ name, err := evaluateTemplateString(environment.Name, context)
+ if err != nil {
+ return Job{}, fmt.Errorf("job %q environment name: %w", id, err)
+ }
+ if name == "" || utf8.RuneCountInString(name) > maxEnvironmentNameLength {
+ return Job{}, fmt.Errorf("job %q evaluated an invalid environment name", id)
+ }
+ environment.Name = name
+ if environment.URL != "" {
+ url, err := evaluateTemplateString(environment.URL, context)
+ if err != nil {
+ return Job{}, fmt.Errorf("job %q environment url: %w", id, err)
+ }
+ if url == "" || utf8.RuneCountInString(url) > maxEnvironmentURLLength {
+ return Job{}, fmt.Errorf("job %q evaluated an invalid environment url", id)
+ }
+ environment.URL = url
+ }
+ job.Environment = &environment
+ }
job.RunsOn = append(StringList(nil), job.RunsOn...)
labels := make(map[string]struct{}, len(job.RunsOn))
@@ -612,6 +699,18 @@ func EvaluateJob(id string, job Job, context expression.Context) (Job, error) {
return job, nil
}
+func evaluateTemplateString(input string, context expression.Context) (string, error) {
+ program, err := expression.Parse(input)
+ if err != nil {
+ return "", err
+ }
+ result, err := program.Evaluate(context)
+ if err != nil {
+ return "", err
+ }
+ return result.String()
+}
+
func EvaluateConcurrency(definition *Definition, event Event) (string, bool, error) {
if definition.Concurrency.Group == "" {
return "", false, nil
diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go
index ca57d0b..cfd26a6 100644
--- a/internal/workflow/workflow_test.go
+++ b/internal/workflow/workflow_test.go
@@ -36,6 +36,51 @@ func TestParseRemainingKelosTriggers(t *testing.T) {
}
}
+func TestJobEnvironmentScalarAndMappingForms(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ value string
+ wantName string
+ wantURL string
+ }{
+ {name: "scalar", value: "staging", wantName: "staging"},
+ {name: "mapping", value: "\n name: ${{ inputs.environment }}-${{ matrix.arch }}\n url: https://deploy.example/${{ matrix.arch }}", wantName: "production-arm64", wantURL: "https://deploy.example/arm64"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ definition, err := Parse([]byte("name: Deploy\non: push\njobs:\n deploy:\n runs-on: ubuntu-latest\n environment: " + test.value + "\n strategy:\n matrix:\n arch: [arm64]\n steps:\n - run: deploy\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ job, err := EvaluateJob("deploy", definition.Jobs["deploy"], workflowexpression.Context{
+ Availability: workflowexpression.NewAvailability("github", "inputs", "matrix"),
+ Values: map[string]any{
+ "github": map[string]any{}, "inputs": map[string]any{"environment": "production"}, "matrix": map[string]any{"arch": "arm64"},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if job.Environment == nil || job.Environment.Name != test.wantName || job.Environment.URL != test.wantURL {
+ t.Fatalf("environment = %#v, want name %q url %q", job.Environment, test.wantName, test.wantURL)
+ }
+ })
+ }
+}
+
+func TestJobEnvironmentRejectsInvalidForms(t *testing.T) {
+ for _, environment := range []string{
+ "{}",
+ "{name: production, unknown: value}",
+ "{name: production, name: staging}",
+ "${{ secrets.ENVIRONMENT }}",
+ } {
+ workflowText := "name: Deploy\non: push\njobs:\n deploy:\n runs-on: ubuntu-latest\n environment: " + environment + "\n steps:\n - run: deploy\n"
+ if _, err := Parse([]byte(workflowText)); err == nil {
+ t.Fatalf("Parse() accepted environment %q", environment)
+ }
+ }
+}
+
func TestMatchWorkflowRunFilters(t *testing.T) {
definition, err := Parse([]byte("name: Deploy\non:\n workflow_run:\n workflows: [Release]\n types: [completed]\n branches: [main]\n" + minimalJob))
if err != nil {
diff --git a/internal/workflowstatus/status.go b/internal/workflowstatus/status.go
index 01efd93..f1938c4 100644
--- a/internal/workflowstatus/status.go
+++ b/internal/workflowstatus/status.go
@@ -8,6 +8,7 @@ import (
const (
queued = "Queued"
+ waiting = "Waiting for approval"
running = "Running"
succeeded = "Succeeded"
failed = "Failed"
@@ -46,6 +47,9 @@ func Job(job *actionsv1alpha1.WorkflowJob) string {
if job.Status.RunnerRef != nil {
return running
}
+ if job.Spec.Environment != nil && !meta.IsStatusConditionTrue(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved) {
+ return waiting
+ }
return queued
}
diff --git a/internal/workflowstatus/status_test.go b/internal/workflowstatus/status_test.go
index 8983c62..08b8472 100644
--- a/internal/workflowstatus/status_test.go
+++ b/internal/workflowstatus/status_test.go
@@ -5,6 +5,7 @@ import (
actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -36,6 +37,7 @@ func TestJob(t *testing.T) {
want string
}{
{name: "waiting", job: &actionsv1alpha1.WorkflowJob{}, want: "Queued"},
+ {name: "waiting for approval", job: workflowJobWaitingForApproval(), want: "Waiting for approval"},
{name: "running", job: &actionsv1alpha1.WorkflowJob{Status: actionsv1alpha1.WorkflowJobStatus{RunnerRef: &corev1.LocalObjectReference{Name: "runner"}}}, want: "Running"},
{name: "succeeded", job: workflowJobWithCondition(metav1.ConditionTrue), want: "Succeeded"},
{name: "failed", job: workflowJobWithCondition(metav1.ConditionFalse), want: "Failed"},
@@ -48,6 +50,12 @@ func TestJob(t *testing.T) {
}
}
+func workflowJobWaitingForApproval() *actionsv1alpha1.WorkflowJob {
+ job := &actionsv1alpha1.WorkflowJob{Spec: actionsv1alpha1.WorkflowJobSpec{Environment: &actionsv1alpha1.WorkflowJobEnvironment{Name: "production"}}}
+ meta.SetStatusCondition(&job.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionFalse, Reason: "ApprovalRequired"})
+ return job
+}
+
func TestJobTerminal(t *testing.T) {
for _, test := range []struct {
status metav1.ConditionStatus