Skip to content

Commit 79f4eef

Browse files
ci+test: consolidated fail-fast CI + tests/ folder with functional & e2e CLI tests (#18)
Per request: a dedicated tests/ folder driving the built binary, a consolidated fail-fast CI, Docker for the tests that need it, and dependency caching. tests/ (new): - tests/e2e — drives the compiled `devstack` CLI. All `//go:build e2e` (never in the fast unit lane; builds the binary once via TestMain). - functional (no daemon): generate + --check, config validate, template list, status, version, and `up` outside a workspace errors. - daemon e2e (Docker): a full `up → status → re-up(idempotent) → down` against a real Engine, gated on DEVSTACK_E2E=1 (it mutates the shared stack) with t.Cleanup teardown of the stack + network. Verified locally (14s, green) and leaves the machine clean. - tests/README.md documents the layout + how to run. CI (.github/workflows/ci.yml): collapse 7 jobs → 2. - `ci`: one lane ordered cheap → expensive so a lint/unit failure fails fast before the costly Docker + cross-compile work: gofmt → vet → installer lint → static build → unit -race → smoke → determinism → cross-compile (4 targets in one step) → govulncheck → integration (-tags=integration -race) → e2e (-tags=e2e, DEVSTACK_E2E=1). setup-go caches GOMODCACHE+GOCACHE (keyed by go.sum); ubuntu-latest's Docker backs the integration + e2e steps. - `release-dryrun`: kept separate (expensive, independent, needs full history) so it never gates the fast feedback. - concurrency group cancels superseded runs. Makefile: `make integration` + `make e2e` targets; help regex now matches names with digits (e2e). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9b6dfca commit 79f4eef

5 files changed

Lines changed: 318 additions & 79 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 44 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -8,98 +8,84 @@ on:
88
permissions:
99
contents: read
1010

11+
# Cancel superseded runs on the same ref (fail-fast + CI economy).
12+
concurrency:
13+
group: ci-${{ github.ref }}
14+
cancel-in-progress: true
15+
1116
env:
1217
# Single enforced Go toolchain floor (DECISIONS, ARCHITECTURE §7.8).
1318
GO_VERSION: "1.25"
1419

1520
jobs:
16-
build-test:
21+
# One consolidated lane ordered cheap → expensive so a lint/unit failure stops
22+
# before the costly Docker + cross-compile work. actions/setup-go caches the
23+
# module + build cache (keyed by go.sum). ubuntu-latest ships Docker, used by
24+
# the integration + e2e steps.
25+
ci:
1726
runs-on: ubuntu-latest
1827
steps:
1928
- uses: actions/checkout@v4
2029
- uses: actions/setup-go@v5
2130
with:
2231
go-version: ${{ env.GO_VERSION }}
2332
check-latest: true
33+
cache: true # GOMODCACHE + GOCACHE, keyed by go.sum
2434

35+
# --- cheap, high-signal (fail fast) ---
2536
- name: gofmt
2637
run: |
2738
unformatted=$(gofmt -l .)
2839
if [ -n "$unformatted" ]; then
2940
echo "These files are not gofmt-clean:"; echo "$unformatted"; exit 1
3041
fi
31-
3242
- name: go vet
3343
run: go vet ./...
34-
44+
- name: installer lint (shellcheck + POSIX sh)
45+
run: |
46+
shellcheck --severity=warning install.sh
47+
sh -n install.sh
3548
- name: build (CGO disabled — static binary invariant)
3649
run: CGO_ENABLED=0 go build ./...
37-
38-
- name: test -race
50+
- name: unit tests -race
3951
env:
4052
CGO_ENABLED: "1" # the race detector requires cgo (see Makefile)
4153
run: go test -race ./...
54+
- name: smoke (built binary, end-to-end in an XDG sandbox)
55+
run: make smoke
56+
- name: determinism (byte-identical generation)
57+
run: make determinism
4258

43-
govulncheck:
44-
runs-on: ubuntu-latest
45-
steps:
46-
- uses: actions/checkout@v4
47-
- uses: actions/setup-go@v5
48-
with:
49-
go-version: ${{ env.GO_VERSION }}
50-
check-latest: true
59+
# --- medium ---
60+
- name: cross-compile (4 CGO-free release targets)
61+
env:
62+
CGO_ENABLED: "0"
63+
run: |
64+
for t in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
65+
echo "→ $t"
66+
GOOS=${t%/*} GOARCH=${t#*/} go build -o /dev/null ./cmd/devstack
67+
done
5168
- name: govulncheck
5269
run: |
5370
go install golang.org/x/vuln/cmd/govulncheck@latest
5471
govulncheck ./...
5572
56-
cross-compile:
57-
# Proves the 4 release targets build CGO-free from one Linux runner.
58-
runs-on: ubuntu-latest
59-
strategy:
60-
matrix:
61-
goos: [linux, darwin]
62-
goarch: [amd64, arm64]
63-
steps:
64-
- uses: actions/checkout@v4
65-
- uses: actions/setup-go@v5
66-
with:
67-
go-version: ${{ env.GO_VERSION }}
68-
check-latest: true
69-
- name: build ${{ matrix.goos }}/${{ matrix.goarch }}
73+
# --- expensive, real-daemon (Docker on ubuntu-latest) ---
74+
- name: docker available
75+
run: docker version
76+
- name: integration tests (-tags=integration -race)
7077
env:
71-
CGO_ENABLED: "0"
72-
GOOS: ${{ matrix.goos }}
73-
GOARCH: ${{ matrix.goarch }}
74-
run: go build -o /dev/null ./cmd/devstack
75-
76-
determinism:
77-
# Asserts the generation pipeline is byte-identical across runs/paths
78-
# (spec 02 acceptance #3, ARCHITECTURE §3) — the rebuild-hash and any
79-
# "commit generated artifacts" decision depend on it.
80-
runs-on: ubuntu-latest
81-
steps:
82-
- uses: actions/checkout@v4
83-
- uses: actions/setup-go@v5
84-
with:
85-
go-version: ${{ env.GO_VERSION }}
86-
check-latest: true
87-
- name: determinism (byte-identical generation)
88-
run: make determinism
89-
90-
installer:
91-
# Lints the curl|sh installer so a broken install path is caught before release.
92-
runs-on: ubuntu-latest
93-
steps:
94-
- uses: actions/checkout@v4
95-
- name: shellcheck install.sh
96-
run: shellcheck --severity=warning install.sh
97-
- name: POSIX sh syntax
98-
run: sh -n install.sh
78+
CGO_ENABLED: "1"
79+
run: go test -tags=integration -race ./...
80+
- name: e2e tests (-tags=e2e, real up/down via the CLI)
81+
env:
82+
DEVSTACK_E2E: "1" # ephemeral runner: safe to mutate the shared stack
83+
run: go test -tags=e2e ./tests/e2e/...
9984

85+
# The full release pipeline (4 CGO-free targets + archives + checksums +
86+
# .deb/.rpm) — expensive and independent, so it runs in parallel and never
87+
# gates the fast feedback above. Needs full history for goreleaser's versioning.
10088
release-dryrun:
101-
# Proves the full release pipeline (4 CGO-free targets + archives + checksums
102-
# + .deb/.rpm) builds, without tagging — so a tag push never fails late.
10389
runs-on: ubuntu-latest
10490
steps:
10591
- uses: actions/checkout@v4
@@ -109,31 +95,12 @@ jobs:
10995
with:
11096
go-version: ${{ env.GO_VERSION }}
11197
check-latest: true
98+
cache: true
11299
- uses: goreleaser/goreleaser-action@v6
113100
with:
114101
version: "~> v2"
115102
args: release --snapshot --clean
116103

117-
integration:
118-
# Real-daemon lane (G1): runs the //go:build integration tests against
119-
# ubuntu-latest's Docker (read-only inspect/logs, health polling, compose
120-
# exec). Tests self-isolate with per-run names (devstack-it-<pid>) + t.Cleanup
121-
# and skip gracefully if the daemon is unreachable.
122-
runs-on: ubuntu-latest
123-
steps:
124-
- uses: actions/checkout@v4
125-
- uses: actions/setup-go@v5
126-
with:
127-
go-version: ${{ env.GO_VERSION }}
128-
check-latest: true
129-
- name: docker available
130-
run: docker version
131-
- name: integration tests (-tags=integration -race)
132-
env:
133-
CGO_ENABLED: "1" # race detector needs cgo
134-
run: go test -tags=integration -race ./...
135-
136104
# Placeholder lanes wired as their milestones land:
137-
# - schema-drift: JSON-Schema ↔ Go-struct round-trip check (M1)
138105
# - macos: macos-14 arm64 preflight-only (G2)
139106
# - config-conformance: golden workspace exercising every schema field

‎Makefile‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ BINDIR ?= $(or $(XDG_BIN_HOME),$(PREFIX)/bin)
2121

2222
# The release binary MUST be CGO-free (static), but `go test -race` REQUIRES cgo.
2323
# So CGO is set per-target, never globally.
24-
.PHONY: build run test test-race test-one vet fmt fmt-check lint tidy vuln clean snapshot ci determinism install uninstall smoke help
24+
.PHONY: build run test test-race integration e2e test-one vet fmt fmt-check lint tidy vuln clean snapshot ci determinism install uninstall smoke help
2525

2626
build: ## Build the static binary into ./dist
2727
CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) ./cmd/devstack
@@ -35,6 +35,12 @@ test: ## Run unit tests
3535
test-race: ## Run unit tests with the race detector (needs cgo)
3636
CGO_ENABLED=1 go test -race ./...
3737

