diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index 9f0f5ca..863ff94 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -23,10 +23,19 @@ when: branch: main - event: tag +# The licensing audit gates the build for the same reason the vuln scan does: +# a non-permissive dependency is a legal defect in the artifact, so it must not +# reach the registry. e2e is deliberately absent — it is non-gating. +# +# These four all declare `event: [push, pull_request]`, so none of them run on a +# tag; Woodpecker treats a filtered-out dependency as satisfied, which is why a +# tag build publishes without re-running them. It is riding on the checks that +# already passed for that commit on main. depends_on: - test - lint - vuln + - licenses variables: # Keep the builder image, GOTOOLCHAIN, and the ko version in sync with the diff --git a/internal/licenses/reproducible_test.go b/internal/licenses/reproducible_test.go index b4c4cbc..d1797a2 100644 --- a/internal/licenses/reproducible_test.go +++ b/internal/licenses/reproducible_test.go @@ -142,3 +142,65 @@ func TestReleasePinsAreConsistent(t *testing.T) { t.Errorf("ko version differs: mise %q, .woodpecker/build.yaml %q", koPin, koCI) } } + +// nonGatingWorkflows are the .woodpecker workflows that are deliberately not +// prerequisites of the build: build and deploy are the thing being gated, and +// e2e is non-gating on purpose (it skips when no browser container is up, so +// requiring it would make releases depend on an advisory check). +var nonGatingWorkflows = map[string]bool{ + "build": true, + "deploy": true, + "e2e": true, +} + +// TestBuildDependsOnEveryGatingWorkflow catches a whole class of quiet failure: +// a check that runs, goes red, and is ignored because nothing depends on it. +// The licensing audit sat in exactly that state — failing on every push while +// images kept building and deploying, because build.yaml listed only test, lint +// and vuln. Adding a new check is easy to do without wiring it in, and the +// symptom is invisible (a red workflow next to a green deploy), so assert the +// wiring instead of trusting it. +func TestBuildDependsOnEveryGatingWorkflow(t *testing.T) { + root := repoRoot(t) + entries, err := os.ReadDir(filepath.Join(root, ".woodpecker")) + if err != nil { + t.Fatalf("read .woodpecker: %v", err) + } + + build := readRepoFile(t, ".woodpecker/build.yaml") + // The depends_on block runs to the next top-level key. + dependsRE := regexp.MustCompile(`(?ms)^depends_on:\s*\n((?:\s*-\s*\S+\s*\n)+)`) + m := dependsRE.FindStringSubmatch(build) + if m == nil { + t.Fatal(".woodpecker/build.yaml: found no depends_on list") + } + declared := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(m[1]), "\n") { + declared[strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))] = true + } + + found := 0 + for _, e := range entries { + name, ok := strings.CutSuffix(e.Name(), ".yaml") + if e.IsDir() || !ok || nonGatingWorkflows[name] { + continue + } + found++ + if !declared[name] { + t.Errorf(".woodpecker/%s.yaml is a check but build.yaml does not depend on it, "+ + "so it can fail while the image still builds and deploys. Add it to depends_on, "+ + "or to nonGatingWorkflows with a reason.", name) + } + } + if found == 0 { + t.Error("found no gating workflows in .woodpecker — has the CI layout changed?") + } + + // A name in depends_on that matches no workflow file is silently ignored by + // Woodpecker, which looks like a gate but is not one. + for name := range declared { + if _, err := os.Stat(filepath.Join(root, ".woodpecker", name+".yaml")); err != nil { + t.Errorf("build.yaml depends on %q, which is not a .woodpecker workflow", name) + } + } +}