Skip to content

Commit 29d0658

Browse files
Jason Barnettclaude
authored andcommitted
feat: add support for secrets field in step configuration
Add the `secrets` field to the Step struct, allowing users to specify Buildkite secrets that should be injected into step environments. Supports both formats as documented by Buildkite: - Array format: ["API_ACCESS_TOKEN", "DATABASE_PASSWORD"] - Map format: {"MY_API_KEY": "API_ACCESS_TOKEN"} Changes: - Add Secrets field as interface{} to support both formats - Add tests for secrets parsing (array and map formats) - Add tests for secrets in nested steps within groups - Add pipeline generation tests for both formats - Add documentation with examples in README.md Closes #113 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 81d3bb3 commit 29d0658

4 files changed

Lines changed: 307 additions & 0 deletions

File tree

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,67 @@ steps:
342342
command: "echo deploy-bar"
343343
```
344344

345+
### `secrets` (optional)
346+
347+
Add `secrets` to inject [Buildkite Secrets](https://buildkite.com/docs/pipelines/security/secrets/buildkite-secrets) into your command steps. Secrets can be specified in two formats:
348+
349+
**Array format** - secret names are used as environment variable names:
350+
351+
```yaml
352+
steps:
353+
- label: "Deploy with secrets"
354+
plugins:
355+
- monorepo-diff#v1.6.2:
356+
diff: "git diff --name-only HEAD~1"
357+
watch:
358+
- path: "service/"
359+
config:
360+
command: "deploy.sh"
361+
secrets:
362+
- API_ACCESS_TOKEN
363+
- DATABASE_PASSWORD
364+
```
365+
366+
**Map format** - specify custom environment variable names:
367+
368+
```yaml
369+
steps:
370+
- label: "Deploy with secrets"
371+
plugins:
372+
- monorepo-diff#v1.6.2:
373+
diff: "git diff --name-only HEAD~1"
374+
watch:
375+
- path: "service/"
376+
config:
377+
command: "deploy.sh"
378+
secrets:
379+
MY_API_KEY: api_access_token_secret
380+
DB_PASS: database_password_secret
381+
```
382+
383+
Secrets also work within grouped steps:
384+
385+
```yaml
386+
steps:
387+
- label: "Deploy services"
388+
plugins:
389+
- monorepo-diff#v1.6.2:
390+
diff: "git diff --name-only HEAD~1"
391+
watch:
392+
- path: "services/"
393+
config:
394+
group: "Deploy"
395+
steps:
396+
- command: "deploy-uat.sh"
397+
label: "Deploy UAT"
398+
secrets:
399+
- UAT_DB_HOST
400+
- command: "deploy-prod.sh"
401+
label: "Deploy Prod"
402+
secrets:
403+
DB_HOST: prod_db_host_secret
404+
```
405+
345406
## Example
346407

347408
```yaml

