diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5459892..e823375 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,32 @@ jobs: version: "~> v2" args: release --snapshot --clean + # Native macOS arm64 lane (G2): proves the darwin/arm64 RUNTIME target — not just + # the cross-compile on the Linux lane — actually builds and passes its daemon-free + # tests. Hosted macOS runners have no Docker, so the integration/e2e (daemon) + # steps stay on the ubuntu `ci` lane; this lane runs build + unit(-race) + a + # binary preflight. Invoked via `go` directly (not make: hosted macOS ships BSD + # make, and the Makefile uses GNU features). + macos: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + cache: true + - name: build (CGO disabled — static binary invariant) + run: CGO_ENABLED=0 go build ./... + - name: unit tests -race + env: + CGO_ENABLED: "1" # the race detector requires cgo + run: go test -race ./... + - name: binary preflight (runs natively on arm64) + run: | + go build -o devstack ./cmd/devstack + ./devstack version + ./devstack --help >/dev/null + # Placeholder lanes wired as their milestones land: - # - macos: macos-14 arm64 preflight-only (G2) # - config-conformance: golden workspace exercising every schema field diff --git a/internal/cli/postpull_test.go b/internal/cli/postpull_test.go new file mode 100644 index 0000000..f656f77 --- /dev/null +++ b/internal/cli/postpull_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/git" +) + +func TestGitHeadTracksCommits(t *testing.T) { + requireGit(t) + dir := t.TempDir() + gitInitRepo(t, dir) + gx, err := git.New() + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + h1, err := gx.Head(ctx, dir) + if err != nil || len(h1) < 7 { + t.Fatalf("Head = %q, err %v", h1, err) + } + // A new commit moves HEAD. + if err := os.WriteFile(filepath.Join(dir, "f"), []byte("y\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", "two"}} { + c := exec.Command("git", args...) + c.Dir = dir + c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + h2, err := gx.Head(ctx, dir) + if err != nil { + t.Fatal(err) + } + if h2 == h1 { + t.Error("HEAD should change after a new commit") + } +} + +func TestRunPostPullExecutesHostHooks(t *testing.T) { + dir := t.TempDir() + r := repo{name: "app", dir: dir} + hook := config.Hook{ + Name: "setup", + Run: "host", + Command: []string{"sh", "-c", "echo ran > postpull.marker"}, + } + if err := runPostPull(context.Background(), r, "deadbeef", []config.Hook{hook}); err != nil { + t.Fatalf("runPostPull: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "postpull.marker")); err != nil { + t.Errorf("postPull host hook did not run in the repo dir: %v", err) + } +} + +func TestRunPostPullAbortPropagates(t *testing.T) { + dir := t.TempDir() + r := repo{name: "app", dir: dir} + hook := config.Hook{ + Name: "boom", + Run: "host", + Command: []string{"sh", "-c", "exit 7"}, + OnFailure: "abort", + } + if err := runPostPull(context.Background(), r, "sha", []config.Hook{hook}); err == nil { + t.Fatal("an onFailure:abort postPull hook must surface an error") + } +} + +func TestRunPostPullDefaultsToWarn(t *testing.T) { + // With no onFailure, a failing postPull hook defaults to warn → sync still ok. + dir := t.TempDir() + r := repo{name: "app", dir: dir} + hook := config.Hook{Name: "soft", Run: "host", Command: []string{"sh", "-c", "exit 1"}} + if err := runPostPull(context.Background(), r, "sha", []config.Hook{hook}); err != nil { + t.Errorf("default onFailure should be warn (no error), got %v", err) + } +} diff --git a/internal/cli/ws.go b/internal/cli/ws.go index 25e315d..8951e00 100644 --- a/internal/cli/ws.go +++ b/internal/cli/ws.go @@ -13,7 +13,9 @@ import ( "golang.org/x/sync/errgroup" "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/generate" "github.com/open-source-cloud/devstack/internal/git" + "github.com/open-source-cloud/devstack/internal/hooks" ) // repo is one workspace repository to manage. @@ -259,10 +261,13 @@ func newWsCloneCmd(g *GlobalOpts) *cobra.Command { // --- ws sync --------------------------------------------------------------- func newWsSyncCmd(g *GlobalOpts) *cobra.Command { - var jobs int + var ( + jobs int + noHooks bool + ) cmd := &cobra.Command{ Use: "sync [names...]", - Short: "Fetch + fast-forward pull every repo in parallel", + Short: "Fetch + fast-forward pull every repo in parallel (runs postPull hooks on new commits)", RunE: func(cmd *cobra.Command, args []string) error { repos, err := loadRepos(args) if err != nil { @@ -275,22 +280,77 @@ func newWsSyncCmd(g *GlobalOpts) *cobra.Command { if jobs <= 0 { jobs = defaultJobs() } + // postPull hooks come from each project's devstack.yaml. Load the full + // model best-effort: if a sibling repo isn't cloned yet the load fails, + // so we degrade to git-only sync (postPull just doesn't fire). + postPull := map[string][]config.Hook{} + if !noHooks { + if m, lerr := config.Load(mustCwd()); lerr == nil { + for name, p := range m.Projects { + if len(p.Hooks.PostPull) > 0 { + postPull[name] = p.Hooks.PostPull + } + } + } else if !g.Quiet { + fmt.Fprintf(cmd.ErrOrStderr(), "[note] postPull hooks skipped: %v\n", lerr) + } + } + results := runPerRepo(cmd.Context(), repos, jobs, func(ctx context.Context, r repo) error { if !gx.IsRepo(ctx, r.dir) { return fmt.Errorf("not cloned (run `ws clone`)") } + before, _ := gx.Head(ctx, r.dir) // "" if it can't be read; treated as changed if err := gx.Fetch(ctx, r.dir); err != nil { return err } - return gx.Pull(ctx, r.dir) + if err := gx.Pull(ctx, r.dir); err != nil { + return err + } + after, _ := gx.Head(ctx, r.dir) + // postPull fires only when the pull advanced HEAD (spec 11): the + // SHA change IS the trigger, so no ledger is needed here. + if hooks := postPull[r.name]; len(hooks) > 0 && after != before { + return runPostPull(ctx, r, after, hooks) + } + return nil }) return reportResults(cmd, g, "sync", results) }, } cmd.Flags().IntVar(&jobs, "jobs", 0, "max parallel syncs (default min(8, 2*CPUs))") + cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip postPull hooks") return cmd } +// runPostPull runs a project's postPull hooks after its worktree advanced to head. +// Host hooks run in the repo dir; compose-exec hooks target the project stack if +// its generated compose exists. No state ledger: the HEAD change is the trigger, +// so hooks run exactly once per pulled revision. preDown-style warn default keeps +// a broken setup hook from failing the whole sync. +func runPostPull(ctx context.Context, r repo, head string, hookList []config.Hook) error { + composeFile := filepath.Join(r.dir, generate.GenDir, generate.ComposeFile) + if _, err := os.Stat(composeFile); err != nil { + composeFile = "" + } + runner := &hooks.Runner{ + Execer: hooks.OSExecer{BaseDir: r.dir, Project: "devstack-" + r.name, File: composeFile}, + } + _, err := runner.RunPhase(ctx, hookList, hooks.PhaseOpts{ + Project: r.name, + Phase: "postPull", + DefaultOnFailure: hooks.OnWarn, + ScopeKey: func(config.Hook) string { return head }, // satisfies `once:` hooks (no ledger → runs) + }) + return err +} + +// mustCwd returns the working directory (empty on error — callers degrade). +func mustCwd() string { + cwd, _ := os.Getwd() + return cwd +} + // --- ws git ---------------------------------------------------------------- func newWsGitCmd(g *GlobalOpts) *cobra.Command { diff --git a/internal/git/gitx.go b/internal/git/gitx.go index a4a3dad..a0100c6 100644 --- a/internal/git/gitx.go +++ b/internal/git/gitx.go @@ -183,6 +183,16 @@ func (g *Git) Pull(ctx context.Context, dir string) error { return err } +// Head returns the current commit SHA (HEAD). Used to detect whether a pull +// actually advanced the worktree (the postPull-hook trigger, spec 11). +func (g *Git) Head(ctx context.Context, dir string) (string, error) { + out, err := g.run(ctx, dir, "--no-optional-locks", "rev-parse", "HEAD") + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + // RemoteURL returns origin's URL (for idempotent clone validation). func (g *Git) RemoteURL(ctx context.Context, dir string) (string, error) { out, err := g.run(ctx, dir, "--no-optional-locks", "remote", "get-url", "origin")