fix(templates): harden component CI steps and compile regexes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
parent
d63f827713
commit
9f957d6e75
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
277
internal/service/component_updates_test.go
Normal file
277
internal/service/component_updates_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user