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. 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. 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:** **Component Variables:**
```go ```go
{{.ProjectName}} // "acme" {{.ProjectName}} // "acme"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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