Skip to content

Commit 6ce0259

Browse files
authored
Document how to multi-label on GHES (#98)
* Document how to multi-label on GHES * Ensure labels are always provided
1 parent 72647ae commit 6ce0259

3 files changed

Lines changed: 112 additions & 2 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ You do *not* need to adopt the full controller (and Kubernetes) to take advantag
1212

1313
A runner scale set is a group of self-hosted runners that autoscales based on workflow demand. Here's how it works:
1414

15-
1. **Registration**: You create a scale set with a name, which also serves as the label workflows use to target it (e.g., `runs-on: my-scale-set`). Multiple labels can be assigned per scale set. Like regular self-hosted runners, scale sets can be registered at the repository, organization, or enterprise level.
15+
1. **Registration**: You create a scale set with a name, which also serves as the label workflows use to target it (e.g., `runs-on: my-scale-set`). Multiple labels can be assigned per scale set (on GHES, this requires enabling a feature flag — see [GitHub Enterprise Server](#github-enterprise-server)). Like regular self-hosted runners, scale sets can be registered at the repository, organization, or enterprise level.
1616
2. **Polling**: Your scale set client continuously polls the API, reporting its maximum capacity (how many runners it can produce).
1717
3. **Job matching**: GitHub matches jobs to your scale set based on the label and runner group policies, just like regular self-hosted runners.
1818
4. **Scaling signal**: The API responds with how many runners your scale set needs online (`statistics.TotalAssignedJobs`).
@@ -153,6 +153,23 @@ You can find more details on required permissions in the [GitHub Docs](https://d
153153

154154
GitHub Enterprise Server (GHES) is supported out of the box—just use your GHES URL when creating the client.
155155

156+
### GitHub Enterprise Server
157+
158+
#### Multiple labels per scale set
159+
160+
Assigning more than one label to a scale set is supported on **GHES 3.18 and later** and requires the `DistributedTask.AllowRunnerScaleSetCustomLabels` feature flag to be enabled on the appliance. Without it, the scale set name is used as the only label, and any additional labels you provide are silently dropped.
161+
162+
- **GHES 3.18 – 3.20:** the flag is **off by default**. A site admin needs to enable it on the appliance:
163+
164+
```bash
165+
# SSH into the GHES appliance as admin
166+
ghe-actions-console -s actions
167+
# In the LightRail prompt:
168+
Set-FeatureFlag -FeatureName DistributedTask.AllowRunnerScaleSetCustomLabels -State On
169+
```
170+
171+
- **GHES 3.21 and later:** the flag is **on by default**, no action required.
172+
156173
---
157174

158175
## Security Notes

client.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"bytes"
66
"context"
77
"encoding/json"
8+
"errors"
89
"fmt"
910
"io"
1011
"maps"
@@ -388,11 +389,27 @@ func applyDefaultLabelTypes(runnerScaleSet *RunnerScaleSet) {
388389
}
389390
}
390391

392+
func ensureLabels(runnerScaleSet *RunnerScaleSet) error {
393+
if len(runnerScaleSet.Labels) > 0 {
394+
return nil
395+
}
396+
397+
if runnerScaleSet.Name == "" {
398+
return errors.New("runner scale set must have a name or at least one label")
399+
}
400+
401+
runnerScaleSet.Labels = []Label{{Name: runnerScaleSet.Name, Type: "System"}}
402+
return nil
403+
}
404+
391405
// CreateRunnerScaleSet creates a new runner scale set. Note that runner scale set names must be unique within a runner group.
392406
func (c *Client) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) {
393407
c.mu.Lock()
394408
defer c.mu.Unlock()
395409

410+
if err := ensureLabels(runnerScaleSet); err != nil {
411+
return nil, fmt.Errorf("validating runner scale set: %w", err)
412+
}
396413
applyDefaultLabelTypes(runnerScaleSet)
397414

398415
body, err := json.Marshal(runnerScaleSet)

client_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,13 @@ func TestCreateRunnerScaleSet(t *testing.T) {
829829
}
830830

831831
scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
832-
runnerScaleSet := RunnerScaleSet{ID: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: RunnerSetting{}}
832+
runnerScaleSet := RunnerScaleSet{
833+
ID: 1,
834+
Name: "ScaleSet",
835+
Labels: []Label{{Name: "ScaleSet", Type: "System"}},
836+
CreatedOn: scaleSetCreationDateTime,
837+
RunnerSetting: RunnerSetting{},
838+
}
833839

834840
t.Run("Create runner scale set", func(t *testing.T) {
835841
want := &runnerScaleSet
@@ -920,6 +926,76 @@ func TestCreateRunnerScaleSet(t *testing.T) {
920926
expectedRetry := retryMax + 1
921927
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
922928
})
929+
930+
t.Run("populates labels with the scale set name when none are provided", func(t *testing.T) {
931+
var sentBody RunnerScaleSet
932+
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
933+
require.NoError(t, json.NewDecoder(r.Body).Decode(&sentBody))
934+
rsl, err := json.Marshal(&runnerScaleSet)
935+
require.NoError(t, err)
936+
w.Write(rsl)
937+
}))
938+
939+
client, err := newClient(
940+
testSystemInfo,
941+
server.configURLForOrg("my-org"),
942+
auth,
943+
)
944+
require.NoError(t, err)
945+
946+
input := &RunnerScaleSet{Name: "my-scale-set"}
947+
_, err = client.CreateRunnerScaleSet(ctx, input)
948+
require.NoError(t, err)
949+
950+
expectedLabels := []Label{{Name: "my-scale-set", Type: "System"}}
951+
assert.Equal(t, expectedLabels, sentBody.Labels, "expected the request body to default labels to the scale set name")
952+
assert.Equal(t, expectedLabels, input.Labels, "expected the input to be populated with the default label")
953+
})
954+
955+
t.Run("returns an error when both name and labels are empty", func(t *testing.T) {
956+
called := false
957+
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
958+
called = true
959+
}))
960+
961+
client, err := newClient(
962+
testSystemInfo,
963+
server.configURLForOrg("my-org"),
964+
auth,
965+
)
966+
require.NoError(t, err)
967+
968+
_, err = client.CreateRunnerScaleSet(ctx, &RunnerScaleSet{})
969+
require.Error(t, err)
970+
assert.False(t, called, "expected no request to be sent when validation fails")
971+
})
972+
973+
t.Run("preserves caller-provided labels", func(t *testing.T) {
974+
var sentBody RunnerScaleSet
975+
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
976+
require.NoError(t, json.NewDecoder(r.Body).Decode(&sentBody))
977+
rsl, err := json.Marshal(&runnerScaleSet)
978+
require.NoError(t, err)
979+
w.Write(rsl)
980+
}))
981+
982+
client, err := newClient(
983+
testSystemInfo,
984+
server.configURLForOrg("my-org"),
985+
auth,
986+
)
987+
require.NoError(t, err)
988+
989+
input := &RunnerScaleSet{
990+
Name: "my-scale-set",
991+
Labels: []Label{{Name: "linux"}, {Name: "x64"}},
992+
}
993+
_, err = client.CreateRunnerScaleSet(ctx, input)
994+
require.NoError(t, err)
995+
996+
expectedLabels := []Label{{Name: "linux", Type: "System"}, {Name: "x64", Type: "System"}}
997+
assert.Equal(t, expectedLabels, sentBody.Labels, "expected caller-provided labels to be preserved")
998+
})
923999
}
9241000

9251001
func TestUpdateRunnerScaleSet(t *testing.T) {

0 commit comments

Comments
 (0)