38+
integration: ## Run the real-daemon integration tests (needs Docker + cgo)
39+
CGO_ENABLED=1 go test -tags=integration -race ./...
40+
41+
e2e: ## Run the CLI end-to-end tests (builds the binary; mutates Docker)
42+
DEVSTACK_E2E=1 go test -tags=e2e ./tests/e2e/...
43+
3844
# Run a single test, e.g.: make test-one RUN=TestSerializesAcquire PKG=./internal/lock
3945
test-one:
4046
CGO_ENABLED=1 go test -race -run '$(RUN)' -v $(PKG)
@@ -125,4 +131,4 @@ clean:
125131
rm -rf dist
126132

127133
help:
128-
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
134+
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'

‎tests/README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# tests/
2+
3+
Cross-cutting tests that exercise the **built `devstack` binary** end to end,
4+
complementing the per-package unit + `//go:build integration` tests under
5+
`internal/`.
6+
7+
## Layout
8+
9+
- **`e2e/`** — drives the compiled CLI in an isolated XDG sandbox + temp
10+
workspace. Two tiers, both behind `//go:build e2e` (so they never run in the
11+
fast unit lane and only build the binary when asked):
12+
- **functional** (no daemon): `generate`, `config validate`, `template list`,
13+
`status`, `version`, and error paths like `up` outside a workspace.
14+
- **daemon e2e** (Docker): a full `up → status → re-up(idempotent) → down`
15+
against a real Engine. Gated on `DEVSTACK_E2E=1` because it mutates Docker
16+
(the shared stack + `devstack_shared` network); each test cleans up after
17+
itself with `t.Cleanup`.
18+
19+
## Running
20+
21+
```bash
22+
make e2e # DEVSTACK_E2E=1 go test -tags=e2e ./tests/e2e/... (needs Docker)
23+
go test -tags=e2e ./tests/e2e/... # functional only (daemon tests skip)
24+
make integration # the internal/ -tags=integration suite (needs Docker)
25+
```
26+
27+
CI runs all of these in the consolidated `ci` lane (cheap → expensive, fail-fast)
28+
on a Docker-enabled runner; see `.github/workflows/ci.yml`.