pipeline_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,114 @@ func TestGeneratePipelineWithStepKey(t *testing.T) {
714714

715715
assert.Equal(t, want, string(got))
716716
}
717+
718+
func TestGeneratePipelineWithSecretsAsMap(t *testing.T) {
719+
steps := []Step{
720+
{
721+
Command: "echo deploy",
722+
Label: "Deploy",
723+
Secrets: map[string]interface{}{
724+
"DATABRICKS_HOST": "databricks_host_secret",
725+
"DATABRICKS_TOKEN": "databricks_token_secret",
726+
},
727+
},
728+
}
729+
730+
plugin := Plugin{Wait: false}
731+
732+
pipeline, _, err := generatePipeline(steps, plugin)
733+
require.NoError(t, err)
734+
defer func() {
735+
if err = os.Remove(pipeline.Name()); err != nil {
736+
t.Logf("Failed to remove temporary pipeline file: %v", err)
737+
}
738+
}()
739+
740+
got, err := os.ReadFile(pipeline.Name())
741+
require.NoError(t, err)
742+
743+
// Check that the output contains the expected secrets (order may vary in maps)
744+
assert.Contains(t, string(got), "label: Deploy")
745+
assert.Contains(t, string(got), "command: echo deploy")
746+
assert.Contains(t, string(got), "secrets:")
747+
assert.Contains(t, string(got), "DATABRICKS_HOST: databricks_host_secret")
748+
assert.Contains(t, string(got), "DATABRICKS_TOKEN: databricks_token_secret")
749+
}
750+
751+
func TestGeneratePipelineWithSecretsAsArray(t *testing.T) {
752+
steps := []Step{
753+
{
754+
Command: "echo deploy",
755+
Label: "Deploy",
756+
Secrets: []interface{}{"API_ACCESS_TOKEN", "DATABASE_PASSWORD"},
757+
},
758+
}
759+
760+
want := `steps:
761+
- label: Deploy
762+
command: echo deploy
763+
secrets:
764+
- API_ACCESS_TOKEN
765+
- DATABASE_PASSWORD
766+
`
767+
768+
plugin := Plugin{Wait: false}
769+
770+
pipeline, _, err := generatePipeline(steps, plugin)
771+
require.NoError(t, err)
772+
defer func() {
773+
if err = os.Remove(pipeline.Name()); err != nil {
774+
t.Logf("Failed to remove temporary pipeline file: %v", err)
775+
}
776+
}()
777+
778+
got, err := os.ReadFile(pipeline.Name())
779+
require.NoError(t, err)
780+
781+
assert.Equal(t, want, string(got))
782+
}
783+
784+
func TestGeneratePipelineWithSecretsInGroup(t *testing.T) {
785+
steps := []Step{
786+
{
787+
Group: "deploy group",
788+
Steps: []Step{
789+
{
790+
Command: "echo deploy uat",
791+
Label: "Deploy UAT",
792+
Secrets: map[string]interface{}{
793+
"DB_HOST": "uat_db_host",
794+
},
795+
},
796+
{
797+
Command: "echo deploy prod",
798+
Label: "Deploy Prod",
799+
Secrets: []interface{}{"PROD_DB_HOST", "PROD_DB_PASS"},
800+
},
801+
},
802+
},
803+
}
804+
805+
plugin := Plugin{Wait: false}
806+
807+
pipeline, _, err := generatePipeline(steps, plugin)
808+
require.NoError(t, err)
809+
defer func() {
810+
if err = os.Remove(pipeline.Name()); err != nil {
811+
t.Logf("Failed to remove temporary pipeline file: %v", err)
812+
}
813+
}()
814+
815+
got, err := os.ReadFile(pipeline.Name())
816+
require.NoError(t, err)
817+
818+
// Check structure and content (map order may vary)
819+
assert.Contains(t, string(got), "group: deploy group")
820+
assert.Contains(t, string(got), "label: Deploy UAT")
821+
assert.Contains(t, string(got), "command: echo deploy uat")
822+
assert.Contains(t, string(got), "DB_HOST: uat_db_host")
823+
assert.Contains(t, string(got), "label: Deploy Prod")
824+
assert.Contains(t, string(got), "command: echo deploy prod")
825+
assert.Contains(t, string(got), "- PROD_DB_HOST")
826+
assert.Contains(t, string(got), "- PROD_DB_PASS")
827+
}

plugin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type Step struct {
9595
Notify []StepNotify `yaml:"notify,omitempty"`
9696
DependsOn interface{} `json:"depends_on" yaml:"depends_on,omitempty"`
9797
Key string `yaml:"key,omitempty"`
98+
Secrets interface{} `json:"secrets,omitempty" yaml:"secrets,omitempty"`
9899
Steps []Step `yaml:"steps,omitempty"`
99100
}
100101

