fix(templates): harden component CI steps and compile regexes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- Add --connect-timeout 10 and --max-time 15 to all verify step curl
  calls to prevent hanging on registry health checks
- Fix cli template: depends_on [deps] -> [preflight] for consistency
- Add cross-reference comment to service template about verify logic
  being replicated across all 5 component templates
- Document component CI step rules in composable-monorepo.md
- Compile regexes at package level instead of per-call in
  component_updates.go
- Add component_updates_test.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-09 19:36:23 -07:00
parent d63f827713
commit 9f957d6e75
9 changed files with 304 additions and 5 deletions

View File

@ -118,6 +118,13 @@ internal/adapter/templates/templates/components/
Each component includes `.woodpecker.step.yml.tmpl` that defines its Kaniko build step.
When components are added, the service renders this template and inserts it into the main pipeline.
**Component CI Step Rules:**
- ALL component build steps MUST use `depends_on: [preflight]` (not `[deps]`) — this ensures registry health is verified before any image build
- Components with Docker images (service, app-*, worker) MUST have a 3-step chain: `build``verify` (registry check) → `deploy` with explicit `depends_on` between each
- Build steps MUST NOT use `failure: ignore` — build failures should be visible, not swallowed
- The `verify` step uses `failure: ignore` so deploy is skipped (not failed) when the image doesn't exist
- `updateWoodpeckerYml` automatically wires each new `deploy-{name}` step into `build-complete`'s `depends_on` via the `# BUILD_COMPLETE_DEPS` marker
**Component Variables:**
```go
{{.ProjectName}} // "acme"

View File

@ -30,6 +30,8 @@ verify-{{COMPONENT_NAME}}:
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
--connect-timeout 10 \
--max-time 15 \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then

View File

@ -30,6 +30,8 @@ verify-{{COMPONENT_NAME}}:
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
--connect-timeout 10 \
--max-time 15 \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then

View File

@ -30,6 +30,8 @@ verify-{{COMPONENT_NAME}}:
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
--connect-timeout 10 \
--max-time 15 \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then

View File

@ -5,7 +5,7 @@
# This step builds and tests the CLI.
build-{{COMPONENT_NAME}}:
depends_on: [deps]
depends_on: [preflight]
image: golang:1.25-alpine
commands:
- cd cli/{{COMPONENT_NAME}}

View File

@ -1,5 +1,7 @@
# Woodpecker CI step for {{COMPONENT_NAME}} service
# Add this step to your .woodpecker.yml
# NOTE: verify step is replicated in all component templates (service, app-react,
# app-astro, app-nextjs, worker). Update all 5 if changing the verify logic.
build-{{COMPONENT_NAME}}:
depends_on: [preflight]
@ -30,6 +32,8 @@ verify-{{COMPONENT_NAME}}:
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
--connect-timeout 10 \
--max-time 15 \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then

View File

@ -30,6 +30,8 @@ verify-{{COMPONENT_NAME}}:
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
--connect-timeout 10 \
--max-time 15 \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then

View File

@ -9,6 +9,11 @@ import (
"github.com/orchard9/rdev/internal/logging"
)
var (
reDeployStep = regexp.MustCompile(`(?m)^deploy-([a-zA-Z0-9_-]+):`)
reDependsOn = regexp.MustCompile(`depends_on:\s*\[([^\]]*)\]`)
)
// updateProcfile adds an entry for the component.
func (s *ComponentService) updateProcfile(existing string, componentType domain.ComponentType, componentName, componentPath string, _ int) string {
var entry string
@ -99,8 +104,7 @@ func (s *ComponentService) updateWoodpeckerYml(existing, stepYaml string) string
// extractDeployStepName finds the deploy-{name}: key in a step YAML block.
func extractDeployStepName(stepYaml string) string {
re := regexp.MustCompile(`(?m)^deploy-([a-zA-Z0-9_-]+):`)
match := re.FindStringSubmatch(stepYaml)
match := reDeployStep.FindStringSubmatch(stepYaml)
if len(match) >= 2 {
return "deploy-" + match[1]
}
@ -120,8 +124,7 @@ func addBuildCompleteDep(yml, stepName string) string {
// Parse existing depends_on array from the line
// Format: " depends_on: [preflight, deploy-foo] # BUILD_COMPLETE_DEPS"
re := regexp.MustCompile(`depends_on:\s*\[([^\]]*)\]`)
match := re.FindStringSubmatch(line)
match := reDependsOn.FindStringSubmatch(line)
if len(match) < 2 {
break
}

View File

@ -0,0 +1,277 @@
package service
import (
"strings"
"testing"
)
func TestExtractDeployStepName(t *testing.T) {
tests := []struct {
name string
stepYaml string
want string
}{
{
name: "service template with deploy step",
stepYaml: `build-studio-api:
depends_on: [preflight]
image: woodpeckerci/plugin-kaniko
deploy-studio-api:
depends_on: [verify-studio-api]
image: bitnami/kubectl:latest`,
want: "deploy-studio-api",
},
{
name: "worker template with deploy step",
stepYaml: `build-processor:
depends_on: [preflight]
deploy-processor:
image: bitnami/kubectl:latest`,
want: "deploy-processor",
},
{
name: "CLI template without deploy step",
stepYaml: `build-mytool:
depends_on: [deps]
image: golang:1.25-alpine
commands:
- go build ./cmd`,
want: "",
},
{
name: "empty input",
stepYaml: "",
want: "",
},
{
name: "hyphenated component name",
stepYaml: `deploy-my-cool-service:
image: bitnami/kubectl:latest`,
want: "deploy-my-cool-service",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := extractDeployStepName(tc.stepYaml)
if got != tc.want {
t.Errorf("extractDeployStepName() = %q, want %q", got, tc.want)
}
})
}
}
func TestAddBuildCompleteDep(t *testing.T) {
baseYml := `steps:
preflight:
depends_on: [deps]
image: alpine/curl
# COMPONENT_STEPS_BELOW
build-complete:
depends_on: [preflight] # BUILD_COMPLETE_DEPS
image: alpine:3.19
commands:
- echo "All component builds complete"`
tests := []struct {
name string
yml string
stepName string
wantDeps string
}{
{
name: "first component added",
yml: baseYml,
stepName: "deploy-studio-api",
wantDeps: "depends_on: [preflight, deploy-studio-api] # BUILD_COMPLETE_DEPS",
},
{
name: "second component added",
yml: strings.Replace(baseYml,
"depends_on: [preflight] # BUILD_COMPLETE_DEPS",
"depends_on: [preflight, deploy-studio-api] # BUILD_COMPLETE_DEPS", 1),
stepName: "deploy-studio-ui",
wantDeps: "depends_on: [preflight, deploy-studio-api, deploy-studio-ui] # BUILD_COMPLETE_DEPS",
},
{
name: "duplicate detection",
yml: strings.Replace(baseYml, "depends_on: [preflight] # BUILD_COMPLETE_DEPS", "depends_on: [preflight, deploy-studio-api] # BUILD_COMPLETE_DEPS", 1),
stepName: "deploy-studio-api",
wantDeps: "depends_on: [preflight, deploy-studio-api] # BUILD_COMPLETE_DEPS",
},
{
name: "missing marker returns unchanged",
yml: "steps:\n build-complete:\n image: alpine:3.19",
stepName: "deploy-foo",
wantDeps: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := addBuildCompleteDep(tc.yml, tc.stepName)
if tc.wantDeps == "" {
// Expect no change
if got != tc.yml {
t.Errorf("expected unchanged yml, but got modification")
}
return
}
if !strings.Contains(got, tc.wantDeps) {
t.Errorf("result does not contain expected depends_on line\nwant: %s\ngot:\n%s", tc.wantDeps, got)
}
})
}
}
func TestAddBuildCompleteDep_PreservesIndentation(t *testing.T) {
yml := `steps:
build-complete:
depends_on: [preflight] # BUILD_COMPLETE_DEPS
image: alpine:3.19`
got := addBuildCompleteDep(yml, "deploy-foo")
// The indentation (8 spaces) should be preserved
want := " depends_on: [preflight, deploy-foo] # BUILD_COMPLETE_DEPS"
if !strings.Contains(got, want) {
t.Errorf("indentation not preserved\nwant line: %q\ngot:\n%s", want, got)
}
}
func TestUpdateWoodpeckerYml_WiresBuildCompleteDeps(t *testing.T) {
skeleton := `steps:
preflight:
depends_on: [deps]
image: alpine/curl
# COMPONENT_STEPS_BELOW
# Do not remove the marker above
build-complete:
depends_on: [preflight] # BUILD_COMPLETE_DEPS
image: alpine:3.19
commands:
- echo "All component builds complete"`
stepYaml := `build-studio-api:
depends_on: [preflight]
image: woodpeckerci/plugin-kaniko
verify-studio-api:
depends_on: [build-studio-api]
image: alpine/curl
deploy-studio-api:
depends_on: [verify-studio-api]
image: bitnami/kubectl:latest`
svc := &ComponentService{}
result := svc.updateWoodpeckerYml(skeleton, stepYaml)
// Verify step YAML was inserted after marker
if !strings.Contains(result, " build-studio-api:") {
t.Error("component build step not inserted")
}
if !strings.Contains(result, " verify-studio-api:") {
t.Error("component verify step not inserted")
}
if !strings.Contains(result, " deploy-studio-api:") {
t.Error("component deploy step not inserted")
}
// Verify build-complete depends_on was updated
if !strings.Contains(result, "depends_on: [preflight, deploy-studio-api] # BUILD_COMPLETE_DEPS") {
t.Errorf("build-complete depends_on not updated\ngot:\n%s", result)
}
// Verify marker is preserved for future components
if !strings.Contains(result, "# COMPONENT_STEPS_BELOW") {
t.Error("COMPONENT_STEPS_BELOW marker was removed")
}
}
func TestUpdateWoodpeckerYml_TwoComponents(t *testing.T) {
skeleton := `steps:
preflight:
depends_on: [deps]
# COMPONENT_STEPS_BELOW
build-complete:
depends_on: [preflight] # BUILD_COMPLETE_DEPS
image: alpine:3.19`
step1 := `build-studio-api:
depends_on: [preflight]
verify-studio-api:
depends_on: [build-studio-api]
deploy-studio-api:
depends_on: [verify-studio-api]`
step2 := `build-studio-ui:
depends_on: [preflight]
verify-studio-ui:
depends_on: [build-studio-ui]
deploy-studio-ui:
depends_on: [verify-studio-ui]`
svc := &ComponentService{}
// Add first component
result := svc.updateWoodpeckerYml(skeleton, step1)
// Add second component
result = svc.updateWoodpeckerYml(result, step2)
// Both deploy steps should be in build-complete depends_on
if !strings.Contains(result, "depends_on: [preflight, deploy-studio-api, deploy-studio-ui] # BUILD_COMPLETE_DEPS") {
t.Errorf("build-complete doesn't depend on both deploy steps\ngot:\n%s", result)
}
}
func TestUpdateWoodpeckerYml_CLISkipsBuildCompleteDep(t *testing.T) {
skeleton := `steps:
# COMPONENT_STEPS_BELOW
build-complete:
depends_on: [preflight] # BUILD_COMPLETE_DEPS
image: alpine:3.19`
cliStep := `build-mytool:
depends_on: [deps]
image: golang:1.25-alpine
commands:
- go build ./cmd`
svc := &ComponentService{}
result := svc.updateWoodpeckerYml(skeleton, cliStep)
// CLI has no deploy step, so build-complete should stay unchanged
if !strings.Contains(result, "depends_on: [preflight] # BUILD_COMPLETE_DEPS") {
t.Errorf("build-complete was modified for CLI component (no deploy step)\ngot:\n%s", result)
}
}
func TestUpdateWoodpeckerYml_MissingMarker(t *testing.T) {
noMarker := `steps:
build:
image: golang:1.25`
svc := &ComponentService{}
result := svc.updateWoodpeckerYml(noMarker, "deploy-foo:\n image: test")
if result != noMarker {
t.Error("expected unchanged yml when marker is missing")
}
}