‎tests/e2e/cli_test.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"os/exec"
7+
"strings"
8+
"testing"
9+
)
10+
11+
const wsSharedPG = `apiVersion: devstack/v1
12+
kind: Workspace
13+
name: e2e
14+
shared:
15+
postgres: { template: postgres, params: { version: "16" } }
16+
projects:
17+
- { name: app, path: app }
18+
`
19+
20+
const projNodeUsesPG = `apiVersion: devstack/v1
21+
kind: Project
22+
name: app
23+
services:
24+
web:
25+
template: node.vite
26+
uses: [workspace.shared.postgres]
27+
`
28+
29+
func appWorkspace() map[string]string {
30+
return map[string]string{"workspace.yaml": wsSharedPG, "app/devstack.yaml": projNodeUsesPG}
31+
}
32+
33+
// --- functional (no daemon) -------------------------------------------------
34+
35+
func TestFunctional_Version(t *testing.T) {
36+
s := newSandbox(t, appWorkspace())
37+
if out := s.run(t, "version"); !strings.Contains(out, "commit") {
38+
t.Errorf("version output = %q, want it to mention commit", out)
39+
}
40+
}
41+
42+
func TestFunctional_GenerateValidateTemplate(t *testing.T) {
43+
s := newSandbox(t, appWorkspace())
44+
45+
s.run(t, "generate")
46+
// Shared + project compose materialized.
47+
for _, rel := range []string{".devstack/shared/docker-compose.yaml", "app/.devstack/docker-compose.yaml"} {
48+
if _, err := exec.Command("test", "-f", s.ws+"/"+rel).Output(); err != nil {
49+
t.Errorf("expected generated file %s", rel)
50+
}
51+
}
52+
// --check is idempotent right after generate.
53+
s.run(t, "generate", "--check")
54+
// config validate passes; template list shows a built-in.
55+
s.run(t, "config", "validate")
56+
if out := s.run(t, "template", "list"); !strings.Contains(out, "php.laravel.nginx") {
57+
t.Errorf("template list missing a built-in:\n%s", out)
58+
}
59+
}
60+
61+
func TestFunctional_StatusAndDoctor(t *testing.T) {
62+
s := newSandbox(t, appWorkspace())
63+
if out := s.run(t, "status"); !strings.Contains(out, "SHARED") {
64+
t.Errorf("status output missing SHARED section:\n%s", out)
65+
}
66+
// doctor may exit non-zero when the daemon is down; we only assert the JSON
67+
// contract is emitted.
68+
out, _ := s.tryRun("doctor", "--json")
69+
if !strings.Contains(out, `"checks"`) {
70+
t.Errorf("doctor --json missing checks:\n%s", out)
71+
}
72+
}
73+
74+
func TestFunctional_UpOutsideWorkspaceErrors(t *testing.T) {
75+
// A sandbox with no workspace.yaml.
76+
s := newSandbox(t, map[string]string{"README": "no workspace here"})
77+
if out, err := s.tryRun("up"); err == nil {
78+
t.Errorf("up outside a workspace should fail; got:\n%s", out)
79+
}
80+
}
81+
82+
// --- daemon e2e (Docker; DEVSTACK_E2E=1) ------------------------------------
83+
84+
func TestE2E_UpStatusDown(t *testing.T) {
85+
requireDaemon(t)
86+
s := newSandbox(t, appWorkspace())
87+
t.Cleanup(func() {
88+
_, _ = s.tryRun("down")
89+
dockerComposeDown("devstack-shared")
90+
dockerComposeDown("devstack-app")
91+
_ = exec.Command("docker", "network", "rm", "devstack_shared").Run()
92+
})
93+
94+
// First up: full saga, shared-postgres health-gated, project started.
95+
out := s.run(t, "up")
96+
if !strings.Contains(out, "[ok]") || strings.Contains(out, "[failed]") {
97+
t.Fatalf("up did not complete cleanly:\n%s", out)
98+
}
99+
100+
// Status shows the shared service with a ref from app.
101+
st := s.run(t, "status")
102+
if !strings.Contains(st, "shared-postgres") {
103+
t.Errorf("status missing shared-postgres:\n%s", st)
104+
}
105+
106+
// Re-run is idempotent: satisfied phases skip.
107+
reup := s.run(t, "up")
108+
if !strings.Contains(reup, "[skipped]") {
109+
t.Errorf("re-run should skip satisfied phases:\n%s", reup)
110+
}
111+
112+
// down stops the project + releases its ref; shared keeps running.
113+
s.run(t, "down")
114+
}

0 commit comments

Comments
 (0)