plugin_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,3 +1087,137 @@ func TestPluginEnvWithEqualsSignsAndSpacesInValues(t *testing.T) {
10871087
assert.Equal(t, "value with spaces", got.Env["SPACE_VALUE"])
10881088
assert.Equal(t, "\"--opt1=val1 --opt2=val2\"", got.Env["COMPLEX"])
10891089
}
1090+
1091+
func TestPluginShouldPreserveSecretsAsMap(t *testing.T) {
1092+
param := `[{
1093+
"github.com/buildkite-plugins/monorepo-diff-buildkite-plugin#commit": {
1094+
"watch": [
1095+
{
1096+
"path": "service/**/*",
1097+
"config": {
1098+
"command": "echo deploy",
1099+
"secrets": {
1100+
"DATABRICKS_HOST": "databricks_host_secret",
1101+
"DATABRICKS_TOKEN": "databricks_token_secret"
1102+
}
1103+
}
1104+
}
1105+
]
1106+
}
1107+
}]`
1108+
1109+
got, err := initializePlugin(param)
1110+
assert.NoError(t, err)
1111+
1112+
expected := Plugin{
1113+
Diff: "git diff --name-only HEAD~1",
1114+
Wait: false,
1115+
LogLevel: "info",
1116+
Interpolation: true,
1117+
Watch: []WatchConfig{
1118+
{
1119+
Paths: []string{"service/**/*"},
1120+
Step: Step{
1121+
Command: "echo deploy",
1122+
Secrets: map[string]interface{}{
1123+
"DATABRICKS_HOST": "databricks_host_secret",
1124+
"DATABRICKS_TOKEN": "databricks_token_secret",
1125+
},
1126+
},
1127+
},
1128+
},
1129+
}
1130+
1131+
if diff := cmp.Diff(expected, got); diff != "" {
1132+
t.Fatalf("plugin diff (-want +got):\n%s", diff)
1133+
}
1134+
}
1135+
1136+
func TestPluginShouldPreserveSecretsAsArray(t *testing.T) {
1137+
param := `[{
1138+
"github.com/buildkite-plugins/monorepo-diff-buildkite-plugin#commit": {
1139+
"watch": [
1140+
{
1141+
"path": "service/**/*",
1142+
"config": {
1143+
"command": "echo deploy",
1144+
"secrets": ["API_ACCESS_TOKEN", "DATABASE_PASSWORD"]
1145+
}
1146+
}
1147+
]
1148+
}
1149+
}]`
1150+
1151+
got, err := initializePlugin(param)
1152+
assert.NoError(t, err)
1153+
1154+
expected := Plugin{
1155+
Diff: "git diff --name-only HEAD~1",
1156+
Wait: false,
1157+
LogLevel: "info",
1158+
Interpolation: true,
1159+
Watch: []WatchConfig{
1160+
{
1161+
Paths: []string{"service/**/*"},
1162+
Step: Step{
1163+
Command: "echo deploy",
1164+
Secrets: []interface{}{"API_ACCESS_TOKEN", "DATABASE_PASSWORD"},
1165+
},
1166+
},
1167+
},
1168+
}
1169+
1170+
if diff := cmp.Diff(expected, got); diff != "" {
1171+
t.Fatalf("plugin diff (-want +got):\n%s", diff)
1172+
}
1173+
}
1174+
1175+
func TestPluginShouldPreserveSecretsInNestedSteps(t *testing.T) {
1176+
param := `[{
1177+
"github.com/buildkite-plugins/monorepo-diff-buildkite-plugin#commit": {
1178+
"watch": [
1179+
{
1180+
"path": "service/**/*",
1181+
"config": {
1182+
"group": "deploy group",
1183+
"steps": [
1184+
{
1185+
"command": "echo deploy uat",
1186+
"label": "Deploy UAT",
1187+
"secrets": {
1188+
"DB_HOST": "uat_db_host"
1189+
}
1190+
},
1191+
{
1192+
"command": "echo deploy prod",
1193+
"label": "Deploy Prod",
1194+
"secrets": ["PROD_DB_HOST", "PROD_DB_PASS"]
1195+
}
1196+
]
1197+
}
1198+
}
1199+
]
1200+
}
1201+
}]`
1202+
1203+
got, err := initializePlugin(param)
1204+
assert.NoError(t, err)
1205+
1206+
assert.Equal(t, 1, len(got.Watch))
1207+
assert.Equal(t, "deploy group", got.Watch[0].Step.Group)
1208+
assert.Equal(t, 2, len(got.Watch[0].Step.Steps))
1209+
1210+
// Verify first nested step has secrets as map
1211+
firstStep := got.Watch[0].Step.Steps[0]
1212+
assert.Equal(t, "echo deploy uat", firstStep.Command)
1213+
secretsMap, ok := firstStep.Secrets.(map[string]interface{})
1214+
assert.True(t, ok, "first step secrets should be a map")
1215+
assert.Equal(t, "uat_db_host", secretsMap["DB_HOST"])
1216+
1217+
// Verify second nested step has secrets as array
1218+
secondStep := got.Watch[0].Step.Steps[1]
1219+
assert.Equal(t, "echo deploy prod", secondStep.Command)
1220+
secretsArray, ok := secondStep.Secrets.([]interface{})
1221+
assert.True(t, ok, "second step secrets should be an array")
1222+
assert.Equal(t, []interface{}{"PROD_DB_HOST", "PROD_DB_PASS"}, secretsArray)
1223+
}

0 commit comments

Comments
 (0)