From 953994cf354499e8f8b8244e47ec7491d53fd47d Mon Sep 17 00:00:00 2001 From: Noel Georgi Date: Thu, 11 Sep 2025 10:57:54 +0530 Subject: [PATCH] feat: use runner groups Use runner groups for GitHub action to work with GHA runner scale sets. Support `string`, `array` and `object` types for `runs-on` github action workflow syntax. Signed-off-by: Noel Georgi --- .github/workflows/ci.yaml | 5 +- .../workflows/slack-notify-ci-failure.yaml | 5 +- .github/workflows/slack-notify.yaml | 5 +- internal/output/ghworkflow/gh_workflow.go | 41 ++++----- .../output/ghworkflow/gh_workflow_test.go | 4 +- internal/output/ghworkflow/types.go | 86 ++++++++++++++++++- internal/project/common/gh_workflow.go | 39 ++------- internal/project/custom/custom.go | 10 +-- internal/project/helm/build.go | 5 +- internal/project/pkgfile/build.go | 9 +- 10 files changed, 126 insertions(+), 83 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 76b7a79..8a3d9d9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. # -# Generated on 2025-08-21T07:54:01Z by kres 18c31cf-dirty. +# Generated on 2025-09-11T00:53:48Z by kres ba56673-dirty. concurrency: group: ${{ github.head_ref || github.run_id }} @@ -27,8 +27,7 @@ jobs: packages: write pull-requests: read runs-on: - - self-hosted - - generic + group: generic if: (!startsWith(github.head_ref, 'renovate/') && !startsWith(github.head_ref, 'dependabot/')) steps: - name: gather-system-info diff --git a/.github/workflows/slack-notify-ci-failure.yaml b/.github/workflows/slack-notify-ci-failure.yaml index 925a90e..b62a14a 100644 --- a/.github/workflows/slack-notify-ci-failure.yaml +++ b/.github/workflows/slack-notify-ci-failure.yaml @@ -1,6 +1,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. # -# Generated on 2025-08-05T08:47:34Z by kres 95bf7d7-dirty. +# Generated on 2025-09-11T08:31:52Z by kres cb448bc-dirty. "on": workflow_run: @@ -14,8 +14,7 @@ name: slack-notify-failure jobs: slack-notify: runs-on: - - self-hosted - - generic + group: generic if: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' steps: - name: Slack Notify diff --git a/.github/workflows/slack-notify.yaml b/.github/workflows/slack-notify.yaml index 512abba..62adacc 100644 --- a/.github/workflows/slack-notify.yaml +++ b/.github/workflows/slack-notify.yaml @@ -1,6 +1,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. # -# Generated on 2025-08-04T12:39:36Z by kres 5fb5b90-dirty. +# Generated on 2025-09-11T08:31:52Z by kres cb448bc-dirty. "on": workflow_run: @@ -12,8 +12,7 @@ name: slack-notify jobs: slack-notify: runs-on: - - self-hosted - - generic + group: generic if: github.event.workflow_run.conclusion != 'skipped' steps: - name: Get PR number diff --git a/internal/output/ghworkflow/gh_workflow.go b/internal/output/ghworkflow/gh_workflow.go index 8329414..44656b8 100644 --- a/internal/output/ghworkflow/gh_workflow.go +++ b/internal/output/ghworkflow/gh_workflow.go @@ -22,8 +22,6 @@ import ( ) const ( - // HostedRunner is the name of the hosted runner. - HostedRunner = "self-hosted" // GenericRunner is the name of the generic runner. GenericRunner = "generic" // PkgsRunner is the name of the default runner for packages. @@ -128,10 +126,9 @@ func NewOutput(mainBranch string, withDefaultJob bool, withStaleJob bool, slackC }, Jobs: map[string]*Job{ "slack-notify": { - RunsOn: []string{ - HostedRunner, - GenericRunner, - }, + RunsOn: RunsOn{value: RunsOnGroupLabel{ + Group: GenericRunner, + }}, If: "github.event.workflow_run.conclusion != 'skipped'", Steps: []*JobStep{ Step("Get PR number"). @@ -159,10 +156,9 @@ func NewOutput(mainBranch string, withDefaultJob bool, withStaleJob bool, slackC }, Jobs: map[string]*Job{ "slack-notify": { - RunsOn: []string{ - HostedRunner, - GenericRunner, - }, + RunsOn: RunsOn{value: RunsOnGroupLabel{ + Group: GenericRunner, + }}, If: "github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request'", Steps: []*JobStep{ Step("Slack Notify"). @@ -191,7 +187,7 @@ func NewOutput(mainBranch string, withDefaultJob bool, withStaleJob bool, slackC }, Jobs: map[string]*Job{ "action": { - RunsOn: []string{"ubuntu-latest"}, + RunsOn: RunsOn{[]string{"ubuntu-latest"}}, Steps: []*JobStep{ { Name: "Lock old issues", @@ -222,7 +218,7 @@ func NewOutput(mainBranch string, withDefaultJob bool, withStaleJob bool, slackC }, Jobs: map[string]*Job{ "stale": { - RunsOn: []string{"ubuntu-latest"}, + RunsOn: RunsOn{[]string{"ubuntu-latest"}}, Steps: []*JobStep{ { Name: "Close stale issues and PRs", @@ -345,25 +341,26 @@ func (o *Output) AddSlackNotifyForFailure(workflow string) { o.workflows[SlackCIFailureWorkflow].Workflows = append(o.workflows[SlackCIFailureWorkflow].Workflows, workflow) } -// SetRunners allows to set custom runners for the default job. -// If runners are not provided, the default runners will be used. -func (o *Output) SetRunners(runners ...string) { - if len(runners) == 0 { - o.workflows[CiWorkflow].Jobs["default"].RunsOn = []string{ - HostedRunner, - GenericRunner, - } +// SetRunnerGroup allows to set custom runners for the default job. +// If runner is empty, it will be set to "generic". +func (o *Output) SetRunnerGroup(runner string) { + if runner == "" { + o.workflows[CiWorkflow].Jobs["default"].RunsOn = RunsOn{value: RunsOnGroupLabel{ + Group: GenericRunner, + }} return } - o.workflows[CiWorkflow].Jobs["default"].RunsOn = runners + o.workflows[CiWorkflow].Jobs["default"].RunsOn = RunsOn{value: RunsOnGroupLabel{ + Group: runner, + }} } // SetOptionsForPkgs overwrites default job steps and services for pkgs. // Note that calling this method will overwrite any existing steps. func (o *Output) SetOptionsForPkgs() { - o.SetRunners(HostedRunner, PkgsRunner) + o.SetRunnerGroup(PkgsRunner) o.workflows[CiWorkflow].Jobs["default"].Steps = DefaultPkgsSteps() } diff --git a/internal/output/ghworkflow/gh_workflow_test.go b/internal/output/ghworkflow/gh_workflow_test.go index 76ebe12..98c9566 100644 --- a/internal/output/ghworkflow/gh_workflow_test.go +++ b/internal/output/ghworkflow/gh_workflow_test.go @@ -39,6 +39,7 @@ func (suite *GHWorkflowSuite) TestDefaultWorkflows() { ) output := ghworkflow.NewOutput(defaultBranch, withDefaultJob, withStaleJob, customSlackChannel) //nolint:typecheck + output.SetRunnerGroup("generic") var buf bytes.Buffer @@ -72,7 +73,8 @@ jobs: issues: read packages: write pull-requests: read - runs-on: [] + runs-on: + group: generic if: (!startsWith(github.head_ref, 'renovate/') && !startsWith(github.head_ref, 'dependabot/')) steps: - name: gather-system-info diff --git a/internal/output/ghworkflow/types.go b/internal/output/ghworkflow/types.go index 1c9fb36..30a8a4c 100644 --- a/internal/output/ghworkflow/types.go +++ b/internal/output/ghworkflow/types.go @@ -4,6 +4,8 @@ package ghworkflow +import "fmt" + // Workflow represents Github Actions workflow. // //nolint:govet @@ -68,7 +70,7 @@ type Push struct { // Job represents GitHub Actions job. type Job struct { Permissions map[string]string `yaml:"permissions,omitempty"` - RunsOn []string `yaml:"runs-on"` + RunsOn RunsOn `yaml:"runs-on"` If string `yaml:"if,omitempty"` Needs []string `yaml:"needs,omitempty"` Outputs map[string]string `yaml:"outputs,omitempty"` @@ -76,6 +78,88 @@ type Job struct { Steps []*JobStep `yaml:"steps"` } +// RunsOn represents GitHub Actions runs-on field which can be a string, slice, or type with Group/Label structure. +type RunsOn struct { + value any +} + +type RunsOnGroupLabel struct { + Group string `yaml:"group,omitempty"` + Label string `yaml:"label,omitempty"` +} + +// MarshalYAML implements yaml.Marshaler. +func (r RunsOn) MarshalYAML() (any, error) { + // check if r.value is nil or empty + if r.value == nil { + return nil, fmt.Errorf("runs-on needs to be set") + } + + // check for empty values based on type + switch v := r.value.(type) { + case string: + if v == "" { + return nil, fmt.Errorf("runs-on cannot be empty string") + } + case []string: + if len(v) == 0 { + return nil, fmt.Errorf("runs-on cannot be empty slice") + } + case RunsOnGroupLabel: + if v.Group == "" && v.Label == "" { + return nil, fmt.Errorf("runs-on needs to be set with at least group or label") + } + default: + return nil, fmt.Errorf("runs-on must be a string, slice of strings, or type with group/label, got: %T", r.value) + } + + return r.value, nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (r *RunsOn) UnmarshalYAML(unmarshal func(any) error) error { + // Try string first + var str string + if err := unmarshal(&str); err == nil { + r.value = str + + return nil + } + + // Try slice of strings + var slice []string + if err := unmarshal(&slice); err == nil { + r.value = slice + + return nil + } + + // Try group/label map + var groupLabel RunsOnGroupLabel + if err := unmarshal(&groupLabel); err == nil { + r.value = groupLabel + + return nil + } + + return fmt.Errorf("runs-on must be a string, slice of strings, or map with group/label, got: %T", r.value) +} + +// NewRunsOnString creates a RunsOn from a string. +func NewRunsOnString(runner string) RunsOn { + return RunsOn{value: runner} +} + +// NewRunsOnSlice creates a RunsOn from a slice of strings. +func NewRunsOnSlice(runners []string) RunsOn { + return RunsOn{value: runners} +} + +// NewRunsOnGroupLabel creates a RunsOn from a group and label. +func NewRunsOnGroupLabel(group, label string) RunsOn { + return RunsOn{value: RunsOnGroupLabel{Group: group, Label: label}} +} + // Service represents GitHub Actions service. type Service struct { Image string `yaml:"image"` diff --git a/internal/project/common/gh_workflow.go b/internal/project/common/gh_workflow.go index f3654a8..0ca393d 100644 --- a/internal/project/common/gh_workflow.go +++ b/internal/project/common/gh_workflow.go @@ -24,7 +24,7 @@ type Job struct { Conditions []string `yaml:"conditions,omitempty"` Crons []string `yaml:"crons,omitempty"` Depends []string `yaml:"depends,omitempty"` - Runners []string `yaml:"runners,omitempty"` + RunnerGroup string `yaml:"runnerGroup,omitempty"` TriggerLabels []string `yaml:"triggerLabels,omitempty"` Steps []Step `yaml:"steps,omitempty"` SOPS bool `yaml:"sops"` @@ -95,9 +95,9 @@ type RegistryLoginStep struct { type GHWorkflow struct { meta *meta.Options dag.BaseNode - CIFailuresSlackNotifyChannel string `yaml:"ciFailuresSlackNotifyChannel,omitempty"` - CustomRunners []string `yaml:"customRunners,omitempty"` - Jobs []Job `yaml:"jobs"` + CIFailuresSlackNotifyChannel string `yaml:"ciFailuresSlackNotifyChannel,omitempty"` + CustomRunnerGroup string `yaml:"customRunnerGroup,omitempty"` + Jobs []Job `yaml:"jobs"` } // NewGHWorkflow creates a new GHWorkflow node. @@ -114,7 +114,7 @@ func NewGHWorkflow(meta *meta.Options) *GHWorkflow { //nolint:gocognit,gocyclo,cyclop,maintidx func (gh *GHWorkflow) CompileGitHubWorkflow(o *ghworkflow.Output) error { if !gh.meta.CompileGithubWorkflowsOnly { - o.SetRunners(gh.CustomRunners...) + o.SetRunnerGroup(gh.CustomRunnerGroup) return nil } @@ -124,7 +124,7 @@ func (gh *GHWorkflow) CompileGitHubWorkflow(o *ghworkflow.Output) error { for _, job := range gh.Jobs { jobDef := &ghworkflow.Job{ If: ghworkflow.DefaultSkipCondition, - RunsOn: job.Runners, + RunsOn: ghworkflow.NewRunsOnGroupLabel(job.RunnerGroup, ""), Permissions: ghworkflow.DefaultJobPermissions(), Needs: job.Depends, Steps: ghworkflow.CommonSteps(), @@ -443,32 +443,7 @@ func (gh *GHWorkflow) CompileGitHubWorkflow(o *ghworkflow.Output) error { }, Jobs: map[string]*ghworkflow.Job{ "default": { - RunsOn: job.Runners, - Steps: jobDef.Steps, - }, - }, - }, - ) - - o.AddWorkflow( - workflowName, - &ghworkflow.Workflow{ - Name: workflowName, - // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#example-using-a-fallback-value - Concurrency: ghworkflow.Concurrency{ - Group: "${{ github.head_ref || github.run_id }}", - CancelInProgress: true, - }, - On: ghworkflow.On{ - Schedule: xslices.Map(job.Crons, func(cron string) ghworkflow.Schedule { - return ghworkflow.Schedule{ - Cron: cron, - } - }), - }, - Jobs: map[string]*ghworkflow.Job{ - "default": { - RunsOn: job.Runners, + RunsOn: ghworkflow.NewRunsOnGroupLabel(job.RunnerGroup, ""), Services: jobDef.Services, Steps: jobDef.Steps, }, diff --git a/internal/project/custom/custom.go b/internal/project/custom/custom.go index 6903529..de8c401 100644 --- a/internal/project/custom/custom.go +++ b/internal/project/custom/custom.go @@ -94,7 +94,7 @@ type Step struct { Name string `yaml:"name"` EnvironmentOverride map[string]string `yaml:"environmentOverride"` Crons []string `yaml:"crons"` - RunnerLabels []string `yaml:"runnerLabels"` + RunnerGroup string `yaml:"runnerGroup"` TriggerLabels []string `yaml:"triggerLabels"` Artifacts Artifacts `yaml:"artifacts"` } `yaml:"jobs"` @@ -401,10 +401,6 @@ func (step *Step) CompileGitHubWorkflow(output *ghworkflow.Output) error { steps..., ) - runnerLabels := []string{ - ghworkflow.HostedRunner, - } - for _, job := range step.GHAction.Jobs { conditions := xslices.Map(job.TriggerLabels, func(label string) string { return fmt.Sprintf("contains(fromJSON(needs.default.outputs.labels), '%s')", label) @@ -475,7 +471,7 @@ func (step *Step) CompileGitHubWorkflow(output *ghworkflow.Output) error { } output.AddJob(job.Name, &ghworkflow.Job{ - RunsOn: append(runnerLabels, job.RunnerLabels...), + RunsOn: ghworkflow.NewRunsOnGroupLabel(job.RunnerGroup, ""), If: strings.Join(conditions, " || "), Needs: []string{"default"}, Steps: defaultSteps, @@ -553,7 +549,7 @@ func (step *Step) CompileGitHubWorkflow(output *ghworkflow.Output) error { }, Jobs: map[string]*ghworkflow.Job{ "default": { - RunsOn: append(runnerLabels, job.RunnerLabels...), + RunsOn: ghworkflow.NewRunsOnGroupLabel(job.RunnerGroup, ""), Steps: append( defaultSteps, steps..., diff --git a/internal/project/helm/build.go b/internal/project/helm/build.go index 67e1304..b94ad43 100644 --- a/internal/project/helm/build.go +++ b/internal/project/helm/build.go @@ -134,10 +134,7 @@ func (helm *Build) CompileGitHubWorkflow(output *ghworkflow.Output) error { Jobs: map[string]*ghworkflow.Job{ "default": { Permissions: jobPermissions, - RunsOn: []string{ - ghworkflow.HostedRunner, - ghworkflow.GenericRunner, - }, + RunsOn: ghworkflow.NewRunsOnGroupLabel(ghworkflow.GenericRunner, ""), Steps: slices.Concat( ghworkflow.CommonSteps(), []*ghworkflow.JobStep{ diff --git a/internal/project/pkgfile/build.go b/internal/project/pkgfile/build.go index 3aeeea4..e74e43b 100644 --- a/internal/project/pkgfile/build.go +++ b/internal/project/pkgfile/build.go @@ -261,13 +261,8 @@ func (pkgfile *Build) CompileGitHubWorkflow(output *ghworkflow.Output) error { "labels": "${{ steps.retrieve-pr-labels.outputs.result }}", }) - runnerLabels := []string{ - ghworkflow.HostedRunner, - ghworkflow.PkgsRunner, - } - output.AddJob("reproducibility", &ghworkflow.Job{ - RunsOn: runnerLabels, + RunsOn: ghworkflow.NewRunsOnGroupLabel(ghworkflow.PkgsRunner, ""), If: "contains(fromJSON(needs.default.outputs.labels), 'integration/reproducibility')", Needs: []string{"default"}, Steps: ghworkflow.DefaultPkgsSteps(), @@ -293,7 +288,7 @@ func (pkgfile *Build) CompileGitHubWorkflow(output *ghworkflow.Output) error { }, Jobs: map[string]*ghworkflow.Job{ "reproducibility": { - RunsOn: runnerLabels, + RunsOn: ghworkflow.NewRunsOnGroupLabel(ghworkflow.PkgsRunner, ""), Steps: append( ghworkflow.DefaultPkgsSteps(), ghworkflow.Step("reproducibility-test").SetMakeStep("reproducibility-test"),